123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
- import {FormattedMessage, injectIntl} from "react-intl";
- import {
- MIN_CORNER_FAVICON_SIZE,
- MIN_RICH_FAVICON_SIZE,
- TOP_SITES_CONTEXT_MENU_OPTIONS,
- TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
- TOP_SITES_SOURCE,
- } from "./TopSitesConstants";
- import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
- import React from "react";
- import {ScreenshotUtils} from "content-src/lib/screenshot-utils";
- import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
- export class TopSiteLink extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {screenshotImage: null};
- this.onDragEvent = this.onDragEvent.bind(this);
- this.onKeyPress = this.onKeyPress.bind(this);
- }
- /*
- * Helper to determine whether the drop zone should allow a drop. We only allow
- * dropping top sites for now.
- */
- _allowDrop(e) {
- return e.dataTransfer.types.includes("text/topsite-index");
- }
- onDragEvent(event) {
- switch (event.type) {
- case "click":
- // Stop any link clicks if we started any dragging
- if (this.dragged) {
- event.preventDefault();
- }
- break;
- case "dragstart":
- this.dragged = true;
- event.dataTransfer.effectAllowed = "move";
- event.dataTransfer.setData("text/topsite-index", this.props.index);
- event.target.blur();
- this.props.onDragEvent(event, this.props.index, this.props.link, this.props.title);
- break;
- case "dragend":
- this.props.onDragEvent(event);
- break;
- case "dragenter":
- case "dragover":
- case "drop":
- if (this._allowDrop(event)) {
- event.preventDefault();
- this.props.onDragEvent(event, this.props.index);
- }
- break;
- case "mousedown":
- // Block the scroll wheel from appearing for middle clicks on search top sites
- if (event.button === 1 && this.props.link.searchTopSite) {
- event.preventDefault();
- }
- // Reset at the first mouse event of a potential drag
- this.dragged = false;
- break;
- }
- }
- /**
- * Helper to obtain the next state based on nextProps and prevState.
- *
- * NOTE: Rename this method to getDerivedStateFromProps when we update React
- * to >= 16.3. We will need to update tests as well. We cannot rename this
- * method to getDerivedStateFromProps now because there is a mismatch in
- * the React version that we are using for both testing and production.
- * (i.e. react-test-render => "16.3.2", react => "16.2.0").
- *
- * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
- */
- static getNextStateFromProps(nextProps, prevState) {
- const {screenshot} = nextProps.link;
- const imageInState = ScreenshotUtils.isRemoteImageLocal(prevState.screenshotImage, screenshot);
- if (imageInState) {
- return null;
- }
- // Since image was updated, attempt to revoke old image blob URL, if it exists.
- ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage);
- return {screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot)};
- }
- // NOTE: Remove this function when we update React to >= 16.3 since React will
- // call getDerivedStateFromProps automatically. We will also need to
- // rename getNextStateFromProps to getDerivedStateFromProps.
- componentWillMount() {
- const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
- if (nextState) {
- this.setState(nextState);
- }
- }
- // NOTE: Remove this function when we update React to >= 16.3 since React will
- // call getDerivedStateFromProps automatically. We will also need to
- // rename getNextStateFromProps to getDerivedStateFromProps.
- componentWillReceiveProps(nextProps) {
- const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
- if (nextState) {
- this.setState(nextState);
- }
- }
- componentWillUnmount() {
- ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage);
- }
- onKeyPress(event) {
- // If we have tabbed to a search shortcut top site, and we click 'enter',
- // we should execute the onClick function. This needs to be added because
- // search top sites are anchor tags without an href. See bug 1483135
- if (this.props.link.searchTopSite && event.key === "Enter") {
- this.props.onClick(event);
- }
- }
- render() {
- const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
- const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}${link.searchTopSite ? " search-shortcut" : ""}`;
- const {tippyTopIcon, faviconSize} = link;
- const [letterFallback] = title;
- let imageClassName;
- let imageStyle;
- let showSmallFavicon = false;
- let smallFaviconStyle;
- let smallFaviconFallback;
- let hasScreenshotImage = this.state.screenshotImage && this.state.screenshotImage.url;
- if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
- smallFaviconFallback = false;
- } else if (link.searchTopSite) {
- imageClassName = "top-site-icon rich-icon";
- imageStyle = {
- backgroundColor: link.backgroundColor,
- backgroundImage: `url(${tippyTopIcon})`,
- };
- smallFaviconStyle = {backgroundImage: `url(${tippyTopIcon})`};
- } else if (link.customScreenshotURL) {
- // assume high quality custom screenshot and use rich icon styles and class names
- imageClassName = "top-site-icon rich-icon";
- imageStyle = {
- backgroundColor: link.backgroundColor,
- backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "none",
- };
- } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
- // styles and class names for top sites with rich icons
- imageClassName = "top-site-icon rich-icon";
- imageStyle = {
- backgroundColor: link.backgroundColor,
- backgroundImage: `url(${tippyTopIcon || link.favicon})`,
- };
- } else {
- // styles and class names for top sites with screenshot + small icon in top left corner
- imageClassName = `screenshot${hasScreenshotImage ? " active" : ""}`;
- imageStyle = {backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "none"};
- // only show a favicon in top left if it's greater than 16x16
- if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
- showSmallFavicon = true;
- smallFaviconStyle = {backgroundImage: `url(${link.favicon})`};
- } else if (hasScreenshotImage) {
- // Don't show a small favicon if there is no screenshot, because that
- // would result in two fallback icons
- showSmallFavicon = true;
- smallFaviconFallback = true;
- }
- }
- let draggableProps = {};
- if (isDraggable) {
- draggableProps = {
- onClick: this.onDragEvent,
- onDragEnd: this.onDragEvent,
- onDragStart: this.onDragEvent,
- onMouseDown: this.onDragEvent,
- };
- }
- return (<li className={topSiteOuterClassName} onDrop={this.onDragEvent} onDragOver={this.onDragEvent} onDragEnter={this.onDragEvent} onDragLeave={this.onDragEvent} {...draggableProps}>
- <div className="top-site-inner">
- {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */}
- {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
- <a className="top-site-button" href={link.searchTopSite ? undefined : link.url} tabIndex="0" onKeyPress={this.onKeyPress} onClick={onClick} draggable={true}>
- <div className="tile" aria-hidden={true} data-fallback={letterFallback}>
- <div className={imageClassName} style={imageStyle} />
- {link.searchTopSite && <div className="top-site-icon search-topsite" />}
- {showSmallFavicon && <div
- className="top-site-icon default-icon"
- data-fallback={smallFaviconFallback && letterFallback}
- style={smallFaviconStyle} />}
- </div>
- <div className={`title ${link.isPinned ? "pinned" : ""}`}>
- {link.isPinned && <div className="icon icon-pin-small" />}
- <span dir="auto">{title}</span>
- </div>
- </a>
- {children}
- </div>
- </li>);
- }
- }
- TopSiteLink.defaultProps = {
- title: "",
- link: {},
- isDraggable: true,
- };
- export class TopSite extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {showContextMenu: false};
- this.onLinkClick = this.onLinkClick.bind(this);
- this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
- this.onMenuUpdate = this.onMenuUpdate.bind(this);
- }
- /**
- * Report to telemetry additional information about the item.
- */
- _getTelemetryInfo() {
- const value = {icon_type: this.props.link.iconType};
- // Filter out "not_pinned" type for being the default
- if (this.props.link.isPinned) {
- value.card_type = "pinned";
- }
- if (this.props.link.searchTopSite) {
- // Set the card_type as "search" regardless of its pinning status
- value.card_type = "search";
- value.search_vendor = this.props.link.hostname;
- }
- return {value};
- }
- userEvent(event) {
- this.props.dispatch(ac.UserEvent(Object.assign({
- event,
- source: TOP_SITES_SOURCE,
- action_position: this.props.index,
- }, this._getTelemetryInfo())));
- }
- onLinkClick(event) {
- this.userEvent("CLICK");
- // Specially handle a top site link click for "typed" frecency bonus as
- // specified as a property on the link.
- event.preventDefault();
- const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
- if (!this.props.link.searchTopSite) {
- this.props.dispatch(ac.OnlyToMain({
- type: at.OPEN_LINK,
- data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}}),
- }));
- } else {
- this.props.dispatch(ac.OnlyToMain({
- type: at.FILL_SEARCH_TERM,
- data: {label: this.props.link.label},
- }));
- }
- }
- onMenuButtonClick(event) {
- event.preventDefault();
- this.props.onActivate(this.props.index);
- this.setState({showContextMenu: true});
- }
- onMenuUpdate(showContextMenu) {
- this.setState({showContextMenu});
- }
- render() {
- const {props} = this;
- const {link} = props;
- const isContextMenuOpen = this.state.showContextMenu && props.activeIndex === props.index;
- const title = link.label || link.hostname;
- return (<TopSiteLink {...props} onClick={this.onLinkClick} onDragEvent={this.props.onDragEvent} className={`${props.className || ""}${isContextMenuOpen ? " active" : ""}`} title={title}>
- <div>
- <button aria-haspopup="true" className="context-menu-button icon" title={this.props.intl.formatMessage({id: "context_menu_title"})} onClick={this.onMenuButtonClick}>
- <span className="sr-only">
- <FormattedMessage id="context_menu_button_sr" values={{title}} />
- </span>
- </button>
- {isContextMenuOpen &&
- <LinkMenu
- dispatch={props.dispatch}
- index={props.index}
- onUpdate={this.onMenuUpdate}
- options={link.searchTopSite ? TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS : TOP_SITES_CONTEXT_MENU_OPTIONS}
- site={link}
- siteInfo={this._getTelemetryInfo()}
- source={TOP_SITES_SOURCE} />
- }
- </div>
- </TopSiteLink>);
- }
- }
- TopSite.defaultProps = {
- link: {},
- onActivate() {},
- };
- export class TopSitePlaceholder extends React.PureComponent {
- constructor(props) {
- super(props);
- this.onEditButtonClick = this.onEditButtonClick.bind(this);
- }
- onEditButtonClick() {
- this.props.dispatch(
- {type: at.TOP_SITES_EDIT, data: {index: this.props.index}});
- }
- render() {
- return (<TopSiteLink {...this.props} className={`placeholder ${this.props.className || ""}`} isDraggable={false}>
- <button aria-haspopup="true" className="context-menu-button edit-button icon"
- title={this.props.intl.formatMessage({id: "edit_topsites_edit_button"})}
- onClick={this.onEditButtonClick} />
- </TopSiteLink>);
- }
- }
- export class _TopSiteList extends React.PureComponent {
- static get DEFAULT_STATE() {
- return {
- activeIndex: null,
- draggedIndex: null,
- draggedSite: null,
- draggedTitle: null,
- topSitesPreview: null,
- };
- }
- constructor(props) {
- super(props);
- this.state = _TopSiteList.DEFAULT_STATE;
- this.onDragEvent = this.onDragEvent.bind(this);
- this.onActivate = this.onActivate.bind(this);
- }
- componentWillReceiveProps(nextProps) {
- if (this.state.draggedSite) {
- const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
- const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
- if (prevTopSites && prevTopSites[this.state.draggedIndex] &&
- prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url &&
- (!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url)) {
- // We got the new order from the redux store via props. We can clear state now.
- this.setState(_TopSiteList.DEFAULT_STATE);
- }
- }
- }
- userEvent(event, index) {
- this.props.dispatch(ac.UserEvent({
- event,
- source: TOP_SITES_SOURCE,
- action_position: index,
- }));
- }
- onDragEvent(event, index, link, title) {
- switch (event.type) {
- case "dragstart":
- this.dropped = false;
- this.setState({
- draggedIndex: index,
- draggedSite: link,
- draggedTitle: title,
- activeIndex: null,
- });
- this.userEvent("DRAG", index);
- break;
- case "dragend":
- if (!this.dropped) {
- // If there was no drop event, reset the state to the default.
- this.setState(_TopSiteList.DEFAULT_STATE);
- }
- break;
- case "dragenter":
- if (index === this.state.draggedIndex) {
- this.setState({topSitesPreview: null});
- } else {
- this.setState({topSitesPreview: this._makeTopSitesPreview(index)});
- }
- break;
- case "drop":
- if (index !== this.state.draggedIndex) {
- this.dropped = true;
- this.props.dispatch(ac.AlsoToMain({
- type: at.TOP_SITES_INSERT,
- data: {
- site: {
- url: this.state.draggedSite.url,
- label: this.state.draggedTitle,
- customScreenshotURL: this.state.draggedSite.customScreenshotURL,
- // Only if the search topsites experiment is enabled
- ...(this.state.draggedSite.searchTopSite && {searchTopSite: true}),
- },
- index,
- draggedFromIndex: this.state.draggedIndex,
- },
- }));
- this.userEvent("DROP", index);
- }
- break;
- }
- }
- _getTopSites() {
- // Make a copy of the sites to truncate or extend to desired length
- let topSites = this.props.TopSites.rows.slice();
- topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
- return topSites;
- }
- /**
- * Make a preview of the topsites that will be the result of dropping the currently
- * dragged site at the specified index.
- */
- _makeTopSitesPreview(index) {
- const topSites = this._getTopSites();
- topSites[this.state.draggedIndex] = null;
- const pinnedOnly = topSites.map(site => ((site && site.isPinned) ? site : null));
- const unpinned = topSites.filter(site => site && !site.isPinned);
- const siteToInsert = Object.assign({}, this.state.draggedSite, {isPinned: true, isDragged: true});
- if (!pinnedOnly[index]) {
- pinnedOnly[index] = siteToInsert;
- } else {
- // Find the hole to shift the pinned site(s) towards. We shift towards the
- // hole left by the site being dragged.
- let holeIndex = index;
- const indexStep = index > this.state.draggedIndex ? -1 : 1;
- while (pinnedOnly[holeIndex]) {
- holeIndex += indexStep;
- }
- // Shift towards the hole.
- const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
- while (holeIndex !== index) {
- const nextIndex = holeIndex + shiftingStep;
- pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
- holeIndex = nextIndex;
- }
- pinnedOnly[index] = siteToInsert;
- }
- // Fill in the remaining holes with unpinned sites.
- const preview = pinnedOnly;
- for (let i = 0; i < preview.length; i++) {
- if (!preview[i]) {
- preview[i] = unpinned.shift() || null;
- }
- }
- return preview;
- }
- onActivate(index) {
- this.setState({activeIndex: index});
- }
- render() {
- const {props} = this;
- const topSites = this.state.topSitesPreview || this._getTopSites();
- const topSitesUI = [];
- const commonProps = {
- onDragEvent: this.onDragEvent,
- dispatch: props.dispatch,
- intl: props.intl,
- };
- // We assign a key to each placeholder slot. We need it to be independent
- // of the slot index (i below) so that the keys used stay the same during
- // drag and drop reordering and the underlying DOM nodes are reused.
- // This mostly (only?) affects linux so be sure to test on linux before changing.
- let holeIndex = 0;
- // On narrow viewports, we only show 6 sites per row. We'll mark the rest as
- // .hide-for-narrow to hide in CSS via @media query.
- const maxNarrowVisibleIndex = props.TopSitesRows * 6;
- for (let i = 0, l = topSites.length; i < l; i++) {
- const link = topSites[i] && Object.assign({}, topSites[i], {iconType: this.props.topSiteIconType(topSites[i])});
- const slotProps = {
- key: link ? link.url : holeIndex++,
- index: i,
- };
- if (i >= maxNarrowVisibleIndex) {
- slotProps.className = "hide-for-narrow";
- }
- topSitesUI.push(!link ? (
- <TopSitePlaceholder
- {...slotProps}
- {...commonProps} />
- ) : (
- <TopSite
- link={link}
- activeIndex={this.state.activeIndex}
- onActivate={this.onActivate}
- {...slotProps}
- {...commonProps} />
- ));
- }
- return (<ul className={`top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`}>
- {topSitesUI}
- </ul>);
- }
- }
- export const TopSiteList = injectIntl(_TopSiteList);
|