Skip to content

Commit b7d2306

Browse files
committed
Support slide mode
1 parent 1c31df6 commit b7d2306

24 files changed

+481
-30
lines changed

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,6 @@ Qiita の Markdown 記法については[Markdown 記法 チートシート](htt
1818

1919
Qiita CLI、Qiita Preview は現在ベータ版です。
2020
機能についても開発中のものがあります。
21-
未実装の機能は以下の通りです。
22-
23-
- スライドモードのプレビュー
24-
25-
これらの機能に関しましては、正式版リリースまでに開発を行っていきます。
26-
正式リリースまでは破壊的なアップデートなどが頻繁にされる可能性がございますのでご了承ください。
2721

2822
## Qiita CLI の導入方法について
2923

@@ -147,6 +141,7 @@ private: false # true: 限定共有記事 / false: 公開記事
147141
updated_at: "" # 記事を投稿した際に自動的に記事の更新日時に変わります
148142
id: null # 記事を投稿した際に自動的に記事のUUIDに変わります
149143
organization_url_name: null # 関連付けるOrganizationのURL名
144+
slide: false # true: スライドモードON / false: スライドモードOFF
150145
---
151146
# new article body
152147
```

src/client/components/Article.tsx

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +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 [isRendered, setIsRendered] = useState(false);
25-
26-
useEffect(() => {
27-
if (isRendered) {
28-
bodyElement.current && executeScriptTagsInElement(bodyElement.current);
29-
bodyElement.current && applyMathJax(bodyElement.current);
30-
}
31-
}, [isRendered, bodyElement, renderedBody]);
32-
33-
useEffect(() => {
34-
setIsRendered(true);
35-
}, []);
21+
export const Article = ({ renderedBody, tags, title, slide }: Props) => {
22+
const bodyRef = useRef<HTMLDivElement>(null);
3623

3724
return (
3825
<article css={containerStyle}>
@@ -50,8 +37,9 @@ export const Article = ({ renderedBody, tags, title }: Props) => {
5037
))}
5138
</ul>
5239
</div>
53-
<div css={bodyStyle} className="it-MdContent" ref={bodyElement}>
54-
<div dangerouslySetInnerHTML={{ __html: renderedBody }} />
40+
<div css={bodyStyle} className="it-MdContent">
41+
{slide && <Slide renderedBody={renderedBody} title={title} />}
42+
<QiitaMarkdownHtmlBody renderedBody={renderedBody} bodyRef={bodyRef} />
5543
</div>
5644
</article>
5745
);

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy