mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-09-07 10:11:17 +02:00
IE contentEditable carets and selection boxes show on top of any elements, interfering with HtmlEditorFields dialogs. This fixes that by storing the selection prior to opening the dialog, then unselecting everything. On dialog close the selection is restored
1259 lines
38 KiB
JavaScript
1259 lines
38 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.initial
|
|
ss.editorWrappers.tinyMCE = (function() {
|
|
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 tinyMCE.activeEditor;
|
|
},
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param String
|
|
* @param Object Implementation specific configuration
|
|
* @param Function
|
|
*/
|
|
create: function(domID, config, onSuccess) {
|
|
var ed = new tinymce.Editor(domID, config);
|
|
ed.onInit.add(onSuccess);
|
|
ed.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);
|
|
},
|
|
/**
|
|
* 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') || ss.editorWrappers['default'], ed = 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();
|
|
|
|
// 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');
|
|
},
|
|
|
|
fromContainingForm: {
|
|
onbeforesubmitform: function(){
|
|
if(this.isChanged()) {
|
|
this.getEditor().save();
|
|
this.trigger('change'); // TinyMCE assigns value attr directly, which doesn't trigger change event
|
|
}
|
|
}
|
|
},
|
|
|
|
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);
|
|
|
|
// Avoid flicker (also set in CSS to apply as early as possible)
|
|
self.css('visibility', '');
|
|
|
|
// 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, function() {
|
|
self.css('visibility', 'visible');
|
|
});
|
|
|
|
this._super();
|
|
},
|
|
|
|
'from .cms-container': {
|
|
onbeforestatechange: function(){
|
|
this.css('visibility', 'hidden');
|
|
|
|
var ed = this.getEditor(), container = 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 url = $('#cms-editor-dialogs').data('url' + capitalize(type) + 'form'),
|
|
dialog = $('.htmleditorfield-' + type + 'dialog');
|
|
|
|
if(dialog.length) {
|
|
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,
|
|
success: function(html) {
|
|
dialog.html(html);
|
|
dialog.trigger('dialogopen');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
$('.htmleditorfield-dialog').entwine({
|
|
onadd: function() {
|
|
// Create jQuery dialog
|
|
|
|
var height = $(window).height() * 0.8;
|
|
var width = $(window).width() * 0.8;
|
|
|
|
if (!this.is('.ui-dialog-content')) this.dialog({autoOpen: true, bgiframe: true, modal: true, height: height, width: width, ghost: true});
|
|
|
|
this._super();
|
|
},
|
|
|
|
getForm: function() {
|
|
return this.find('form');
|
|
},
|
|
open: function() {
|
|
this.dialog('open');
|
|
},
|
|
close: function() {
|
|
this.dialog('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,
|
|
Bookmark: null,
|
|
|
|
setSelection: function(node) {
|
|
return this._super($(node));
|
|
},
|
|
|
|
// Wrapper for various HTML editors
|
|
Editor: null,
|
|
|
|
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.setEditor(null);
|
|
|
|
this._super();
|
|
},
|
|
|
|
getDialog: function() {
|
|
// TODO Refactor to listen to form events to remove two-way coupling
|
|
return this.closest('.htmleditorfield-dialog');
|
|
},
|
|
|
|
fromDialog: {
|
|
ondialogopen: 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();
|
|
},
|
|
|
|
ondialogclose: function(){
|
|
var ed = this.getEditor();
|
|
ed.onclose();
|
|
|
|
ed.moveToBookmark(this.getBookmark());
|
|
|
|
this.setSelection(null);
|
|
this.setBookmark(null);
|
|
|
|
this.resetFields();
|
|
}
|
|
},
|
|
|
|
createEditor: function(){
|
|
return ss.editorWrappers['default']();
|
|
},
|
|
|
|
/**
|
|
* Get the tinyMCE editor
|
|
*/
|
|
getEditor: function(){
|
|
var val = this._super();
|
|
if(!val) {
|
|
this.setEditor(val = this.createEditor());
|
|
}
|
|
return val;
|
|
},
|
|
|
|
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() {
|
|
/* NOP */
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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();
|
|
this.find('fieldset :input:not(:radio)').val('').change();
|
|
},
|
|
redraw: function(setDefaults) {
|
|
this._super();
|
|
|
|
var linkType = this.find(':input[name=LinkType]:checked').val(), list = ['internal', 'external', 'file', 'email'];
|
|
|
|
// If we haven't selected an existing link, then just make sure we default to "internal" for the link type.
|
|
if(!linkType) {
|
|
this.find(':input[name=LinkType]').val(['internal']);
|
|
linkType = 'internal';
|
|
}
|
|
|
|
this.addAnchorSelector();
|
|
|
|
// Toggle field visibility and state based on type selection
|
|
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 == 'anchor') {
|
|
this.find('.field#AnchorSelector').show();
|
|
this.find('.field#AnchorRefresh').show();
|
|
}
|
|
|
|
this.find(':input[name=TargetBlank]').attr('disabled', (linkType == 'email'));
|
|
|
|
if(typeof setDefaults == 'undefined' || setDefaults) {
|
|
this.find(':input[name=TargetBlank]').attr('checked', (linkType == 'file'));
|
|
}
|
|
},
|
|
insertLink: 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;
|
|
}
|
|
|
|
var attributes = {
|
|
href : href,
|
|
target : target,
|
|
title : this.find(':input[name=Description]').val()
|
|
};
|
|
|
|
this.modifySelection(function(ed){
|
|
ed.insertLink(attributes);
|
|
})
|
|
|
|
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">Select an anchor</option>'));
|
|
for (var j = 0; j < anchors.length; j++) {
|
|
selector.append($('<option value="'+anchors[j]+'">'+anchors[j]+'</option>'));
|
|
}
|
|
},
|
|
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, '');
|
|
if(el.is(':radio')) {
|
|
el.val([selected]).change(); // setting as an arry due to jQuery quirks
|
|
} else {
|
|
el.val(selected).change();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Return information about the currently selected link, suitable for population of the link
|
|
* form.
|
|
*/
|
|
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*(?:%20)?id=([0-9]+)\]?(#.*)?$/)) {
|
|
return {
|
|
LinkType: 'file',
|
|
file: RegExp.$1,
|
|
Description: title
|
|
};
|
|
} 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]+)\]?(#.*)?$/)) {
|
|
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 {
|
|
return {
|
|
LinkType: 'internal'
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
$('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'), function() {
|
|
$(this).updateFromNode(node);
|
|
self.toggleCloseButton();
|
|
self.redraw();
|
|
});
|
|
}
|
|
this.redraw();
|
|
},
|
|
redraw: function() {
|
|
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
|
|
if(header) 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']();
|
|
|
|
var updateExisting = Boolean(this.find('.ss-htmleditorfield-file').length);
|
|
this.find('.htmleditorfield-mediaform-heading.insert')[updateExisting ? 'hide' : 'show']();
|
|
this.find('.Actions .media-insert')[updateExisting ? 'hide' : 'show']();
|
|
this.find('.htmleditorfield-mediaform-heading.update')[updateExisting ? 'show' : 'hide']();
|
|
this.find('.Actions .media-update')[updateExisting ? 'show' : 'hide']();
|
|
},
|
|
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.redraw();
|
|
|
|
this._super();
|
|
},
|
|
getFileView: function(idOrUrl) {
|
|
return this.find('.ss-htmleditorfield-file[data-id=' + idOrUrl + ']');
|
|
},
|
|
showFileView: function(idOrUrl, successCallback) {
|
|
var self = this, params = (Number(idOrUrl) == idOrUrl) ? {ID: idOrUrl} : {FileURL: idOrUrl},
|
|
item = $('<div class="ss-htmleditorfield-file" />');
|
|
|
|
item.addClass('loading');
|
|
this.find('.content-edit').append(item);
|
|
$.ajax({
|
|
// url: this.data('urlViewfile') + '?ID=' + id,
|
|
url: $.path.addSearchParams(this.attr('action').replace(/MediaForm/, 'viewfile'), params),
|
|
success: function(html, status, xhr) {
|
|
var newItem = $(html);
|
|
item.replaceWith(newItem);
|
|
self.redraw();
|
|
if(successCallback) successCallback.call(newItem, html, status, xhr);
|
|
},
|
|
error: function() {
|
|
item.remove();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
$('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'));
|
|
});
|
|
var uploadedFiles = $('.ss-uploadfield-files').children('.ss-uploadfield-item');
|
|
uploadedFiles.each(function(){
|
|
var uploadedID = $(this).data('fileid');
|
|
if ($.inArray(uploadedID, editFieldIDs) == -1) {
|
|
//trigger the detail view for filling out details about the file we are about to insert into TinyMCE
|
|
form.showFileView(uploadedID);
|
|
}
|
|
});
|
|
|
|
form.redraw();
|
|
}
|
|
|
|
});
|
|
|
|
/**
|
|
* Show the second step after adding a URL
|
|
*/
|
|
$('form.htmleditorfield-form.htmleditorfield-mediaform .add-url').entwine({
|
|
onclick: function(e) {
|
|
var form = this.closest('form');
|
|
|
|
var urlField = this.closest('.CompositeField').find('input.remoteurl');
|
|
|
|
form.showFileView(urlField.val());
|
|
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() {
|
|
},
|
|
/**
|
|
* 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() {
|
|
var el,
|
|
attrs = this.getAttributes(),
|
|
extraData = this.getExtraData(),
|
|
// imgEl = $('<img id="_ss_tmp_img" />');
|
|
imgEl = $('<img />').attr(attrs);
|
|
|
|
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
|
|
},
|
|
/**
|
|
* Logic similar to TinyMCE 'advimage' plugin, insertAndClose() method.
|
|
*/
|
|
insertHTML: function(ed) {
|
|
var form = this.closest('form'),
|
|
node = form.getSelection(), captionNode = node.closest('.captionImage');
|
|
|
|
if(node && node.is('img')) {
|
|
// If the image exists, update it to avoid complications with inserting TinyMCE HTML content
|
|
var attrs = this.getAttributes(), extraData = this.getExtraData();
|
|
node.attr(attrs);
|
|
// TODO Doesn't allow adding a caption to image after it was first added
|
|
if(captionNode.length) {
|
|
captionNode.find('.caption').text(extraData.CaptionText);
|
|
captionNode.css({width: attrs.width, height: attrs.height}).attr('class', attrs['class']);
|
|
}
|
|
// Undo needs to be added manually as we're doing direct DOM changes
|
|
ed.addUndo();
|
|
} else {
|
|
// Otherwise insert the whole HTML content
|
|
ed.repaint();
|
|
ed.insertContent(this.getHTML(), {skip_undo : 1});
|
|
ed.addUndo(); // Not sure why undo is separate here, replicating TinyMCE logic
|
|
}
|
|
|
|
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')).attr('disabled', 'disabled');
|
|
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));
|
|
|
|
// Default to a managable size for the HTML view. Can be overwritten by user after initialization
|
|
if(this.attr('name') == 'Width') this.closest('.ss-htmleditorfield-file').updateDimensions('Width', 600);
|
|
|
|
},
|
|
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() {
|
|
var itemInfo = this.prev('.ss-uploadfield-item-info'), status = itemInfo.find('.ss-uploadfield-item-status');
|
|
var text="";
|
|
|
|
if(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');
|
|
});
|
|
}
|
|
|
|
return value;
|
|
}
|