silverstripe-framework/javascript/HtmlEditorField.js
Damian Mooyman 8331171f2c Merge remote-tracking branch 'origin/3.1' into 3
Conflicts:
	.scrutinizer.yml
	admin/javascript/LeftAndMain.Panel.js
	core/startup/ParameterConfirmationToken.php
	dev/Debug.php
	dev/FixtureBlueprint.php
	docs/en/00_Getting_Started/05_Coding_Conventions.md
	docs/en/00_Getting_Started/index.md
	docs/en/02_Developer_Guides/01_Templates/01_Syntax.md
	filesystem/File.php
	filesystem/Folder.php
	forms/FieldList.php
	forms/LabelField.php
	forms/MoneyField.php
	forms/TextField.php
	forms/TreeDropdownField.php
	forms/Validator.php
	forms/gridfield/GridField.php
	forms/gridfield/GridFieldExportButton.php
	lang/de.yml
	lang/fi.yml
	model/DataObject.php
	model/SQLQuery.php
	parsers/ShortcodeParser.php
	security/ChangePasswordForm.php
	security/Security.php
	tests/control/DirectorTest.php
	tests/core/startup/ParameterConfirmationTokenTest.php
	tests/dev/FixtureBlueprintTest.php
	tests/forms/FieldListTest.php
	tests/forms/MoneyFieldTest.php
	tests/model/SQLQueryTest.php
	tests/security/SecurityTest.php
2015-06-02 19:13:38 +12:00

1550 lines
46 KiB
JavaScript

/**
* 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.
if(ed.settings.update_interval) {
var interval;
jQuery(ed.getBody()).on('focus', function() {
interval = setInterval(function() {
// Update underlying element as necessary
var element = jQuery(ed.getElement());
if(ed.isDirty()) {
// Set content without triggering editor content cleanup
element.val(ed.getContent({format : 'raw', no_events : 1}));
}
}, ed.settings.update_interval);
});
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) {
try {
ed.remove();
} catch(ex) {}
try {
ed.destroy();
} catch(ex) {}
// Remove any residual tinyMCE editor element
this.next('.mceEditor').remove();
// 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;
try {
while (parent && parent.nodeType == 1) parent = parent.parentNode;
}
catch(err) {}
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();
this.addAnchorSelector();
// Toggle field visibility depending on the link type.
this.find('div.content .field').hide();
this.find('.field[id$="LinkType"]').show();
this.find('.field[id$="' + linkType +'_Holder"]').show();
if(linkType == 'internal' || linkType == 'anchor') {
this.find('.field[id$="Anchor_Holder"]').show();
}
if(linkType == 'email') {
this.find('.field[id$="Subject_Holder"]').show();
} else {
this.find('.field[id$="TargetBlank_Holder"]').show();
}
if(linkType == 'anchor') {
this.find('.field[id$="AnchorSelector_Holder"]').show();
}
this.find('.field[id$="Description_Holder"]').show();
},
/**
* @return Object Keys: 'href', 'target', 'title'
*/
getLinkAttributes: function() {
var href,
target = null,
subject = this.find(':input[name=Subject]').val(),
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();
if(subject) {
href += '?subject=' + encodeURIComponent(subject);
}
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();
},
/**
* Builds an anchor selector element and injects it into the DOM next to the anchor field.
*/
addAnchorSelector: function() {
// Avoid adding twice
if(this.find(':input[name=AnchorSelector]').length) return;
var self = this;
var anchorSelector = $(
'<select id="Form_EditorToolbarLinkForm_AnchorSelector" name="AnchorSelector"></select>'
);
this.find(':input[name=Anchor]').parent().append(anchorSelector);
// Initialise the anchor dropdown.
this.updateAnchorSelector();
// copy the value from dropdown to the text field
anchorSelector.change(function(e) {
self.find(':input[name="Anchor"]').val($(this).val());
});
},
/**
* Fetch relevant anchors, depending on the link type.
*
* @return $.Deferred A promise of an anchor array, or an error message.
*/
getAnchors: function() {
var linkType = this.find(':input[name=LinkType]:checked').val();
var dfdAnchors = $.Deferred();
switch (linkType) {
case 'anchor':
// Fetch from the local editor.
var collectedAnchors = [];
var 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++) {
collectedAnchors.push(raw[i].substr(6).replace(/"$/, ''));
}
}
}
dfdAnchors.resolve(collectedAnchors);
break;
case 'internal':
// Fetch available anchors from the target internal page.
var pageId = this.find(':input[name=internal]').val();
if (pageId) {
$.ajax({
url: $.path.addSearchParams(
this.attr('action').replace('LinkForm', 'getanchors'),
{'PageID': parseInt(pageId)}
),
success: function(body, status, xhr) {
dfdAnchors.resolve($.parseJSON(body));
},
error: function(xhr, status) {
dfdAnchors.reject(xhr.responseText);
}
});
} else {
dfdAnchors.resolve([]);
}
break;
default:
// This type does not support anchors at all.
dfdAnchors.reject(ss.i18n._t(
'HtmlEditorField.ANCHORSNOTSUPPORTED',
'Anchors are not supported for this link type.'
));
break;
}
return dfdAnchors.promise();
},
/**
* Update the anchor list in the dropdown.
*/
updateAnchorSelector: function() {
var self = this;
var selector = this.find(':input[name=AnchorSelector]');
var dfdAnchors = this.getAnchors();
// Inform the user we are loading.
selector.empty();
selector.append($(
'<option value="" selected="1">' +
ss.i18n._t('HtmlEditorField.LOOKINGFORANCHORS', 'Looking for anchors...') +
'</option>'
));
dfdAnchors.done(function(anchors) {
selector.empty();
selector.append($(
'<option value="" selected="1">' +
ss.i18n._t('HtmlEditorField.SelectAnchor') +
'</option>'
));
if (anchors) {
for (var j = 0; j < anchors.length; j++) {
selector.append($('<option value="'+anchors[j]+'">'+anchors[j]+'</option>'));
}
}
}).fail(function(message) {
selector.empty();
selector.append($(
'<option value="" selected="1">' +
message +
'</option>'
));
});
// Poke the selector for IE8, otherwise the changes won't be noticed.
if ($.browser.msie) selector.hide().show();
},
/**
* 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();
this._super();
},
onchange: function() {
this.parents('form:first').redraw();
// Update if a anchor-supporting link type is selected.
var linkType = this.parent().find(':checked').val();
if (linkType==='anchor' || linkType==='internal') {
this.parents('form.htmleditorfield-linkform').updateAnchorSelector();
}
this._super();
}
});
$('form.htmleditorfield-linkform input[name=internal]').entwine({
/**
* Update the anchor dropdown if a different page is selected in the "internal" dropdown.
*/
onvalueupdated: function() {
this.parents('form.htmleditorfield-linkform').updateAnchorSelector();
this._super();
}
});
$('form.htmleditorfield-linkform :submit[name=action_remove]').entwine({
onclick: function(e) {
this.parents('form:first').removeLink();
this._super();
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._super();
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 = $.trim(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');
var node = form.getSelection();
if (!ed) 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(),
'alt' : this.find(':input[name=AltText]').val(),
'title' : this.find(':input[name=Title]').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=AltText]').val(node.attr('alt'));
this.find(':input[name=Title]').val(node.attr('title'));
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();
// Make sure we're in an HtmlEditorField here, or fall-back to _super(). HtmlEditorField with
// AssetUploadField doesn't use iframes, so needs its own toggleEditForm() logic
if (this.closest('.ss-uploadfield-item').hasClass('ss-htmleditorfield-file')) {
editForm.parent('ss-uploadfield-item').removeClass('ui-state-warning');
editForm.toggleEditForm();
e.preventDefault(); // Avoid a form submit
return false; // Avoid duplication from button
}
this._super(e);
}
});
$('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 .field[id$="ParentID_Holder"] .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 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');
});
}
return value;
}