diff --git a/forms/HtmlEditorField.php b/forms/HtmlEditorField.php
index adf5d5981..c5c32d8c2 100644
--- a/forms/HtmlEditorField.php
+++ b/forms/HtmlEditorField.php
@@ -190,7 +190,8 @@ class HtmlEditorField_Toolbar extends RequestHandler {
private static $allowed_actions = array(
'LinkForm',
'MediaForm',
- 'viewfile'
+ 'viewfile',
+ 'getanchors'
);
/**
@@ -518,6 +519,41 @@ class HtmlEditorField_Toolbar extends RequestHandler {
))->renderWith($this->templateViewFile);
}
+ /**
+ * Find all anchors available on the given page.
+ *
+ * @return array
+ */
+ public function getanchors() {
+ $id = (int)$this->request->getVar('PageID');
+ $anchors = array();
+
+ if (($page = Page::get()->byID($id)) && !empty($page)) {
+ if (!$page->canView()) {
+ throw new SS_HTTPResponse_Exception(
+ _t(
+ 'HtmlEditorField.ANCHORSCANNOTACCESSPAGE',
+ 'You are not permitted to access the content of the target page.'
+ ),
+ 403
+ );
+ }
+
+ // Similar to the regex found in HtmlEditorField.js / getAnchors method.
+ if (preg_match_all("/name=\"([^\"]+?)\"|name='([^']+?)'/im", $page->Content, $matches)) {
+ $anchors = $matches[1];
+ }
+
+ } else {
+ throw new SS_HTTPResponse_Exception(
+ _t('HtmlEditorField.ANCHORSPAGENOTFOUND', 'Target page not found.'),
+ 404
+ );
+ }
+
+ return json_encode($anchors);
+ }
+
/**
* Similar to {@link File->getCMSFields()}, but only returns fields
* for manipulating the instance of the file as inserted into the HTML content,
diff --git a/javascript/HtmlEditorField.js b/javascript/HtmlEditorField.js
index eb0067906..6e7332969 100644
--- a/javascript/HtmlEditorField.js
+++ b/javascript/HtmlEditorField.js
@@ -74,7 +74,7 @@ ss.editorWrappers.tinyMCE = (function() {
this.statusKeyboardNavigation.destroy();
this.statusKeyboardNavigation = null;
}
- }
+ };
ss.editorWrappers.tinyMCE.patched = true;
}
@@ -543,6 +543,7 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
* 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();
@@ -558,7 +559,7 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
redraw: function() {
this._super();
- var linkType = this.find(':input[name=LinkType]:checked').val(), list = ['internal', 'external', 'file', 'email'];
+ var linkType = this.find(':input[name=LinkType]:checked').val();
this.addAnchorSelector();
@@ -568,10 +569,7 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
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();
- }
+ if(linkType == 'anchor') this.find('.field#AnchorSelector').show();
this.find('.field#Description').show();
},
/**
@@ -613,8 +611,8 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
}
return {
- href : href,
- target : target,
+ href : href,
+ target : target,
title : this.find(':input[name=Description]').val()
};
},
@@ -630,63 +628,135 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
});
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, anchorSelector;
+ var self = this;
+ var anchorSelector = $(
+ ''
+ );
+ this.find(':input[name=Anchor]').parent().append(anchorSelector);
- // refresh the anchor selector on click, or in case of IE - button click
- if( !$.browser.ie ) {
- anchorSelector = $('');
- this.find(':input[name=Anchor]').parent().append(anchorSelector);
-
- anchorSelector.focus(function(e) {
- self.refreshAnchors();
- });
- } else {
- var buttonRefresh = $('');
- anchorSelector = $('');
- this.find(':input[name=Anchor]').parent().append(buttonRefresh).append(anchorSelector);
-
- buttonRefresh.click(function(e) {
- self.refreshAnchors();
- });
- }
-
- // initialization
- self.refreshAnchors();
+ // 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());
});
},
- // 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(/"$/, ''));
+ /**
+ * 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;
+ },
+
+ /**
+ * 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($(
''
));
- for (var j = 0; j < anchors.length; j++) {
- selector.append($(''));
- }
+
+ dfdAnchors.done(function(anchors) {
+ selector.empty();
+ selector.append($(
+ ''
+ ));
+
+ if (anchors) {
+ for (var j = 0; j < anchors.length; j++) {
+ selector.append($(''));
+ }
+ }
+
+ }).fail(function(message) {
+ selector.empty();
+ selector.append($(
+ ''
+ ));
+ });
+
+ // 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.
@@ -711,103 +781,123 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
}
}
},
- /**
- * 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 element, then use use that 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";
+ /**
+ * 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 element, then use use that 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;
+ }
}
-
- 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;
}
});
@@ -881,7 +971,7 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
this.find('.Actions .media-update')[editingSelected ? 'show' : 'hide']();
this.find('.ss-uploadfield-item-editform').toggleEditForm(editingSelected);
},
- resetFields: function() {
+ 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
@@ -1118,7 +1208,9 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
* Logic similar to TinyMCE 'advimage' plugin, insertAndClose() method.
*/
insertHTML: function(ed) {
- var form = this.closest('form'), node = form.getSelection(), ed = form.getEditor();
+ 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();
@@ -1259,7 +1351,7 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
imgEl = $('').attr(attrs).addClass('ss-htmleditorfield-file embed');
$.each(extraData, function (key, value) {
- imgEl.attr('data-' + key, value)
+ imgEl.attr('data-' + key, value);
});
if(extraData.CaptionText) {
@@ -1354,10 +1446,10 @@ ss.editorWrappers['default'] = ss.editorWrappers.tinyMCE;
this.height(0);
itemInfo.find('.toggle-details-icon').removeClass('opened');
if(!this.hasClass('edited')){
- text = ss.i18n._t('UploadField.NOCHANGES', 'No Changes')
+ text = ss.i18n._t('UploadField.NOCHANGES', 'No Changes');
status.addClass('ui-state-success-text');
}else{
- text = ss.i18n._t('UploadField.CHANGESSAVED', 'Changes Made')
+ text = ss.i18n._t('UploadField.CHANGESSAVED', 'Changes Made');
this.removeClass('edited');
status.addClass('ui-state-success-text');
}