TopSite.jsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
  2. import {FormattedMessage, injectIntl} from "react-intl";
  3. import {
  4. MIN_CORNER_FAVICON_SIZE,
  5. MIN_RICH_FAVICON_SIZE,
  6. TOP_SITES_CONTEXT_MENU_OPTIONS,
  7. TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
  8. TOP_SITES_SOURCE,
  9. } from "./TopSitesConstants";
  10. import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
  11. import React from "react";
  12. import {ScreenshotUtils} from "content-src/lib/screenshot-utils";
  13. import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
  14. export class TopSiteLink extends React.PureComponent {
  15. constructor(props) {
  16. super(props);
  17. this.state = {screenshotImage: null};
  18. this.onDragEvent = this.onDragEvent.bind(this);
  19. this.onKeyPress = this.onKeyPress.bind(this);
  20. }
  21. /*
  22. * Helper to determine whether the drop zone should allow a drop. We only allow
  23. * dropping top sites for now.
  24. */
  25. _allowDrop(e) {
  26. return e.dataTransfer.types.includes("text/topsite-index");
  27. }
  28. onDragEvent(event) {
  29. switch (event.type) {
  30. case "click":
  31. // Stop any link clicks if we started any dragging
  32. if (this.dragged) {
  33. event.preventDefault();
  34. }
  35. break;
  36. case "dragstart":
  37. this.dragged = true;
  38. event.dataTransfer.effectAllowed = "move";
  39. event.dataTransfer.setData("text/topsite-index", this.props.index);
  40. event.target.blur();
  41. this.props.onDragEvent(event, this.props.index, this.props.link, this.props.title);
  42. break;
  43. case "dragend":
  44. this.props.onDragEvent(event);
  45. break;
  46. case "dragenter":
  47. case "dragover":
  48. case "drop":
  49. if (this._allowDrop(event)) {
  50. event.preventDefault();
  51. this.props.onDragEvent(event, this.props.index);
  52. }
  53. break;
  54. case "mousedown":
  55. // Block the scroll wheel from appearing for middle clicks on search top sites
  56. if (event.button === 1 && this.props.link.searchTopSite) {
  57. event.preventDefault();
  58. }
  59. // Reset at the first mouse event of a potential drag
  60. this.dragged = false;
  61. break;
  62. }
  63. }
  64. /**
  65. * Helper to obtain the next state based on nextProps and prevState.
  66. *
  67. * NOTE: Rename this method to getDerivedStateFromProps when we update React
  68. * to >= 16.3. We will need to update tests as well. We cannot rename this
  69. * method to getDerivedStateFromProps now because there is a mismatch in
  70. * the React version that we are using for both testing and production.
  71. * (i.e. react-test-render => "16.3.2", react => "16.2.0").
  72. *
  73. * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
  74. */
  75. static getNextStateFromProps(nextProps, prevState) {
  76. const {screenshot} = nextProps.link;
  77. const imageInState = ScreenshotUtils.isRemoteImageLocal(prevState.screenshotImage, screenshot);
  78. if (imageInState) {
  79. return null;
  80. }
  81. // Since image was updated, attempt to revoke old image blob URL, if it exists.
  82. ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage);
  83. return {screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot)};
  84. }
  85. // NOTE: Remove this function when we update React to >= 16.3 since React will
  86. // call getDerivedStateFromProps automatically. We will also need to
  87. // rename getNextStateFromProps to getDerivedStateFromProps.
  88. componentWillMount() {
  89. const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
  90. if (nextState) {
  91. this.setState(nextState);
  92. }
  93. }
  94. // NOTE: Remove this function when we update React to >= 16.3 since React will
  95. // call getDerivedStateFromProps automatically. We will also need to
  96. // rename getNextStateFromProps to getDerivedStateFromProps.
  97. componentWillReceiveProps(nextProps) {
  98. const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
  99. if (nextState) {
  100. this.setState(nextState);
  101. }
  102. }
  103. componentWillUnmount() {
  104. ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage);
  105. }
  106. onKeyPress(event) {
  107. // If we have tabbed to a search shortcut top site, and we click 'enter',
  108. // we should execute the onClick function. This needs to be added because
  109. // search top sites are anchor tags without an href. See bug 1483135
  110. if (this.props.link.searchTopSite && event.key === "Enter") {
  111. this.props.onClick(event);
  112. }
  113. }
  114. render() {
  115. const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
  116. const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}${link.searchTopSite ? " search-shortcut" : ""}`;
  117. const {tippyTopIcon, faviconSize} = link;
  118. const [letterFallback] = title;
  119. let imageClassName;
  120. let imageStyle;
  121. let showSmallFavicon = false;
  122. let smallFaviconStyle;
  123. let smallFaviconFallback;
  124. let hasScreenshotImage = this.state.screenshotImage && this.state.screenshotImage.url;
  125. if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
  126. smallFaviconFallback = false;
  127. } else if (link.searchTopSite) {
  128. imageClassName = "top-site-icon rich-icon";
  129. imageStyle = {
  130. backgroundColor: link.backgroundColor,
  131. backgroundImage: `url(${tippyTopIcon})`,
  132. };
  133. smallFaviconStyle = {backgroundImage: `url(${tippyTopIcon})`};
  134. } else if (link.customScreenshotURL) {
  135. // assume high quality custom screenshot and use rich icon styles and class names
  136. imageClassName = "top-site-icon rich-icon";
  137. imageStyle = {
  138. backgroundColor: link.backgroundColor,
  139. backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "none",
  140. };
  141. } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
  142. // styles and class names for top sites with rich icons
  143. imageClassName = "top-site-icon rich-icon";
  144. imageStyle = {
  145. backgroundColor: link.backgroundColor,
  146. backgroundImage: `url(${tippyTopIcon || link.favicon})`,
  147. };
  148. } else {
  149. // styles and class names for top sites with screenshot + small icon in top left corner
  150. imageClassName = `screenshot${hasScreenshotImage ? " active" : ""}`;
  151. imageStyle = {backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "none"};
  152. // only show a favicon in top left if it's greater than 16x16
  153. if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
  154. showSmallFavicon = true;
  155. smallFaviconStyle = {backgroundImage: `url(${link.favicon})`};
  156. } else if (hasScreenshotImage) {
  157. // Don't show a small favicon if there is no screenshot, because that
  158. // would result in two fallback icons
  159. showSmallFavicon = true;
  160. smallFaviconFallback = true;
  161. }
  162. }
  163. let draggableProps = {};
  164. if (isDraggable) {
  165. draggableProps = {
  166. onClick: this.onDragEvent,
  167. onDragEnd: this.onDragEvent,
  168. onDragStart: this.onDragEvent,
  169. onMouseDown: this.onDragEvent,
  170. };
  171. }
  172. return (<li className={topSiteOuterClassName} onDrop={this.onDragEvent} onDragOver={this.onDragEvent} onDragEnter={this.onDragEvent} onDragLeave={this.onDragEvent} {...draggableProps}>
  173. <div className="top-site-inner">
  174. {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */}
  175. {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
  176. <a className="top-site-button" href={link.searchTopSite ? undefined : link.url} tabIndex="0" onKeyPress={this.onKeyPress} onClick={onClick} draggable={true}>
  177. <div className="tile" aria-hidden={true} data-fallback={letterFallback}>
  178. <div className={imageClassName} style={imageStyle} />
  179. {link.searchTopSite && <div className="top-site-icon search-topsite" />}
  180. {showSmallFavicon && <div
  181. className="top-site-icon default-icon"
  182. data-fallback={smallFaviconFallback && letterFallback}
  183. style={smallFaviconStyle} />}
  184. </div>
  185. <div className={`title ${link.isPinned ? "pinned" : ""}`}>
  186. {link.isPinned && <div className="icon icon-pin-small" />}
  187. <span dir="auto">{title}</span>
  188. </div>
  189. </a>
  190. {children}
  191. </div>
  192. </li>);
  193. }
  194. }
  195. TopSiteLink.defaultProps = {
  196. title: "",
  197. link: {},
  198. isDraggable: true,
  199. };
  200. export class TopSite extends React.PureComponent {
  201. constructor(props) {
  202. super(props);
  203. this.state = {showContextMenu: false};
  204. this.onLinkClick = this.onLinkClick.bind(this);
  205. this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
  206. this.onMenuUpdate = this.onMenuUpdate.bind(this);
  207. }
  208. /**
  209. * Report to telemetry additional information about the item.
  210. */
  211. _getTelemetryInfo() {
  212. const value = {icon_type: this.props.link.iconType};
  213. // Filter out "not_pinned" type for being the default
  214. if (this.props.link.isPinned) {
  215. value.card_type = "pinned";
  216. }
  217. if (this.props.link.searchTopSite) {
  218. // Set the card_type as "search" regardless of its pinning status
  219. value.card_type = "search";
  220. value.search_vendor = this.props.link.hostname;
  221. }
  222. return {value};
  223. }
  224. userEvent(event) {
  225. this.props.dispatch(ac.UserEvent(Object.assign({
  226. event,
  227. source: TOP_SITES_SOURCE,
  228. action_position: this.props.index,
  229. }, this._getTelemetryInfo())));
  230. }
  231. onLinkClick(event) {
  232. this.userEvent("CLICK");
  233. // Specially handle a top site link click for "typed" frecency bonus as
  234. // specified as a property on the link.
  235. event.preventDefault();
  236. const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
  237. if (!this.props.link.searchTopSite) {
  238. this.props.dispatch(ac.OnlyToMain({
  239. type: at.OPEN_LINK,
  240. data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}}),
  241. }));
  242. } else {
  243. this.props.dispatch(ac.OnlyToMain({
  244. type: at.FILL_SEARCH_TERM,
  245. data: {label: this.props.link.label},
  246. }));
  247. }
  248. }
  249. onMenuButtonClick(event) {
  250. event.preventDefault();
  251. this.props.onActivate(this.props.index);
  252. this.setState({showContextMenu: true});
  253. }
  254. onMenuUpdate(showContextMenu) {
  255. this.setState({showContextMenu});
  256. }
  257. render() {
  258. const {props} = this;
  259. const {link} = props;
  260. const isContextMenuOpen = this.state.showContextMenu && props.activeIndex === props.index;
  261. const title = link.label || link.hostname;
  262. return (<TopSiteLink {...props} onClick={this.onLinkClick} onDragEvent={this.props.onDragEvent} className={`${props.className || ""}${isContextMenuOpen ? " active" : ""}`} title={title}>
  263. <div>
  264. <button aria-haspopup="true" className="context-menu-button icon" title={this.props.intl.formatMessage({id: "context_menu_title"})} onClick={this.onMenuButtonClick}>
  265. <span className="sr-only">
  266. <FormattedMessage id="context_menu_button_sr" values={{title}} />
  267. </span>
  268. </button>
  269. {isContextMenuOpen &&
  270. <LinkMenu
  271. dispatch={props.dispatch}
  272. index={props.index}
  273. onUpdate={this.onMenuUpdate}
  274. options={link.searchTopSite ? TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS : TOP_SITES_CONTEXT_MENU_OPTIONS}
  275. site={link}
  276. siteInfo={this._getTelemetryInfo()}
  277. source={TOP_SITES_SOURCE} />
  278. }
  279. </div>
  280. </TopSiteLink>);
  281. }
  282. }
  283. TopSite.defaultProps = {
  284. link: {},
  285. onActivate() {},
  286. };
  287. export class TopSitePlaceholder extends React.PureComponent {
  288. constructor(props) {
  289. super(props);
  290. this.onEditButtonClick = this.onEditButtonClick.bind(this);
  291. }
  292. onEditButtonClick() {
  293. this.props.dispatch(
  294. {type: at.TOP_SITES_EDIT, data: {index: this.props.index}});
  295. }
  296. render() {
  297. return (<TopSiteLink {...this.props} className={`placeholder ${this.props.className || ""}`} isDraggable={false}>
  298. <button aria-haspopup="true" className="context-menu-button edit-button icon"
  299. title={this.props.intl.formatMessage({id: "edit_topsites_edit_button"})}
  300. onClick={this.onEditButtonClick} />
  301. </TopSiteLink>);
  302. }
  303. }
  304. export class _TopSiteList extends React.PureComponent {
  305. static get DEFAULT_STATE() {
  306. return {
  307. activeIndex: null,
  308. draggedIndex: null,
  309. draggedSite: null,
  310. draggedTitle: null,
  311. topSitesPreview: null,
  312. };
  313. }
  314. constructor(props) {
  315. super(props);
  316. this.state = _TopSiteList.DEFAULT_STATE;
  317. this.onDragEvent = this.onDragEvent.bind(this);
  318. this.onActivate = this.onActivate.bind(this);
  319. }
  320. componentWillReceiveProps(nextProps) {
  321. if (this.state.draggedSite) {
  322. const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
  323. const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
  324. if (prevTopSites && prevTopSites[this.state.draggedIndex] &&
  325. prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url &&
  326. (!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url)) {
  327. // We got the new order from the redux store via props. We can clear state now.
  328. this.setState(_TopSiteList.DEFAULT_STATE);
  329. }
  330. }
  331. }
  332. userEvent(event, index) {
  333. this.props.dispatch(ac.UserEvent({
  334. event,
  335. source: TOP_SITES_SOURCE,
  336. action_position: index,
  337. }));
  338. }
  339. onDragEvent(event, index, link, title) {
  340. switch (event.type) {
  341. case "dragstart":
  342. this.dropped = false;
  343. this.setState({
  344. draggedIndex: index,
  345. draggedSite: link,
  346. draggedTitle: title,
  347. activeIndex: null,
  348. });
  349. this.userEvent("DRAG", index);
  350. break;
  351. case "dragend":
  352. if (!this.dropped) {
  353. // If there was no drop event, reset the state to the default.
  354. this.setState(_TopSiteList.DEFAULT_STATE);
  355. }
  356. break;
  357. case "dragenter":
  358. if (index === this.state.draggedIndex) {
  359. this.setState({topSitesPreview: null});
  360. } else {
  361. this.setState({topSitesPreview: this._makeTopSitesPreview(index)});
  362. }
  363. break;
  364. case "drop":
  365. if (index !== this.state.draggedIndex) {
  366. this.dropped = true;
  367. this.props.dispatch(ac.AlsoToMain({
  368. type: at.TOP_SITES_INSERT,
  369. data: {
  370. site: {
  371. url: this.state.draggedSite.url,
  372. label: this.state.draggedTitle,
  373. customScreenshotURL: this.state.draggedSite.customScreenshotURL,
  374. // Only if the search topsites experiment is enabled
  375. ...(this.state.draggedSite.searchTopSite && {searchTopSite: true}),
  376. },
  377. index,
  378. draggedFromIndex: this.state.draggedIndex,
  379. },
  380. }));
  381. this.userEvent("DROP", index);
  382. }
  383. break;
  384. }
  385. }
  386. _getTopSites() {
  387. // Make a copy of the sites to truncate or extend to desired length
  388. let topSites = this.props.TopSites.rows.slice();
  389. topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
  390. return topSites;
  391. }
  392. /**
  393. * Make a preview of the topsites that will be the result of dropping the currently
  394. * dragged site at the specified index.
  395. */
  396. _makeTopSitesPreview(index) {
  397. const topSites = this._getTopSites();
  398. topSites[this.state.draggedIndex] = null;
  399. const pinnedOnly = topSites.map(site => ((site && site.isPinned) ? site : null));
  400. const unpinned = topSites.filter(site => site && !site.isPinned);
  401. const siteToInsert = Object.assign({}, this.state.draggedSite, {isPinned: true, isDragged: true});
  402. if (!pinnedOnly[index]) {
  403. pinnedOnly[index] = siteToInsert;
  404. } else {
  405. // Find the hole to shift the pinned site(s) towards. We shift towards the
  406. // hole left by the site being dragged.
  407. let holeIndex = index;
  408. const indexStep = index > this.state.draggedIndex ? -1 : 1;
  409. while (pinnedOnly[holeIndex]) {
  410. holeIndex += indexStep;
  411. }
  412. // Shift towards the hole.
  413. const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
  414. while (holeIndex !== index) {
  415. const nextIndex = holeIndex + shiftingStep;
  416. pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
  417. holeIndex = nextIndex;
  418. }
  419. pinnedOnly[index] = siteToInsert;
  420. }
  421. // Fill in the remaining holes with unpinned sites.
  422. const preview = pinnedOnly;
  423. for (let i = 0; i < preview.length; i++) {
  424. if (!preview[i]) {
  425. preview[i] = unpinned.shift() || null;
  426. }
  427. }
  428. return preview;
  429. }
  430. onActivate(index) {
  431. this.setState({activeIndex: index});
  432. }
  433. render() {
  434. const {props} = this;
  435. const topSites = this.state.topSitesPreview || this._getTopSites();
  436. const topSitesUI = [];
  437. const commonProps = {
  438. onDragEvent: this.onDragEvent,
  439. dispatch: props.dispatch,
  440. intl: props.intl,
  441. };
  442. // We assign a key to each placeholder slot. We need it to be independent
  443. // of the slot index (i below) so that the keys used stay the same during
  444. // drag and drop reordering and the underlying DOM nodes are reused.
  445. // This mostly (only?) affects linux so be sure to test on linux before changing.
  446. let holeIndex = 0;
  447. // On narrow viewports, we only show 6 sites per row. We'll mark the rest as
  448. // .hide-for-narrow to hide in CSS via @media query.
  449. const maxNarrowVisibleIndex = props.TopSitesRows * 6;
  450. for (let i = 0, l = topSites.length; i < l; i++) {
  451. const link = topSites[i] && Object.assign({}, topSites[i], {iconType: this.props.topSiteIconType(topSites[i])});
  452. const slotProps = {
  453. key: link ? link.url : holeIndex++,
  454. index: i,
  455. };
  456. if (i >= maxNarrowVisibleIndex) {
  457. slotProps.className = "hide-for-narrow";
  458. }
  459. topSitesUI.push(!link ? (
  460. <TopSitePlaceholder
  461. {...slotProps}
  462. {...commonProps} />
  463. ) : (
  464. <TopSite
  465. link={link}
  466. activeIndex={this.state.activeIndex}
  467. onActivate={this.onActivate}
  468. {...slotProps}
  469. {...commonProps} />
  470. ));
  471. }
  472. return (<ul className={`top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`}>
  473. {topSitesUI}
  474. </ul>);
  475. }
  476. }
  477. export const TopSiteList = injectIntl(_TopSiteList);