Skip to content

Commit

Permalink
feat(app): add a share button ballon for selected text in articles
Browse files Browse the repository at this point in the history
  • Loading branch information
Kohei Asai committed Apr 11, 2021
1 parent 15374c0 commit cae3259
Show file tree
Hide file tree
Showing 6 changed files with 774 additions and 402 deletions.
810 changes: 411 additions & 399 deletions components/__snapshots__/article.spec.tsx.snap

Large diffs are not rendered by default.

54 changes: 51 additions & 3 deletions components/article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,48 @@ import { FormattedMessage } from "react-intl";
import { Badge } from "./badge";
import { HorizontalList } from "./layout";
import { Markdown } from "./markdown-article";
import {
TextSelectionShareBalloon,
TextSelectionShareBalloonProps,
} from "./text-selection-share-balloon";

// TODO: too big. need to be splitted into small components
export interface ArticleProps extends React.Attributes {
/**
* The title of article.
*/
title: string;
/**
* The image that shows in the top.
*/
coverImageUrl: string;
/**
* The tags related to the article.
*/
tags?: string[];
/**
* The article's last published date.
*/
lastPublishedAt?: Date;
/**
* The author information who wrote the article.
*/
author?: {
name: string;
avatarUrl: string;
};
/**
* A markdown string as the body of article.
*/
body: string;
/**
* The url to share.
*/
shareUrl?: string;
/**
* An event listener that gets called whenever you click buttons on `<TextSelectionShareBalloon>`.
*/
onShareBalloonButtonClick?: TextSelectionShareBalloonProps["onButtonClick"];
className?: string;
style?: React.CSSProperties;
}
Expand All @@ -26,8 +57,12 @@ export const Article: React.VFC<ArticleProps> = ({
lastPublishedAt,
author,
body,
shareUrl,
onShareBalloonButtonClick,
...props
}) => {
const bodyRef = React.useRef<HTMLDivElement>(null);

return (
<article {...props}>
<img
Expand Down Expand Up @@ -129,8 +164,7 @@ export const Article: React.VFC<ArticleProps> = ({
</aside>
) : null}

<Markdown
markdown={body}
<div
className={css`
margin-block-start: var(--space-xl);
Expand All @@ -139,7 +173,21 @@ export const Article: React.VFC<ArticleProps> = ({
padding-inline-end: var(--space-md);
}
`}
/>
ref={bodyRef}
>
<Markdown markdown={body} />
</div>

{shareUrl ? (
<TextSelectionShareBalloon
shareUrl={shareUrl}
disableWhen={(anchorNode, focusNode) =>
!bodyRef.current!.contains(anchorNode) ||
!bodyRef.current!.contains(focusNode)
}
onButtonClick={onShareBalloonButtonClick}
/>
) : null}
</article>
);
};
97 changes: 97 additions & 0 deletions components/text-selection-share-balloon.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Story, Meta } from "@storybook/react";
import * as React from "react";
import {
TextSelectionShareBalloon,
TextSelectionShareBalloonProps,
} from "./text-selection-share-balloon";

export default {
title: "Components/TextSelectionShareBalloon",
component: TextSelectionShareBalloon,
argTypes: {},
args: {
shareUrl:
"https://www.kohei.dev/posts/for-engineers-who-have-overseas-ambition?hl=ja-JP",
},
} as Meta;

export const Example: Story<TextSelectionShareBalloonProps> = (props) => (
<>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Donec ultrices
tincidunt arcu non sodales. Nulla facilisi nullam vehicula ipsum a arcu
cursus. Eu mi bibendum neque egestas congue. Turpis tincidunt id aliquet
risus feugiat in ante. Sed viverra ipsum nunc aliquet bibendum enim
facilisis. Odio ut sem nulla pharetra diam sit amet. Odio ut sem nulla
pharetra diam sit amet nisl. Id cursus metus aliquam eleifend mi.
Venenatis tellus in metus vulputate eu scelerisque felis imperdiet. Etiam
erat velit scelerisque in dictum non. Magna ac placerat vestibulum lectus
mauris ultrices eros in. Nunc aliquet bibendum enim facilisis gravida.
Imperdiet dui accumsan sit amet nulla facilisi morbi. Pharetra magna ac
placerat vestibulum lectus mauris ultrices eros. Velit aliquet sagittis id
consectetur purus ut faucibus. Nisi lacus sed viverra tellus in. Sit amet
nisl purus in mollis nunc.
</p>

<p>
Arcu non sodales neque sodales ut etiam sit amet nisl. Egestas sed tempus
urna et pharetra pharetra massa massa ultricies. Amet facilisis magna
etiam tempor orci eu lobortis elementum. Eros donec ac odio tempor orci
dapibus ultrices. A diam maecenas sed enim ut sem. Aliquet bibendum enim
facilisis gravida neque. Sed cras ornare arcu dui. Bibendum neque egestas
congue quisque egestas diam in. Fermentum posuere urna nec tincidunt
praesent semper. Felis bibendum ut tristique et egestas quis. In arcu
cursus euismod quis viverra nibh cras. Fringilla urna porttitor rhoncus
dolor purus non enim.
</p>

<p>
Sit amet consectetur adipiscing elit. Consectetur purus ut faucibus
pulvinar elementum integer. Tincidunt tortor aliquam nulla facilisi. Mi
bibendum neque egestas congue quisque egestas. Duis at consectetur lorem
donec massa sapien. Feugiat pretium nibh ipsum consequat nisl. Purus
semper eget duis at tellus at urna. Volutpat maecenas volutpat blandit
aliquam etiam erat velit scelerisque. Eget sit amet tellus cras adipiscing
enim eu. Facilisi etiam dignissim diam quis enim lobortis scelerisque
fermentum. Ante in nibh mauris cursus mattis molestie. Vel turpis nunc
eget lorem dolor sed viverra. Habitant morbi tristique senectus et. Arcu
ac tortor dignissim convallis aenean et tortor at risus. Mauris cursus
mattis molestie a iaculis at erat. Risus at ultrices mi tempus imperdiet
nulla malesuada. Eget duis at tellus at urna condimentum mattis
pellentesque.
</p>

<p>
Turpis in eu mi bibendum neque. Sed tempus urna et pharetra pharetra massa
massa. Fames ac turpis egestas sed tempus urna et. Risus pretium quam
vulputate dignissim suspendisse in est. Leo urna molestie at elementum eu.
Pellentesque sit amet porttitor eget dolor. Odio eu feugiat pretium nibh
ipsum consequat nisl vel pretium. Porttitor eget dolor morbi non arcu.
Donec et odio pellentesque diam volutpat commodo sed egestas. Adipiscing
elit pellentesque habitant morbi tristique senectus. Elementum nisi quis
eleifend quam adipiscing. Amet mauris commodo quis imperdiet. Condimentum
id venenatis a condimentum vitae. Malesuada bibendum arcu vitae elementum
curabitur vitae nunc sed. Vitae et leo duis ut diam quam nulla porttitor.
Et malesuada fames ac turpis egestas integer eget. Amet nulla facilisi
morbi tempus iaculis urna id volutpat.
</p>

<p>
Ultrices sagittis orci a scelerisque purus semper eget duis at. Ut sem
nulla pharetra diam sit amet nisl suscipit. Vitae nunc sed velit dignissim
sodales ut. Eget duis at tellus at urna. Massa ultricies mi quis hendrerit
dolor magna eget est. Eros donec ac odio tempor orci. Massa tincidunt nunc
pulvinar sapien et ligula ullamcorper malesuada. Ullamcorper morbi
tincidunt ornare massa. Tortor dignissim convallis aenean et tortor at.
Suspendisse in est ante in nibh mauris cursus mattis molestie. Ut
tristique et egestas quis ipsum suspendisse ultrices. Venenatis tellus in
metus vulputate eu. Vitae purus faucibus ornare suspendisse sed nisi.
Lectus quam id leo in vitae turpis massa. Pellentesque habitant morbi
tristique senectus et netus et malesuada. Commodo quis imperdiet massa
tincidunt.
</p>

<TextSelectionShareBalloon {...props} />
</>
);
152 changes: 152 additions & 0 deletions components/text-selection-share-balloon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { css } from "@linaria/core";
import * as React from "react";
import { Facebook, Linkedin, Twitter } from "react-feather";
import {
useFacebookShare,
useLinkedinShare,
useTwitterShare,
} from "../services/social-share";
import { ButtonListBalloon, ButtonListBaloonItem } from "./button-list-balloon";

export interface TextSelectionShareBalloonProps extends React.Attributes {
/**
*
*/
shareUrl: string;
/**
* A function determine if the balloon should appear by `anchorNode` and `focusNode`. Return `true` to show the balloon.
*
* It always returns `true` if you don't pass a function.
*/
disableWhen?: (anchorNode: Node, focusNode: Node) => boolean;
/**
* An event listener that gets called whenever you click the button in the ballon.
*/
onButtonClick?: (
e: React.MouseEvent,
details: { type: string; selection: string }
) => void;
className?: string;
style?: React.CSSProperties;
}

export const TextSelectionShareBalloon: React.VFC<TextSelectionShareBalloonProps> = ({
shareUrl,
disableWhen = () => false,
onButtonClick = () => {},
className,
...props
}) => {
const [rect, setRect] = React.useState<DOMRect | null>(null);
const [text, setText] = React.useState("");
const [initialScrollY, setInitialScrollY] = React.useState(
globalThis.window?.scrollY ?? 0
);
const [scrollY, setScrollY] = React.useState(globalThis.window?.scrollY ?? 0);
const shareOnTwitter = useTwitterShare({ url: shareUrl, text });
const shareOnFacebook = useFacebookShare({ url: shareUrl, text });
const shareOnLinkedin = useLinkedinShare({ url: shareUrl });

React.useEffect(() => {
const onScroll: EventListener = () => {
setScrollY(globalThis.window.scrollY);
};

globalThis.document.addEventListener("scroll", onScroll);

return () => {
globalThis.document.removeEventListener("scroll", onScroll);
};
}, []);

React.useEffect(() => {
const onSelectionChange = () => {
const selection = globalThis.document.getSelection();

if (
selection &&
selection.type === "Range" &&
selection.rangeCount >= 1 &&
!disableWhen(selection.anchorNode!, selection.focusNode!)
) {
const firstRect = selection.getRangeAt(0).getClientRects().item(0);

setRect(firstRect);
setText(selection.toString().replaceAll(/\s+/g, " "));
setInitialScrollY(globalThis.window.scrollY);

return;
}

setRect(null);
setText("");
setInitialScrollY(0);
};

globalThis.document.addEventListener("selectionchange", onSelectionChange);

return () => {
globalThis.document.removeEventListener(
"selectionchange",
onSelectionChange
);
};
}, [disableWhen]);

return (
<ButtonListBalloon
className={css`
display: none;
position: fixed;
@media (hover: hover) and (pointer: fine) {
display: block;
}
`}
style={
rect
? {
top: rect.y - 64 - scrollY + initialScrollY,
left: rect.x,
opacity: 1,
visibility: "visible",
}
: { opacity: 0, visibility: "hidden" }
}
{...props}
>
<ButtonListBaloonItem
icon={<Twitter />}
onClick={(e) => {
onButtonClick(e, { type: "twitter", selection: text });

shareOnTwitter();
}}
>
Tweet
</ButtonListBaloonItem>

<ButtonListBaloonItem
icon={<Linkedin />}
onClick={(e) => {
onButtonClick(e, { type: "linkedin", selection: text });

shareOnLinkedin();
}}
>
Share
</ButtonListBaloonItem>

<ButtonListBaloonItem
icon={<Facebook />}
onClick={(e) => {
onButtonClick(e, { type: "facebook", selection: text });

shareOnFacebook();
}}
>
Share
</ButtonListBaloonItem>
</ButtonListBalloon>
);
};
4 changes: 4 additions & 0 deletions pages/posts/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ const Page: NextPage<ServerSideProps> = (props) => {
lastPublishedAt={lastPublishedAt}
author={author}
body={body}
shareUrl={`${origin}/posts/${slug}?hl=${intl.locale}`}
onShareBalloonButtonClick={(_, { type }) =>
userMonitoring.trackUiEvent(`click_balloon_${type}_share_button`)
}
/>
</TwoColumnPageLayoutMain>

Expand Down
Loading

0 comments on commit cae3259

Please sign in to comment.