Skip to content

Commit 403d612

Browse files
committed
Support slide mode
1 parent 5feb3a2 commit 403d612

24 files changed

+481
-37
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ private: false # true: 限定共有記事 / false: 公開記事
147147
updated_at: "" # 記事を投稿した際に自動的に記事の更新日時に変わります
148148
id: null # 記事を投稿した際に自動的に記事のUUIDに変わります
149149
organization_url_name: null # 関連付けるOrganizationのURL名
150+
slide: false # true: スライドモードON / false: スライドモードOFF
150151
---
151152
# new article body
152153
```

src/client/components/Article.tsx

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,25 @@
11
import { css } from "@emotion/react";
2+
import { useRef } from "react";
23
import {
34
Colors,
4-
getSpace,
55
LineHeight,
66
Typography,
77
Weight,
8+
getSpace,
89
} from "../lib/variables";
910
import { MaterialSymbol } from "./MaterialSymbol";
10-
import { useState, useEffect, useRef } from "react";
11-
import {
12-
applyMathJax,
13-
executeScriptTagsInElement,
14-
} from "../lib/embed-init-scripts";
11+
import { QiitaMarkdownHtmlBody } from "./QiitaMarkdownHtmlBody";
12+
import { Slide } from "./Slide";
1513

1614
interface Props {
1715
renderedBody: string;
1816
tags: string[];
1917
title: string;
18+
slide: boolean;
2019
}
2120

22-
export const Article = ({ renderedBody, tags, title }: Props) => {
23-
const bodyElement = useRef<HTMLDivElement>(null);
24-
const [isOpen, setIsOpen] = useState(
25-
localStorage.getItem("openArticleState") === "true" ? true : false
26-
);
27-
28-
const [isRendered, setIsRendered] = useState(false);
29-
30-
const toggleAccordion = (event: React.MouseEvent<HTMLInputElement>) => {
31-
event.preventDefault();
32-
setIsOpen((prev) => !prev);
33-
};
34-
35-
useEffect(() => {
36-
localStorage.setItem("openArticleState", JSON.stringify(isOpen));
37-
}, [isOpen]);
38-
39-
useEffect(() => {
40-
if (isRendered) {
41-
bodyElement.current && executeScriptTagsInElement(bodyElement.current);
42-
bodyElement.current && applyMathJax(bodyElement.current);
43-
}
44-
}, [isRendered, bodyElement, renderedBody]);
45-
46-
useEffect(() => {
47-
setIsRendered(true);
48-
}, []);
21+
export const Article = ({ renderedBody, tags, title, slide }: Props) => {
22+
const bodyRef = useRef<HTMLDivElement>(null);
4923

5024
return (
5125
<article css={containerStyle}>
@@ -63,8 +37,9 @@ export const Article = ({ renderedBody, tags, title }: Props) => {
6337
))}
6438
</ul>
6539
</div>
66-
<div css={bodyStyle} className="it-MdContent" ref={bodyElement}>
67-
<div dangerouslySetInnerHTML={{ __html: renderedBody }} />
40+
<div css={bodyStyle} className="it-MdContent">
41+
{slide && <Slide renderedBody={renderedBody} title={title} />}
42+
<QiitaMarkdownHtmlBody renderedBody={renderedBody} bodyRef={bodyRef} />
6843
</div>
6944
</article>
7045
);

src/client/components/ArticleInfo.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface Props {
1717
published: boolean;
1818
errorMessages: string[];
1919
qiitaItemUrl: string | null;
20+
slide: boolean;
2021
}
2122

2223
export const ArticleInfo = ({
@@ -26,6 +27,7 @@ export const ArticleInfo = ({
2627
published,
2728
errorMessages,
2829
qiitaItemUrl,
30+
slide,
2931
}: Props) => {
3032
const [isOpen, setIsOpen] = useState(
3133
localStorage.getItem("openInfoState") === "true" ? true : false
@@ -84,6 +86,7 @@ export const ArticleInfo = ({
8486
<InfoItem title="Organization">
8587
{organizationUrlName || "紐付けなし"}
8688
</InfoItem>
89+
<InfoItem title="スライドモード">{slide ? "ON" : "OFF"}</InfoItem>
8790
</details>
8891
{errorMessages.length > 0 && (
8992
<div css={errorContentsStyle}>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { RefObject, useEffect, useState } from "react";
2+
import {
3+
applyMathJax,
4+
executeScriptTagsInElement,
5+
} from "../lib/embed-init-scripts";
6+
7+
export const QiitaMarkdownHtmlBody = ({
8+
renderedBody,
9+
bodyRef,
10+
}: {
11+
renderedBody: string;
12+
bodyRef: RefObject<HTMLDivElement>;
13+
}) => {
14+
const [isRendered, setIsRendered] = useState(false);
15+
16+
useEffect(() => {
17+
setIsRendered(true);
18+
}, []);
19+
20+
useEffect(() => {
21+
if (isRendered && bodyRef.current) {
22+
executeScriptTagsInElement(bodyRef.current);
23+
applyMathJax(bodyRef.current);
24+
}
25+
}, [isRendered, bodyRef, renderedBody]);
26+
27+
return (
28+
<div dangerouslySetInnerHTML={{ __html: renderedBody }} ref={bodyRef}></div>
29+
);
30+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { SlideViewerContent } from "./SlideViewerContent";
3+
import { SlideViewerDashboard } from "./SlideViewerDashboard";
4+
5+
const LEFT_KEY = 37;
6+
const RIGHT_KEY = 39;
7+
8+
export const SlideViewer = ({ pages }: { pages: string[] }) => {
9+
const [isFullScreen, setIsFullScreen] = useState(false);
10+
11+
const contentRef = useRef<HTMLDivElement>(null);
12+
const scrollToTopOfContent = useCallback(() => {
13+
if (contentRef.current) {
14+
contentRef.current.scrollTop = 0;
15+
}
16+
}, []);
17+
18+
const [currentPageIndex, setCurrentPageIndex] = useState(0);
19+
const next = useCallback(() => {
20+
if (currentPageIndex + 1 < pages.length) {
21+
setCurrentPageIndex(currentPageIndex + 1);
22+
scrollToTopOfContent();
23+
}
24+
}, [currentPageIndex, pages.length, scrollToTopOfContent]);
25+
const prev = useCallback(() => {
26+
if (currentPageIndex - 1 >= 0) {
27+
setCurrentPageIndex(currentPageIndex - 1);
28+
scrollToTopOfContent();
29+
}
30+
}, [currentPageIndex, scrollToTopOfContent]);
31+
32+
useEffect(() => {
33+
const handleGlobalKeyDown = (event: KeyboardEvent) => {
34+
if (event.keyCode === LEFT_KEY) {
35+
prev();
36+
} else if (event.keyCode === RIGHT_KEY) {
37+
next();
38+
}
39+
};
40+
41+
window.addEventListener("keydown", handleGlobalKeyDown);
42+
43+
return () => {
44+
window.removeEventListener("keydown", handleGlobalKeyDown);
45+
};
46+
}, [next, prev]);
47+
48+
return (
49+
<div className={"slideMode" + (isFullScreen ? " fullscreen" : "")}>
50+
<div className="slideMode-Viewer">
51+
<SlideViewerContent
52+
pages={pages}
53+
currentPageIndex={currentPageIndex}
54+
onPrevious={prev}
55+
onNext={next}
56+
contentRef={contentRef}
57+
/>
58+
</div>
59+
60+
<SlideViewerDashboard
61+
currentPage={currentPageIndex + 1}
62+
totalPage={pages.length}
63+
isFullScreen={isFullScreen}
64+
onNext={next}
65+
onPrevious={prev}
66+
onSetPage={(page) => setCurrentPageIndex(page - 1)}
67+
onSwitchFullScreen={() => setIsFullScreen(!isFullScreen)}
68+
/>
69+
</div>
70+
);
71+
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import classNames from "classnames";
2+
import { MouseEvent as ReactMouseEvent, RefObject, useCallback } from "react";
3+
import { QiitaMarkdownHtmlBody } from "../QiitaMarkdownHtmlBody";
4+
5+
export const SlideViewerContent = ({
6+
pages,
7+
currentPageIndex,
8+
onPrevious,
9+
onNext,
10+
contentRef,
11+
}: {
12+
pages: string[];
13+
currentPageIndex: number;
14+
onPrevious: () => void;
15+
onNext: () => void;
16+
contentRef: RefObject<HTMLDivElement>;
17+
}) => {
18+
const handleClickScreen = useCallback<
19+
(event: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void
20+
>(
21+
(event) => {
22+
const clickedElement = event.target as HTMLElement;
23+
24+
// If a viewer clicks <img> or <a> element, we don't navigate.
25+
if (clickedElement.tagName === "IMG" || clickedElement.tagName === "A") {
26+
return;
27+
}
28+
29+
// We want to use getBoundingClientRect because it always returns
30+
// the actual rendered element dimensions, even if there are CSS
31+
// transformations applied to it.
32+
const rect = event.currentTarget.getBoundingClientRect();
33+
34+
// Should we transition to the next or the previous slide?
35+
if (event.clientX - rect.left > rect.width / 2) {
36+
onNext();
37+
} else {
38+
onPrevious();
39+
}
40+
},
41+
[onPrevious, onNext]
42+
);
43+
44+
return (
45+
<div
46+
className={classNames("slideMode-Viewer_content", "markdownContent", {
47+
"slideMode-Viewer_content--firstSlide": currentPageIndex === 0,
48+
})}
49+
onClick={handleClickScreen}
50+
>
51+
<QiitaMarkdownHtmlBody
52+
renderedBody={pages[currentPageIndex] || ""}
53+
bodyRef={contentRef}
54+
/>
55+
</div>
56+
);
57+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useState } from "react";
2+
import { MaterialSymbol } from "../MaterialSymbol";
3+
import { getMagnitudeFromRange } from "./get-magnitude-from-range";
4+
import { SlideViewerDashboardNavigation } from "./SlideViewerDashboardNavigation";
5+
import { SlideViewerDashboardQiitaLogo } from "./SlideViewerDashboardQiitaLogo";
6+
import { SlideViewerDashboardTooltip } from "./SlideViewerDashboardTooltip";
7+
8+
export const SlideViewerDashboard = ({
9+
currentPage,
10+
totalPage,
11+
isFullScreen,
12+
onPrevious,
13+
onNext,
14+
onSwitchFullScreen,
15+
onSetPage,
16+
}: {
17+
currentPage: number;
18+
totalPage: number;
19+
isFullScreen: boolean;
20+
onPrevious: () => void;
21+
onNext: () => void;
22+
onSwitchFullScreen: () => void;
23+
onSetPage: (page: number) => void;
24+
}) => {
25+
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
26+
const [destinationPage, setDestinationPage] = useState(currentPage);
27+
const [tooltipLeftDistance, setTooltipLeftDistance] = useState(0);
28+
29+
return (
30+
<div className="slideMode-Dashboard">
31+
{isTooltipVisible && (
32+
<SlideViewerDashboardTooltip leftDistance={tooltipLeftDistance}>
33+
{destinationPage}/{totalPage}
34+
</SlideViewerDashboardTooltip>
35+
)}
36+
37+
<SlideViewerDashboardNavigation
38+
currentPage={currentPage}
39+
totalPage={totalPage}
40+
onPrevious={onPrevious}
41+
onNext={onNext}
42+
/>
43+
44+
<span className="slideMode-Dashboard_pageCount">
45+
{currentPage} / {totalPage}
46+
</span>
47+
48+
<div
49+
className="slideMode-Dashboard_progress"
50+
onMouseMove={(event) => {
51+
setIsTooltipVisible(true);
52+
setTooltipLeftDistance(
53+
event.clientX - event.currentTarget.getBoundingClientRect().left
54+
);
55+
setDestinationPage(
56+
getMagnitudeFromRange(event.currentTarget, event.clientX, totalPage)
57+
);
58+
}}
59+
onMouseLeave={() => {
60+
setIsTooltipVisible(false);
61+
}}
62+
onClick={() => {
63+
onSetPage(destinationPage);
64+
}}
65+
>
66+
<div
67+
className="slideMode-Dashboard_progressFill"
68+
style={{
69+
width: `${(currentPage / totalPage) * 100}%`,
70+
}}
71+
/>
72+
</div>
73+
74+
<button
75+
aria-label={"スライドショー"}
76+
className="slideMode-Dashboard_button slideMode-Dashboard_button--fullscreen slideMode-Dashboard_button--clickable"
77+
onClick={onSwitchFullScreen}
78+
>
79+
<MaterialSymbol fill={true} size={20}>
80+
{isFullScreen ? "close_fullscreen" : "live_tv"}
81+
</MaterialSymbol>
82+
</button>
83+
84+
<SlideViewerDashboardQiitaLogo />
85+
</div>
86+
);
87+
};

0 commit comments

Comments
 (0)