linkbubble.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. // Copyright 2008 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. /**
  15. * @fileoverview Base class for bubble plugins.
  16. *
  17. */
  18. goog.provide('goog.editor.plugins.LinkBubble');
  19. goog.provide('goog.editor.plugins.LinkBubble.Action');
  20. goog.require('goog.array');
  21. goog.require('goog.dom');
  22. goog.require('goog.dom.Range');
  23. goog.require('goog.dom.TagName');
  24. goog.require('goog.editor.Command');
  25. goog.require('goog.editor.Link');
  26. goog.require('goog.editor.plugins.AbstractBubblePlugin');
  27. goog.require('goog.functions');
  28. goog.require('goog.string');
  29. goog.require('goog.style');
  30. goog.require('goog.ui.editor.messages');
  31. goog.require('goog.uri.utils');
  32. goog.require('goog.window');
  33. /**
  34. * Property bubble plugin for links.
  35. * @param {...!goog.editor.plugins.LinkBubble.Action} var_args List of
  36. * extra actions supported by the bubble.
  37. * @constructor
  38. * @extends {goog.editor.plugins.AbstractBubblePlugin}
  39. */
  40. goog.editor.plugins.LinkBubble = function(var_args) {
  41. goog.editor.plugins.LinkBubble.base(this, 'constructor');
  42. /**
  43. * List of extra actions supported by the bubble.
  44. * @type {Array<!goog.editor.plugins.LinkBubble.Action>}
  45. * @private
  46. */
  47. this.extraActions_ = goog.array.toArray(arguments);
  48. /**
  49. * List of spans corresponding to the extra actions.
  50. * @type {Array<!Element>}
  51. * @private
  52. */
  53. this.actionSpans_ = [];
  54. /**
  55. * A list of whitelisted URL schemes which are safe to open.
  56. * @type {Array<string>}
  57. * @private
  58. */
  59. this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];
  60. };
  61. goog.inherits(
  62. goog.editor.plugins.LinkBubble, goog.editor.plugins.AbstractBubblePlugin);
  63. /**
  64. * Element id for the link text.
  65. * type {string}
  66. * @private
  67. */
  68. goog.editor.plugins.LinkBubble.LINK_TEXT_ID_ = 'tr_link-text';
  69. /**
  70. * Element id for the test link span.
  71. * type {string}
  72. * @private
  73. */
  74. goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_ = 'tr_test-link-span';
  75. /**
  76. * Element id for the test link.
  77. * type {string}
  78. * @private
  79. */
  80. goog.editor.plugins.LinkBubble.TEST_LINK_ID_ = 'tr_test-link';
  81. /**
  82. * Element id for the change link span.
  83. * type {string}
  84. * @private
  85. */
  86. goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_ = 'tr_change-link-span';
  87. /**
  88. * Element id for the link.
  89. * type {string}
  90. * @private
  91. */
  92. goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_ = 'tr_change-link';
  93. /**
  94. * Element id for the delete link span.
  95. * type {string}
  96. * @private
  97. */
  98. goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_ = 'tr_delete-link-span';
  99. /**
  100. * Element id for the delete link.
  101. * type {string}
  102. * @private
  103. */
  104. goog.editor.plugins.LinkBubble.DELETE_LINK_ID_ = 'tr_delete-link';
  105. /**
  106. * Element id for the link bubble wrapper div.
  107. * type {string}
  108. * @private
  109. */
  110. goog.editor.plugins.LinkBubble.LINK_DIV_ID_ = 'tr_link-div';
  111. /**
  112. * @desc Text label for link that lets the user click it to see where the link
  113. * this bubble is for point to.
  114. */
  115. goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK =
  116. goog.getMsg('Go to link: ');
  117. /**
  118. * @desc Label that pops up a dialog to change the link.
  119. */
  120. goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE = goog.getMsg('Change');
  121. /**
  122. * @desc Label that allow the user to remove this link.
  123. */
  124. goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE = goog.getMsg('Remove');
  125. /**
  126. * @desc Message shown in a link bubble when the link is not a valid url.
  127. */
  128. goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE =
  129. goog.getMsg('invalid url');
  130. /**
  131. * Whether to stop leaking the page's url via the referrer header when the
  132. * link text link is clicked.
  133. * @type {boolean}
  134. * @private
  135. */
  136. goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks_ = false;
  137. /**
  138. * Whether to block opening links with a non-whitelisted URL scheme.
  139. * @type {boolean}
  140. * @private
  141. */
  142. goog.editor.plugins.LinkBubble.prototype.blockOpeningUnsafeSchemes_ = true;
  143. /**
  144. * Tells the plugin to stop leaking the page's url via the referrer header when
  145. * the link text link is clicked. When the user clicks on a link, the
  146. * browser makes a request for the link url, passing the url of the current page
  147. * in the request headers. If the user wants the current url to be kept secret
  148. * (e.g. an unpublished document), the owner of the url that was clicked will
  149. * see the secret url in the request headers, and it will no longer be a secret.
  150. * Calling this method will not send a referrer header in the request, just as
  151. * if the user had opened a blank window and typed the url in themselves.
  152. */
  153. goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks = function() {
  154. // TODO(user): Right now only 2 plugins have this API to stop
  155. // referrer leaks. If more plugins need to do this, come up with a way to
  156. // enable the functionality in all plugins at once. Same thing for
  157. // setBlockOpeningUnsafeSchemes and associated functionality.
  158. this.stopReferrerLeaks_ = true;
  159. };
  160. /**
  161. * Tells the plugin whether to block URLs with schemes not in the whitelist.
  162. * If blocking is enabled, this plugin will not linkify the link in the bubble
  163. * popup.
  164. * @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
  165. * schemes.
  166. */
  167. goog.editor.plugins.LinkBubble.prototype.setBlockOpeningUnsafeSchemes =
  168. function(blockOpeningUnsafeSchemes) {
  169. this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
  170. };
  171. /**
  172. * Sets a whitelist of allowed URL schemes that are safe to open.
  173. * Schemes should all be in lowercase. If the plugin is set to block opening
  174. * unsafe schemes, user-entered URLs will be converted to lowercase and checked
  175. * against this list. The whitelist has no effect if blocking is not enabled.
  176. * @param {Array<string>} schemes String array of URL schemes to allow (http,
  177. * https, etc.).
  178. */
  179. goog.editor.plugins.LinkBubble.prototype.setSafeToOpenSchemes = function(
  180. schemes) {
  181. this.safeToOpenSchemes_ = schemes;
  182. };
  183. /** @override */
  184. goog.editor.plugins.LinkBubble.prototype.getTrogClassId = function() {
  185. return 'LinkBubble';
  186. };
  187. /** @override */
  188. goog.editor.plugins.LinkBubble.prototype.isSupportedCommand = function(
  189. command) {
  190. return command == goog.editor.Command.UPDATE_LINK_BUBBLE;
  191. };
  192. /** @override */
  193. goog.editor.plugins.LinkBubble.prototype.execCommandInternal = function(
  194. command, var_args) {
  195. if (command == goog.editor.Command.UPDATE_LINK_BUBBLE) {
  196. this.updateLink_();
  197. }
  198. };
  199. /**
  200. * Updates the href in the link bubble with a new link.
  201. * @private
  202. */
  203. goog.editor.plugins.LinkBubble.prototype.updateLink_ = function() {
  204. var targetEl = this.getTargetElement();
  205. if (targetEl) {
  206. this.closeBubble();
  207. this.createBubble(targetEl);
  208. }
  209. };
  210. /** @override */
  211. goog.editor.plugins.LinkBubble.prototype.getBubbleTargetFromSelection =
  212. function(selectedElement) {
  213. var bubbleTarget = goog.dom.getAncestorByTagNameAndClass(
  214. selectedElement, goog.dom.TagName.A);
  215. if (!bubbleTarget) {
  216. // See if the selection is touching the right side of a link, and if so,
  217. // show a bubble for that link. The check for "touching" is very brittle,
  218. // and currently only guarantees that it will pop up a bubble at the
  219. // position the cursor is placed at after the link dialog is closed.
  220. // NOTE(robbyw): This assumes this method is always called with
  221. // selected element = range.getContainerElement(). Right now this is true,
  222. // but attempts to re-use this method for other purposes could cause issues.
  223. // TODO(robbyw): Refactor this method to also take a range, and use that.
  224. var range = this.getFieldObject().getRange();
  225. if (range && range.isCollapsed() && range.getStartOffset() == 0) {
  226. var startNode = range.getStartNode();
  227. var previous = startNode.previousSibling;
  228. if (previous && previous.tagName == goog.dom.TagName.A) {
  229. bubbleTarget = previous;
  230. }
  231. }
  232. }
  233. return /** @type {Element} */ (bubbleTarget);
  234. };
  235. /**
  236. * Set the optional function for getting the "test" link of a url.
  237. * @param {function(string) : string} func The function to use.
  238. */
  239. goog.editor.plugins.LinkBubble.prototype.setTestLinkUrlFn = function(func) {
  240. this.testLinkUrlFn_ = func;
  241. };
  242. /**
  243. * Returns the target element url for the bubble.
  244. * @return {string} The url href.
  245. * @protected
  246. */
  247. goog.editor.plugins.LinkBubble.prototype.getTargetUrl = function() {
  248. // Get the href-attribute through getAttribute() rather than the href property
  249. // because Google-Toolbar on Firefox with "Send with Gmail" turned on
  250. // modifies the href-property of 'mailto:' links but leaves the attribute
  251. // untouched.
  252. return this.getTargetElement().getAttribute('href') || '';
  253. };
  254. /** @override */
  255. goog.editor.plugins.LinkBubble.prototype.getBubbleType = function() {
  256. return String(goog.dom.TagName.A);
  257. };
  258. /** @override */
  259. goog.editor.plugins.LinkBubble.prototype.getBubbleTitle = function() {
  260. return goog.ui.editor.messages.MSG_LINK_CAPTION;
  261. };
  262. /**
  263. * Returns the message to display for testing a link.
  264. * @return {string} The message for testing a link.
  265. * @protected
  266. */
  267. goog.editor.plugins.LinkBubble.prototype.getTestLinkMessage = function() {
  268. return goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK;
  269. };
  270. /** @override */
  271. goog.editor.plugins.LinkBubble.prototype.createBubbleContents = function(
  272. bubbleContainer) {
  273. var linkObj = this.getLinkToTextObj_();
  274. // Create linkTextSpan, show plain text for e-mail address or truncate the
  275. // text to <= 48 characters so that property bubbles don't grow too wide and
  276. // create a link if URL. Only linkify valid links.
  277. // TODO(robbyw): Repalce this color with a CSS class.
  278. var color = linkObj.valid ? 'black' : 'red';
  279. var shouldOpenUrl = this.shouldOpenUrl(linkObj.linkText);
  280. var linkTextSpan;
  281. if (goog.editor.Link.isLikelyEmailAddress(linkObj.linkText) ||
  282. !linkObj.valid || !shouldOpenUrl) {
  283. linkTextSpan = this.dom_.createDom(
  284. goog.dom.TagName.SPAN, {
  285. id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
  286. style: 'color:' + color
  287. },
  288. this.dom_.createTextNode(linkObj.linkText));
  289. } else {
  290. var testMsgSpan = this.dom_.createDom(
  291. goog.dom.TagName.SPAN,
  292. {id: goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_},
  293. this.getTestLinkMessage());
  294. linkTextSpan = this.dom_.createDom(
  295. goog.dom.TagName.SPAN, {
  296. id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
  297. style: 'color:' + color
  298. },
  299. '');
  300. var linkText = goog.string.truncateMiddle(linkObj.linkText, 48);
  301. // Actually creates a pseudo-link that can't be right-clicked to open in a
  302. // new tab, because that would avoid the logic to stop referrer leaks.
  303. this.createLink(
  304. goog.editor.plugins.LinkBubble.TEST_LINK_ID_,
  305. this.dom_.createTextNode(linkText).data, this.testLink, linkTextSpan);
  306. }
  307. var changeLinkSpan = this.createLinkOption(
  308. goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_);
  309. this.createLink(
  310. goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_,
  311. goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE,
  312. this.showLinkDialog_, changeLinkSpan);
  313. // This function is called multiple times - we have to reset the array.
  314. this.actionSpans_ = [];
  315. for (var i = 0; i < this.extraActions_.length; i++) {
  316. var action = this.extraActions_[i];
  317. var actionSpan = this.createLinkOption(action.spanId_);
  318. this.actionSpans_.push(actionSpan);
  319. this.createLink(action.linkId_, action.message_, function() {
  320. action.actionFn_(this.getTargetUrl());
  321. }, actionSpan);
  322. }
  323. var removeLinkSpan = this.createLinkOption(
  324. goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_);
  325. this.createLink(
  326. goog.editor.plugins.LinkBubble.DELETE_LINK_ID_,
  327. goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE, this.deleteLink_,
  328. removeLinkSpan);
  329. this.onShow();
  330. var bubbleContents = this.dom_.createDom(
  331. goog.dom.TagName.DIV, {id: goog.editor.plugins.LinkBubble.LINK_DIV_ID_},
  332. testMsgSpan || '', linkTextSpan, changeLinkSpan);
  333. for (i = 0; i < this.actionSpans_.length; i++) {
  334. bubbleContents.appendChild(this.actionSpans_[i]);
  335. }
  336. bubbleContents.appendChild(removeLinkSpan);
  337. goog.dom.appendChild(bubbleContainer, bubbleContents);
  338. };
  339. /**
  340. * Tests the link by opening it in a new tab/window. Should be used as the
  341. * click event handler for the test pseudo-link.
  342. * @param {!Event=} opt_event If passed in, the event will be stopped.
  343. * @protected
  344. */
  345. goog.editor.plugins.LinkBubble.prototype.testLink = function(opt_event) {
  346. goog.window.open(
  347. this.getTestLinkAction_(),
  348. {'target': '_blank', 'noreferrer': this.stopReferrerLeaks_},
  349. this.getFieldObject().getAppWindow());
  350. if (opt_event) {
  351. opt_event.stopPropagation();
  352. opt_event.preventDefault();
  353. }
  354. };
  355. /**
  356. * Returns whether the URL should be considered invalid. This always returns
  357. * false in the base class, and should be overridden by subclasses that wish
  358. * to impose validity rules on URLs.
  359. * @param {string} url The url to check.
  360. * @return {boolean} Whether the URL should be considered invalid.
  361. */
  362. goog.editor.plugins.LinkBubble.prototype.isInvalidUrl = goog.functions.FALSE;
  363. /**
  364. * Gets the text to display for a link, based on the type of link
  365. * @return {!Object} Returns an object of the form:
  366. * {linkText: displayTextForLinkTarget, valid: ifTheLinkIsValid}.
  367. * @private
  368. */
  369. goog.editor.plugins.LinkBubble.prototype.getLinkToTextObj_ = function() {
  370. var isError;
  371. var targetUrl = this.getTargetUrl();
  372. if (this.isInvalidUrl(targetUrl)) {
  373. targetUrl = goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE;
  374. isError = true;
  375. } else if (goog.editor.Link.isMailto(targetUrl)) {
  376. targetUrl = targetUrl.substring(7); // 7 == "mailto:".length
  377. }
  378. return {linkText: targetUrl, valid: !isError};
  379. };
  380. /**
  381. * Shows the link dialog.
  382. * @param {goog.events.BrowserEvent} e The event.
  383. * @private
  384. */
  385. goog.editor.plugins.LinkBubble.prototype.showLinkDialog_ = function(e) {
  386. // Needed when this occurs due to an ENTER key event, else the newly created
  387. // dialog manages to have its OK button pressed, causing it to disappear.
  388. e.preventDefault();
  389. this.getFieldObject().execCommand(
  390. goog.editor.Command.MODAL_LINK_EDITOR,
  391. new goog.editor.Link(
  392. /** @type {HTMLAnchorElement} */ (this.getTargetElement()), false));
  393. this.closeBubble();
  394. };
  395. /**
  396. * Deletes the link associated with the bubble
  397. * @param {goog.events.BrowserEvent} e The event.
  398. * @private
  399. */
  400. goog.editor.plugins.LinkBubble.prototype.deleteLink_ = function(e) {
  401. // Needed when this occurs due to an ENTER key event, else the editor receives
  402. // the key press and inserts a newline.
  403. e.preventDefault();
  404. this.getFieldObject().dispatchBeforeChange();
  405. var link = this.getTargetElement();
  406. var child = link.lastChild;
  407. goog.dom.flattenElement(link);
  408. var restoreScrollPosition = this.saveScrollPosition();
  409. var range = goog.dom.Range.createFromNodeContents(child);
  410. range.collapse(false);
  411. range.select();
  412. this.closeBubble();
  413. this.getFieldObject().dispatchChange();
  414. this.getFieldObject().focus();
  415. restoreScrollPosition();
  416. };
  417. /**
  418. * Sets the proper state for the action links.
  419. * @protected
  420. * @override
  421. */
  422. goog.editor.plugins.LinkBubble.prototype.onShow = function() {
  423. var linkDiv =
  424. this.dom_.getElement(goog.editor.plugins.LinkBubble.LINK_DIV_ID_);
  425. if (linkDiv) {
  426. var testLinkSpan =
  427. this.dom_.getElement(goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_);
  428. if (testLinkSpan) {
  429. var url = this.getTargetUrl();
  430. goog.style.setElementShown(testLinkSpan, !goog.editor.Link.isMailto(url));
  431. }
  432. for (var i = 0; i < this.extraActions_.length; i++) {
  433. var action = this.extraActions_[i];
  434. var actionSpan = this.dom_.getElement(action.spanId_);
  435. if (actionSpan) {
  436. goog.style.setElementShown(
  437. actionSpan, action.toShowFn_(this.getTargetUrl()));
  438. }
  439. }
  440. }
  441. };
  442. /**
  443. * Gets the url for the bubble test link. The test link is the link in the
  444. * bubble the user can click on to make sure the link they entered is correct.
  445. * @return {string} The url for the bubble link href.
  446. * @private
  447. */
  448. goog.editor.plugins.LinkBubble.prototype.getTestLinkAction_ = function() {
  449. var targetUrl = this.getTargetUrl();
  450. return this.testLinkUrlFn_ ? this.testLinkUrlFn_(targetUrl) : targetUrl;
  451. };
  452. /**
  453. * Checks whether the plugin should open the given url in a new window.
  454. * @param {string} url The url to check.
  455. * @return {boolean} If the plugin should open the given url in a new window.
  456. * @protected
  457. */
  458. goog.editor.plugins.LinkBubble.prototype.shouldOpenUrl = function(url) {
  459. return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
  460. };
  461. /**
  462. * Determines whether or not a url has a scheme which is safe to open.
  463. * Schemes like javascript are unsafe due to the possibility of XSS.
  464. * @param {string} url A url.
  465. * @return {boolean} Whether the url has a safe scheme.
  466. * @private
  467. */
  468. goog.editor.plugins.LinkBubble.prototype.isSafeSchemeToOpen_ = function(url) {
  469. var scheme = goog.uri.utils.getScheme(url) || 'http';
  470. return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
  471. };
  472. /**
  473. * Constructor for extra actions that can be added to the link bubble.
  474. * @param {string} spanId The ID for the span showing the action.
  475. * @param {string} linkId The ID for the link showing the action.
  476. * @param {string} message The text for the link showing the action.
  477. * @param {function(string):boolean} toShowFn Test function to determine whether
  478. * to show the action for the given URL.
  479. * @param {function(string):void} actionFn Action function to run when the
  480. * action is clicked. Takes the current target URL as a parameter.
  481. * @constructor
  482. * @final
  483. */
  484. goog.editor.plugins.LinkBubble.Action = function(
  485. spanId, linkId, message, toShowFn, actionFn) {
  486. this.spanId_ = spanId;
  487. this.linkId_ = linkId;
  488. this.message_ = message;
  489. this.toShowFn_ = toShowFn;
  490. this.actionFn_ = actionFn;
  491. };