/** * Functions for HtmlEditorFields in the back end. * Includes the JS for the ImageUpload forms. * * Relies on the jquery.form.js plugin to power the * ajax / iframe submissions */ var ss = ss || {}; /** * Wrapper for HTML WYSIWYG libraries, which abstracts library internals * from interface concerns like inserting and editing links. * Caution: Incomplete and unstable API. */ ss.editorWrappers = {}; ss.editorWrappers.tinyMCE = (function() { var instance; return { init: function(config) { if(!ss.editorWrappers.tinyMCE.initialized) { tinyMCE.init(config); ss.editorWrappers.tinyMCE.initialized = true; } }, /** * @return Mixed Implementation specific object */ getInstance: function() { return this.instance; }, /** * Invoked when a content-modifying UI is opened. */ onopen: function() { }, /** * Invoked when a content-modifying UI is closed. */ onclose: function() { }, /** * Write the HTML back to the original text area field. */ save: function() { tinyMCE.triggerSave(); }, /** * Create a new instance based on a textarea field. * * Please proxy the events from your editor implementation into JS events * on the textarea field. For events that do not map directly, use the * following naming scheme: editor<event>. * * @param String * @param Object Implementation specific configuration * @param Function */ create: function(domID, config) { this.instance = new tinymce.Editor(domID, config); // Patch TinyMCE events into underlying textarea field. this.instance.onInit.add(function(ed) { if(!ss.editorWrappers.tinyMCE.patched) { // Not ideal, but there's a memory leak we need to patch var originalDestroy = tinymce.themes.AdvancedTheme.prototype.destroy; tinymce.themes.AdvancedTheme.prototype.destroy = function() { originalDestroy.apply(this, arguments); if (this.statusKeyboardNavigation) { this.statusKeyboardNavigation.destroy(); this.statusKeyboardNavigation = null; } } ss.editorWrappers.tinyMCE.patched = true; } jQuery(ed.getElement()).trigger('editorinit'); // Periodically check for inline changes when focused, // since TinyMCE's onChange only fires on certain actions // like inserting a new paragraph, as opposed to any user input. // This also works around an issue where the "save" button // wouldn't trigger if the click is the cause of a "blur" event // after an (undetected) inline change. This "blur" causes onChange // to trigger, which will change the button markup to show "alternative" styles, // effectively cancelling the original click event. var interval; jQuery(ed.getBody()).on('focus', function() { interval = setInterval(function() { ed.save(); }, 5000); }); jQuery(ed.getBody()).on('blur', function() { clearInterval(interval); }); }); this.instance.onChange.add(function(ed, l) { // Update underlying textarea on every change, so external handlers // such as changetracker have a chance to trigger properly. ed.save(); jQuery(ed.getElement()).trigger('change'); }); // Add more events here as needed. this.instance.render(); }, /** * Redraw the editor contents */ repaint: function() { tinyMCE.execCommand("mceRepaint"); }, /** * @return boolean */ isDirty: function() { return this.getInstance().isDirty(); }, /** * HTML representation of the edited content. * * Returns: {String} */ getContent: function() { return this.getInstance().getContent(); }, /** * DOM tree of the edited content * * Returns: DOMElement */ getDOM: function() { return this.getInstance().dom; }, /** * Returns: DOMElement */ getContainer: function() { return this.getInstance().getContainer(); }, /** * Get the closest node matching the current selection. * * Returns: {jQuery} DOMElement */ getSelectedNode: function() { return this.getInstance().selection.getNode(); }, /** * Select the given node within the editor DOM * * Parameters: {DOMElement} */ selectNode: function(node) { this.getInstance().selection.select(node); }, /** * Replace entire content * * @param String HTML * @param Object opts */ setContent: function(html, opts) { this.getInstance().execCommand('mceSetContent', false, html, opts); }, /** * Insert content at the current caret position * * @param String HTML */ insertContent: function(html, opts) { this.getInstance().execCommand('mceInsertContent', false, html, opts); }, /** * Replace currently selected content * * @param {String} html */ replaceContent: function(html, opts) { this.getInstance().execCommand('mceReplaceContent', false, html, opts); }, /** * Insert or update a link in the content area (based on current editor selection) * * Parameters: {Object} attrs */ insertLink: function(attrs, opts) { this.getInstance().execCommand("mceInsertLink", false, attrs, opts); }, /** * Remove the link from the currently selected node (if any). */ removeLink: function() { this.getInstance().execCommand('unlink', false); }, /** * Strip any editor-specific notation from link in order to make it presentable in the UI. * * Parameters: * {Object} * {DOMElement} */ cleanLink: function(href, node) { var cb = tinyMCE.settings['urlconverter_callback']; if(cb) href = eval(cb + "(href, node, true);"); // Turn into relative if(href.match(new RegExp('^' + tinyMCE.settings['document_base_url'] + '(.*)$'))) { href = RegExp.$1; } // Get rid of TinyMCE's temporary URLs if(href.match(/^javascript:\s*mctmp/)) href = ''; return href; }, /** * Creates a bookmark for the currently selected range, * which can be used to reselect this range at a later point. * @return {mixed} */ createBookmark: function() { return this.getInstance().selection.getBookmark(); }, /** * Selects a bookmarked range previously saved through createBookmark(). * @param {mixed} bookmark */ moveToBookmark: function(bookmark) { this.getInstance().selection.moveToBookmark(bookmark); this.getInstance().focus(); }, /** * Removes any selection & de-focuses this editor */ blur: function() { this.getInstance().selection.collapse(); }, /** * Add new undo point with the current DOM content. */ addUndo: function() { this.getInstance().undoManager.add(); } }; }); // Override this to switch editor wrappers ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE; (function($) { $.entwine('ss', function($) { /** * Class: textarea.htmleditor * * Add tinymce to HtmlEditorFields within the CMS. Works in combination * with a TinyMCE.init() call which is prepopulated with the used HTMLEditorConfig settings, * and included in the page as an inline <script> tag. */ $('textarea.htmleditor').entwine({ Editor: null, /** * Constructor: onmatch */ onadd: function() { var edClass = this.data('editor') || 'default', ed = ss.editorWrappers[edClass](); this.setEditor(ed); // Using a global config (generated through HTMLEditorConfig PHP logic). // Depending on browser cache load behaviour, entwine's DOMMaybeChanged // can be called before the bottom-most inline script tag is executed, // which defines the global. If that's the case, wait for the window load. if(typeof ssTinyMceConfig != 'undefined') this.redraw(); this._super(); }, onremove: function() { var ed = tinyMCE.get(this.attr('id')); if (ed) { ed.remove(); ed.destroy(); // TinyMCE leaves behind events. We should really fix TinyMCE, but lets brute force it for now $.each(jQuery.cache, function(){ var source = this.handle && this.handle.elem; if (!source) return; var parent = source; while (parent && parent.nodeType == 1) parent = parent.parentNode; if (!parent) $(source).unbind().remove(); }); } this._super(); }, getContainingForm: function(){ return this.closest('form'); }, fromWindow: { onload: function(){ this.redraw(); } }, redraw: function() { // Using a global config (generated through HTMLEditorConfig PHP logic) var config = ssTinyMceConfig, self = this, ed = this.getEditor(); ed.init(config); // Create editor instance and render it. // Similar logic to adapter/jquery/jquery.tinymce.js, but doesn't rely on monkey-patching // jQuery methods, and avoids replicate the script lazyloading which is already in place with jQuery.ondemand. ed.create(this.attr('id'), config); this._super(); }, /** * Make sure the editor has flushed all it's buffers before the form is submitted. */ 'from .cms-edit-form': { onbeforesubmitform: function(e) { this.getEditor().save(); this._super(); } }, oneditorinit: function() { // Delayed show because TinyMCE calls hide() via setTimeout on removing an element, // which is called in quick succession with adding a new editor after ajax loading new markup //storing the container object before setting timeout var redrawObj = $(this.getEditor().getInstance().getContainer()); setTimeout(function() { redrawObj.show(); }, 10); }, 'from .cms-container': { onbeforestatechange: function(){ this.css('visibility', 'hidden'); var ed = this.getEditor(), container = (ed && ed.getInstance()) ? ed.getContainer() : null; if(container && container.length) container.remove(); } }, isChanged: function() { var ed = this.getEditor(); return (ed && ed.getInstance() && ed.isDirty()); }, resetChanged: function() { var ed = this.getEditor(); if(typeof tinyMCE == 'undefined') return; // TODO Abstraction layer var inst = tinyMCE.getInstanceById(this.attr('id')); if (inst) inst.startContent = tinymce.trim(inst.getContent({format : 'raw', no_events : 1})); }, openLinkDialog: function() { this.openDialog('link'); }, openMediaDialog: function() { this.openDialog('media'); }, openDialog: function(type) { var capitalize = function(text) { return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); }; var self = this, url = $('#cms-editor-dialogs').data('url' + capitalize(type) + 'form'), dialog = $('.htmleditorfield-' + type + 'dialog'); if(dialog.length) { dialog.getForm().setElement(this); dialog.open(); } else { // Show a placeholder for instant feedback. Will be replaced with actual // form dialog once its loaded. dialog = $('<div class="htmleditorfield-dialog htmleditorfield-' + type + 'dialog loading">'); $('body').append(dialog); $.ajax({ url: url, complete: function() { dialog.removeClass('loading'); }, success: function(html) { dialog.html(html); dialog.getForm().setElement(self); dialog.trigger('ssdialogopen'); } }); } } }); $('.htmleditorfield-dialog').entwine({ onadd: function() { // Create jQuery dialog if (!this.is('.ui-dialog-content')) { this.ssdialog({autoOpen: true}); } this._super(); }, getForm: function() { return this.find('form'); }, open: function() { this.ssdialog('open'); }, close: function() { this.ssdialog('close'); }, toggle: function(bool) { if(this.is(':visible')) this.close(); else this.open(); } }); /** * Base form implementation for interactions with an editor instance, * mostly geared towards modification and insertion of content. */ $('form.htmleditorfield-form').entwine({ Selection: null, // Implementation-dependent serialization of the current editor selection state Bookmark: null, // DOMElement pointing to the currently active textarea Element: null, setSelection: function(node) { return this._super($(node)); }, onadd: function() { // Move title from headline to (jQuery compatible) title attribute var titleEl = this.find(':header:first'); this.getDialog().attr('title', titleEl.text()); this._super(); }, onremove: function() { this.setSelection(null); this.setBookmark(null); this.setElement(null); this._super(); }, getDialog: function() { // TODO Refactor to listen to form events to remove two-way coupling return this.closest('.htmleditorfield-dialog'); }, fromDialog: { onssdialogopen: function(){ var ed = this.getEditor(); ed.onopen(); this.setSelection(ed.getSelectedNode()); this.setBookmark(ed.createBookmark()); ed.blur(); this.find(':input:not(:submit)[data-skip-autofocus!="true"]').filter(':visible:enabled').eq(0).focus(); this.updateFromEditor(); this.redraw(); }, onssdialogclose: function(){ var ed = this.getEditor(); ed.onclose(); ed.moveToBookmark(this.getBookmark()); this.setSelection(null); this.setBookmark(null); this.resetFields(); } }, /** * @return Object ss.editorWrapper instance */ getEditor: function(){ return this.getElement().getEditor(); }, modifySelection: function(callback) { var ed = this.getEditor(); ed.moveToBookmark(this.getBookmark()); callback.call(this, ed); this.setSelection(ed.getSelectedNode()); this.setBookmark(ed.createBookmark()); ed.blur(); }, updateFromEditor: function() { /* NOP */ }, redraw: function() { /* NOP */ }, resetFields: function() { // Flush the tree drop down fields, as their content might get changed in other parts of the CMS, ie in Files and images this.find('.tree-holder').empty(); } }); /** * Inserts and edits links in an html editor, including internal/external web links, * links to files on the webserver, email addresses, and anchors in the existing html content. * Every variation has its own fields (e.g. a "target" attribute doesn't make sense for an email link), * which are toggled through a type dropdown. Variations share fields, so there's only one "title" field in the form. */ $('form.htmleditorfield-linkform').entwine({ // TODO Entwine doesn't respect submits triggered by ENTER key onsubmit: function(e) { this.insertLink(); this.getDialog().close(); return false; }, resetFields: function() { this._super(); // Reset the form using a native call. This will also correctly reset checkboxes and radio buttons. this[0].reset(); }, redraw: function() { this._super(); var linkType = this.find(':input[name=LinkType]:checked').val(), list = ['internal', 'external', 'file', 'email']; this.addAnchorSelector(); // Toggle field visibility depending on the link type. this.find('div.content .field').hide(); this.find('.field#LinkType').show(); this.find('.field#' + linkType).show(); if(linkType == 'internal' || linkType == 'anchor') this.find('.field#Anchor').show(); if(linkType !== 'email') this.find('.field#TargetBlank').show(); if(linkType == 'anchor') { this.find('.field#AnchorSelector').show(); this.find('.field#AnchorRefresh').show(); } this.find('.field#Description').show(); }, /** * @return Object Keys: 'href', 'target', 'title' */ getLinkAttributes: function() { var href, target = null, anchor = this.find(':input[name=Anchor]').val(); // Determine target if(this.find(':input[name=TargetBlank]').is(':checked')) target = '_blank'; // All other attributes switch(this.find(':input[name=LinkType]:checked').val()) { case 'internal': href = '[sitetree_link,id=' + this.find(':input[name=internal]').val() + ']'; if(anchor) href += '#' + anchor; break; case 'anchor': href = '#' + anchor; break; case 'file': href = '[file_link,id=' + this.find(':input[name=file]').val() + ']'; target = '_blank'; break; case 'email': href = 'mailto:' + this.find(':input[name=email]').val(); target = null; break; // case 'external': default: href = this.find(':input[name=external]').val(); // Prefix the URL with "http://" if no prefix is found if(href.indexOf('://') == -1) href = 'http://' + href; break; } return { href : href, target : target, title : this.find(':input[name=Description]').val() }; }, insertLink: function() { this.modifySelection(function(ed){ ed.insertLink(this.getLinkAttributes()); }); this.updateFromEditor(); }, removeLink: function() { this.modifySelection(function(ed){ ed.removeLink(); }); this.close(); }, addAnchorSelector: function() { // Avoid adding twice if(this.find(':input[name=AnchorSelector]').length) return; var self = this, anchorSelector; // refresh the anchor selector on click, or in case of IE - button click if( !$.browser.ie ) { anchorSelector = $('<select id="Form_EditorToolbarLinkForm_AnchorSelector" name="AnchorSelector"></select>'); this.find(':input[name=Anchor]').parent().append(anchorSelector); anchorSelector.focus(function(e) { self.refreshAnchors(); }); } else { var buttonRefresh = $('<a id="Form_EditorToolbarLinkForm_AnchorRefresh" title="Refresh the anchor list" alt="Refresh the anchor list" class="buttonRefresh"><span></span></a>'); anchorSelector = $('<select id="Form_EditorToolbarLinkForm_AnchorSelector" class="hasRefreshButton" name="AnchorSelector"></select>'); this.find(':input[name=Anchor]').parent().append(buttonRefresh).append(anchorSelector); buttonRefresh.click(function(e) { self.refreshAnchors(); }); } // initialization self.refreshAnchors(); // copy the value from dropdown to the text field anchorSelector.change(function(e) { self.find(':input[name="Anchor"]').val($(this).val()); }); }, // this function collects the anchors in the currently active editor and regenerates the dropdown refreshAnchors: function() { var selector = this.find(':input[name=AnchorSelector]'), anchors = [], ed = this.getEditor(); // name attribute is defined as CDATA, should accept all characters and entities // http://www.w3.org/TR/1999/REC-html401-19991224/struct/links.html#h-12.2 if(ed) { var raw = ed.getContent().match(/name="([^"]+?)"|name='([^']+?)'/gim); if (raw && raw.length) { for(var i = 0; i < raw.length; i++) { anchors.push(raw[i].substr(6).replace(/"$/, '')); } } } selector.empty(); selector.append($( '<option value="" selected="1">' + ss.i18n._t('HtmlEditorField.SelectAnchor') + '</option>' )); for (var j = 0; j < anchors.length; j++) { selector.append($('<option value="'+anchors[j]+'">'+anchors[j]+'</option>')); } }, /** * Updates the state of the dialog inputs to match the editor selection. * If selection does not contain a link, resets the fields. */ updateFromEditor: function() { var htmlTagPattern = /<\S[^><]*>/g, fieldName, data = this.getCurrentLink(); if(data) { for(fieldName in data) { var el = this.find(':input[name=' + fieldName + ']'), selected = data[fieldName]; // Remove html tags in the selected text that occurs on IE browsers if(typeof(selected) == 'string') selected = selected.replace(htmlTagPattern, ''); // Set values and invoke the triggers (e.g. for TreeDropdownField). if(el.is(':checkbox')) { el.prop('checked', selected).change(); } else if(el.is(':radio')) { el.val([selected]).change(); } else { el.val(selected).change(); } } } }, /** * Return information about the currently selected link, suitable for population of the link form. * * Returns null if no link was currently selected. */ getCurrentLink: function() { var selectedEl = this.getSelection(), href = "", target = "", title = "", action = "insert", style_class = ""; // We use a separate field for linkDataSource from tinyMCE.linkElement. // If we have selected beyond the range of an <a> element, then use use that <a> element to get the link data source, // but we don't use it as the destination for the link insertion var linkDataSource = null; if(selectedEl.length) { if(selectedEl.is('a')) { // Element is a link linkDataSource = selectedEl; // TODO Limit to inline elements, otherwise will also apply to e.g. paragraphs which already contain one or more links // } else if((selectedEl.find('a').length)) { // // Element contains a link // var firstLinkEl = selectedEl.find('a:first'); // if(firstLinkEl.length) linkDataSource = firstLinkEl; } else { // Element is a child of a link linkDataSource = selectedEl = selectedEl.parents('a:first'); } } if(linkDataSource && linkDataSource.length) this.modifySelection(function(ed){ ed.selectNode(linkDataSource[0]); }); // Is anchor not a link if (!linkDataSource.attr('href')) linkDataSource = null; if (linkDataSource) { href = linkDataSource.attr('href'); target = linkDataSource.attr('target'); title = linkDataSource.attr('title'); style_class = linkDataSource.attr('class'); href = this.getEditor().cleanLink(href, linkDataSource); action = "update"; } if(href.match(/^mailto:(.*)$/)) { return { LinkType: 'email', email: RegExp.$1, Description: title }; } else if(href.match(/^(assets\/.*)$/) || href.match(/^\[file_link\s*(?:\s*|%20|,)?id=([0-9]+)\]?(#.*)?$/)) { return { LinkType: 'file', file: RegExp.$1, Description: title, TargetBlank: target ? true : false }; } else if(href.match(/^#(.*)$/)) { return { LinkType: 'anchor', Anchor: RegExp.$1, Description: title, TargetBlank: target ? true : false }; } else if(href.match(/^\[sitetree_link(?:\s*|%20|,)?id=([0-9]+)\]?(#.*)?$/i)) { return { LinkType: 'internal', internal: RegExp.$1, Anchor: RegExp.$2 ? RegExp.$2.substr(1) : '', Description: title, TargetBlank: target ? true : false }; } else if(href) { return { LinkType: 'external', external: href, Description: title, TargetBlank: target ? true : false }; } else { // No link/invalid link selected. return null; } } }); $('form.htmleditorfield-linkform input[name=LinkType]').entwine({ onclick: function(e) { this.parents('form:first').redraw(); }, onchange: function() { this.parents('form:first').redraw(); } }); $('form.htmleditorfield-linkform :submit[name=action_remove]').entwine({ onclick: function(e) { this.parents('form:first').removeLink(); return false; } }); /** * Responsible for inserting media files, although only images are supported so far. * Allows to select one or more files, and load form fields for each file via ajax. * This allows us to tailor the form fields to the file type (e.g. different ones for images and flash), * as well as add new form fields via framework extensions. * The inputs on each of those files are used for constructing the HTML to insert into * the rich text editor. Also allows editing the properties of existing files if any are selected in the editor. * Note: Not each file has a representation on the webserver filesystem, supports insertion and editing * of remove files as well. */ $('form.htmleditorfield-mediaform').entwine({ toggleCloseButton: function(){ var updateExisting = Boolean(this.find('.ss-htmleditorfield-file').length); this.find('.overview .action-delete')[updateExisting ? 'hide' : 'show'](); }, onsubmit: function() { this.modifySelection(function(ed){ this.find('.ss-htmleditorfield-file').each(function() { $(this).insertHTML(ed); }); ed.repaint(); }); this.getDialog().close(); return false; }, updateFromEditor: function() { var self = this, node = this.getSelection(); // TODO Depends on managed mime type if(node.is('img')) { this.showFileView(node.data('url') || node.attr('src')).done(function(filefield) { filefield.updateFromNode(node); self.toggleCloseButton(); self.redraw(); }); } this.redraw(); }, redraw: function(updateExisting) { this._super(); var node = this.getSelection(), hasItems = Boolean(this.find('.ss-htmleditorfield-file').length), editingSelected = node.is('img'), header = this.find('.header-edit'); // Only show second step if files are selected header[(hasItems) ? 'show' : 'hide'](); // Disable "insert" button if no files are selected this.find('.Actions :submit') .button(hasItems ? 'enable' : 'disable') .toggleClass('ui-state-disabled', !hasItems); // Hide file selection and step labels when editing an existing file this.find('#MediaFormInsertMediaTabs,.header-edit')[editingSelected ? 'hide' : 'show'](); // TODO Way too much knowledge on UploadField internals, use viewfile URL directly instead this.find('.htmleditorfield-mediaform-heading.insert')[editingSelected ? 'hide' : 'show'](); this.find('.ss-uploadfield-item-actions')[editingSelected ? 'hide' : 'show'](); this.find('.ss-uploadfield-item-name')[editingSelected ? 'hide' : 'show'](); this.find('.ss-uploadfield-item-preview')[editingSelected ? 'hide' : 'show'](); this.find('.Actions .media-insert')[editingSelected ? 'hide' : 'show'](); this.find('.htmleditorfield-mediaform-heading.update')[editingSelected ? 'show' : 'hide'](); this.find('.Actions .media-update')[editingSelected ? 'show' : 'hide'](); this.find('.ss-uploadfield-item-editform').toggleEditForm(editingSelected); }, resetFields: function() { this.find('.ss-htmleditorfield-file').remove(); // Remove any existing views this.find('.ss-gridfield-items .ui-selected').removeClass('ui-selected'); // Unselect all items this.find('li.ss-uploadfield-item').remove(); // Remove all selected items this.redraw(); this._super(); }, getFileView: function(idOrUrl) { return this.find('.ss-htmleditorfield-file[data-id=' + idOrUrl + ']'); }, showFileView: function(idOrUrl) { var self = this, params = (Number(idOrUrl) == idOrUrl) ? {ID: idOrUrl} : {FileURL: idOrUrl}; var item = $('<div class="ss-htmleditorfield-file loading" />'); this.find('.content-edit').prepend(item); var dfr = $.Deferred(); $.ajax({ url: $.path.addSearchParams(this.attr('action').replace(/MediaForm/, 'viewfile'), params), success: function(html, status, xhr) { var newItem = $(html).filter('.ss-htmleditorfield-file'); item.replaceWith(newItem); self.redraw(); dfr.resolve(newItem); }, error: function() { item.remove(); dfr.reject(); } }); return dfr.promise(); } }); $('form.htmleditorfield-mediaform .ss-gridfield-items').entwine({ onselectableselected: function(e, ui) { var form = this.closest('form'), item = $(ui.selected); if(!item.is('.ss-gridfield-item')) return; form.closest('form').showFileView(item.data('id')); form.redraw(); }, onselectableunselected: function(e, ui) { var form = this.closest('form'), item = $(ui.unselected); if(!item.is('.ss-gridfield-item')) return; form.getFileView(item.data('id')).remove(); form.redraw(); } }); /** * Show the second step after uploading an image */ $('form.htmleditorfield-form.htmleditorfield-mediaform div.ss-assetuploadfield').entwine({ //the UploadField div.ss-uploadfield-editandorganize is hidden in CSS, // because we use the detail view for each individual file instead onfileuploadstop: function(e) { var form = this.closest('form'); //update the editFields to show those Files that are newly uploaded var editFieldIDs = []; form.find('div.content-edit').find('div.ss-htmleditorfield-file').each(function(){ //get the uploaded file ID when this event triggers, signaling the upload has compeleted successfully editFieldIDs.push($(this).data('id')); }); // we only want this .ss-uploadfield-files - else we get all ss-uploadfield-files wich include the ones not related to #tinymce insertmedia var uploadedFiles = $('.ss-uploadfield-files', this).children('.ss-uploadfield-item'); uploadedFiles.each(function(){ var uploadedID = $(this).data('fileid'); if (uploadedID && $.inArray(uploadedID, editFieldIDs) == -1) { //trigger the detail view for filling out details about the file we are about to insert into TinyMCE $(this).remove(); // Remove successfully added item from the queue form.showFileView(uploadedID); } }); form.redraw(); } }); $('form.htmleditorfield-form.htmleditorfield-mediaform input.remoteurl').entwine({ onadd: function() { this.validate(); }, onkeyup: function() { this.validate(); }, onchange: function() { this.validate(); }, getAddButton: function() { return this.closest('.CompositeField').find('button.add-url'); }, validate: function() { var val = this.val(), orig = val; val = val.replace(/^https?:\/\//i, ''); if (orig !== val) this.val(val); this.getAddButton().button(!!val ? 'enable' : 'disable'); return !!val; } }); /** * Show the second step after adding a URL */ $('form.htmleditorfield-form.htmleditorfield-mediaform .add-url').entwine({ getURLField: function() { return this.closest('.CompositeField').find('input.remoteurl'); }, onclick: function(e) { var urlField = this.getURLField(), container = this.closest('.CompositeField'), form = this.closest('form'); if (urlField.validate()) { container.addClass('loading'); form.showFileView('http://' + urlField.val()).done(function() { container.removeClass('loading'); }); form.redraw(); } return false; } }); /** * Represents a single selected file, together with a set of form fields to edit its properties. * Overload this based on the media type to determine how the HTML should be created. */ $('form.htmleditorfield-mediaform .ss-htmleditorfield-file').entwine({ /** * @return {Object} Map of HTML attributes which can be set on the created DOM node. */ getAttributes: function() { }, /** * @return {Object} Map of additional properties which can be evaluated * by the specific media type. */ getExtraData: function() { }, /** * @return {String} HTML suitable for insertion into the rich text editor */ getHTML: function() { // Assumes UploadField markup structure return $('<div>').append( $('<a/>').attr({href: this.data('url')}).text(this.find('.name').text()) ).html(); }, /** * Insert updated HTML content into the rich text editor */ insertHTML: function(ed) { // Insert content ed.replaceContent(this.getHTML()); }, /** * Updates the form values from an existing node in the editor. * * @param {DOMElement} */ updateFromNode: function(node) { }, /** * Transforms values set on the dimensions form fields based on two constraints: * An aspect ration, and max width/height values. Writes back to the field properties as required. * * @param {String} The dimension to constrain the other value by, if any ("Width" or "Height") * @param {Int} Optional max width * @param {Int} Optional max height */ updateDimensions: function(constrainBy, maxW, maxH) { var widthEl = this.find(':input[name=Width]'), heightEl = this.find(':input[name=Height]'), w = widthEl.val(), h = heightEl.val(), aspect; // Proportionate updating of heights, using the original values if(w && h) { if(constrainBy) { aspect = heightEl.getOrigVal() / widthEl.getOrigVal(); // Uses floor() and ceil() to avoid both fields constantly lowering each other's values in rounding situations if(constrainBy == 'Width') { if(maxW && w > maxW) w = maxW; h = Math.floor(w * aspect); } else if(constrainBy == 'Height') { if(maxH && h > maxH) h = maxH; w = Math.ceil(h / aspect); } } else { if(maxW && w > maxW) w = maxW; if(maxH && h > maxH) h = maxH; } widthEl.val(w); heightEl.val(h); } } }); $('form.htmleditorfield-mediaform .ss-htmleditorfield-file.image').entwine({ getAttributes: function() { var width = this.find(':input[name=Width]').val(), height = this.find(':input[name=Height]').val(); return { 'src' : this.find(':input[name=URL]').val(), 'alt' : this.find(':input[name=AltText]').val(), 'width' : width ? parseInt(width, 10) : null, 'height' : height ? parseInt(height, 10) : null, 'title' : this.find(':input[name=Title]').val(), 'class' : this.find(':input[name=CSSClass]').val() }; }, getExtraData: function() { return { 'CaptionText': this.find(':input[name=CaptionText]').val() }; }, getHTML: function() { /* NOP */ }, /** * Logic similar to TinyMCE 'advimage' plugin, insertAndClose() method. */ insertHTML: function(ed) { var form = this.closest('form'), node = form.getSelection(), ed = form.getEditor(); // Get the attributes & extra data var attrs = this.getAttributes(), extraData = this.getExtraData(); // Find the element we are replacing - either the img, it's wrapper parent, or nothing (if creating) var replacee = (node && node.is('img')) ? node : null; if (replacee && replacee.parent().is('.captionImage')) replacee = replacee.parent(); // Find the img node - either the existing img or a new one, and update it var img = (node && node.is('img')) ? node : $('<img />'); img.attr(attrs); // Any existing figure or caption node var container = img.parent('.captionImage'), caption = container.find('.caption'); // If we've got caption text, we need a wrapping div.captionImage and sibling p.caption if (extraData.CaptionText) { if (!container.length) { container = $('<div></div>'); } container.attr('class', 'captionImage '+attrs['class']).css('width', attrs.width); if (!caption.length) { caption = $('<p class="caption"></p>').appendTo(container); } caption.attr('class', 'caption '+attrs['class']).text(extraData.CaptionText); } // Otherwise forget they exist else { container = caption = null; } // The element we are replacing the replacee with var replacer = container ? container : img; // If we're replacing something, and it's not with itself, do so if (replacee && replacee.not(replacer).length) { replacee.replaceWith(replacer); } // If we have a wrapper element, make sure the img is the first child - img might be the // replacee, and the wrapper the replacer, and we can't do this till after the replace has happened if (container) { container.prepend(img); } // If we don't have a replacee, then we need to insert the whole HTML if (!replacee) { // Otherwise insert the whole HTML content ed.repaint(); ed.insertContent($('<div />').append(replacer).html(), {skip_undo : 1}); } ed.addUndo(); ed.repaint(); }, updateFromNode: function(node) { this.find(':input[name=AltText]').val(node.attr('alt')); this.find(':input[name=Title]').val(node.attr('title')); this.find(':input[name=CSSClass]').val(node.attr('class')); this.find(':input[name=Width]').val(node.width()); this.find(':input[name=Height]').val(node.height()); this.find(':input[name=CaptionText]').val(node.siblings('.caption:first').text()); } }); /** * Insert a flash object tag into the content. * Requires the 'media' plugin for serialization of tags into <img> placeholders. */ $('form.htmleditorfield-mediaform .ss-htmleditorfield-file.flash').entwine({ getAttributes: function() { var width = this.find(':input[name=Width]').val(), height = this.find(':input[name=Height]').val(); return { 'src' : this.find(':input[name=URL]').val(), 'width' : width ? parseInt(width, 10) : null, 'height' : height ? parseInt(height, 10) : null }; }, getHTML: function() { var attrs = this.getAttributes(); // Emulate serialization from 'media' plugin var el = tinyMCE.activeEditor.plugins.media.dataToImg({ 'type': 'flash', 'width': attrs.width, 'height': attrs.height, 'params': {'src': attrs.src}, 'video': {'sources': []} }); return $('<div />').append(el).html(); // Little hack to get outerHTML string }, updateFromNode: function(node) { // TODO Not implemented } }); /** * Insert an oembed object tag into the content. * Requires the 'media' plugin for serialization of tags into <img> placeholders. */ $('form.htmleditorfield-mediaform .ss-htmleditorfield-file.embed').entwine({ getAttributes: function() { var width = this.find(':input[name=Width]').val(), height = this.find(':input[name=Height]').val(); return { 'src' : this.find('.thumbnail-preview').attr('src'), 'width' : width ? parseInt(width, 10) : null, 'height' : height ? parseInt(height, 10) : null, 'class' : this.find(':input[name=CSSClass]').val() }; }, getExtraData: function() { var width = this.find(':input[name=Width]').val(), height = this.find(':input[name=Height]').val(); return { 'CaptionText': this.find(':input[name=CaptionText]').val(), 'Url': this.find(':input[name=URL]').val(), 'thumbnail': this.find('.thumbnail-preview').attr('src'), 'width' : width ? parseInt(width, 10) : null, 'height' : height ? parseInt(height, 10) : null, 'cssclass': this.find(':input[name=CSSClass]').val() }; }, getHTML: function() { var el, attrs = this.getAttributes(), extraData = this.getExtraData(), // imgEl = $('<img id="_ss_tmp_img" />'); imgEl = $('<img />').attr(attrs).addClass('ss-htmleditorfield-file embed'); $.each(extraData, function (key, value) { imgEl.attr('data-' + key, value) }); if(extraData.CaptionText) { el = $('<div style="width: ' + attrs['width'] + 'px;" class="captionImage ' + attrs['class'] + '"><p class="caption">' + extraData.CaptionText + '</p></div>').prepend(imgEl); } else { el = imgEl; } return $('<div />').append(el).html(); // Little hack to get outerHTML string }, updateFromNode: function(node) { this.find(':input[name=Width]').val(node.width()); this.find(':input[name=Height]').val(node.height()); this.find(':input[name=Title]').val(node.attr('title')); this.find(':input[name=CSSClass]').val(node.data('cssclass')); } }); $('form.htmleditorfield-mediaform .ss-htmleditorfield-file .dimensions :input').entwine({ OrigVal: null, onmatch: function () { this._super(); this.setOrigVal(parseInt(this.val(), 10)); }, onunmatch: function() { this._super(); }, onfocusout: function(e) { this.closest('.ss-htmleditorfield-file').updateDimensions(this.attr('name')); } }); /** * Deselect item and remove the 'edit' view */ $('form.htmleditorfield-mediaform .ss-uploadfield-item .ss-uploadfield-item-cancel').entwine({ onclick: function(e) { var form = this.closest('form'), file = this.closest('ss-uploadfield-item'); form.find('.ss-gridfield-item[data-id=' + file.data('id') + ']').removeClass('ui-selected'); this.closest('.ss-uploadfield-item').remove(); form.redraw(); e.preventDefault(); } }); $('div.ss-assetuploadfield .ss-uploadfield-item-edit, div.ss-assetuploadfield .ss-uploadfield-item-name').entwine({ getEditForm: function() { return this.closest('.ss-uploadfield-item').find('.ss-uploadfield-item-editform'); }, fromEditForm: { onchange: function(e){ var form = $(e.target); form.removeClass('edited'); //so edited class is only there once form.addClass('edited'); } }, onclick: function(e) { var editForm = this.getEditForm(); editForm.parent('.ss-uploadfield-item').removeClass('ui-state-warning'); editForm.toggleEditForm(); e.preventDefault(); // Avoid a form submit return false; // Avoid duplication from button } }); $('div.ss-assetuploadfield .ss-uploadfield-item-editform').entwine({ toggleEditForm: function(bool) { var itemInfo = this.prev('.ss-uploadfield-item-info'), status = itemInfo.find('.ss-uploadfield-item-status'); var text=""; if(bool === true || (bool !== false && this.height() === 0)) { text = ss.i18n._t('UploadField.Editing', "Editing ..."); this.height('auto'); itemInfo.find('.toggle-details-icon').addClass('opened'); status.removeClass('ui-state-success-text').removeClass('ui-state-warning-text'); } else { this.height(0); itemInfo.find('.toggle-details-icon').removeClass('opened'); if(!this.hasClass('edited')){ text = ss.i18n._t('UploadField.NOCHANGES', 'No Changes') status.addClass('ui-state-success-text'); }else{ text = ss.i18n._t('UploadField.CHANGESSAVED', 'Changes Made') this.removeClass('edited'); status.addClass('ui-state-success-text'); } } status.attr('title',text).text(text); } }); $('form.htmleditorfield-mediaform #ParentID .TreeDropdownField').entwine({ onadd: function() { this._super(); // TODO Custom event doesn't fire in IE if registered through object literal var self = this; this.bind('change', function() { var fileList = self.closest('form').find('.ss-gridfield'); fileList.setState('ParentID', self.getValue()); fileList.reload(); }); } }); }); })(jQuery); /** * These callback globals hook it into tinymce. They need to be referenced in the TinyMCE config. */ function sapphiremce_cleanup(type, value) { if(type == 'get_from_editor') { // replace indented text with a <blockquote> value = value.replace(/<p [^>]*margin-left[^>]*>([^\n|\n\015|\015\n]*)<\/p>/ig,"<blockquote><p>$1</p></blockquote>"); // replace VML pixel image references with image tags - experimental value = value.replace(/<[a-z0-9]+:imagedata[^>]+src="?([^> "]+)"?[^>]*>/ig,"<img src=\"$1\">"); // Word comments value = value.replace(new RegExp('<(!--)([^>]*)(--)>', 'g'), ""); // kill class=mso??? and on mouse* tags value = value.replace(/([ \f\r\t\n\'\"])class=mso[a-z0-9]+[^ >]+/ig, "$1"); value = value.replace(/([ \f\r\t\n\'\"]class=")mso[a-z0-9]+[^ ">]+ /ig, "$1"); value = value.replace(/([ \f\r\t\n\'\"])class="mso[a-z0-9]+[^">]+"/ig, "$1"); value = value.replace(/([ \f\r\t\n\'\"])on[a-z]+=[^ >]+/ig, "$1"); value = value.replace(/ >/ig, ">"); // remove everything that's in a closing tag value = value.replace(/<(\/[A-Za-z0-9]+)[ \f\r\t\n]+[^>]*>/ig,"<$1>"); } if(type == 'get_from_editor_dom') { jQuery(value).find('img').each(function() { this.onresizestart = null; this.onresizeend = null; this.removeAttribute('onresizestart'); this.removeAttribute('onresizeend'); }); } // if we are inserting from a popup back into the editor // add the changed class and update the Content value if(type == 'insert_to_editor' && typeof tinyMCE.selectedInstance.editorId !== 'undefined') { var field = jQuery('#' + tinyMCE.selectedInstance.editorId); var original = field.val(); if (original != value) { field.val(value).addClass('changed'); field.closest('form').addClass('changed'); } } return value; }