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'); }