Skip to content

Support slide mode #27

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@ Qiita の Markdown 記法については[Markdown 記法 チートシート](htt

Qiita CLI、Qiita Preview は現在ベータ版です。
機能についても開発中のものがあります。
未実装の機能は以下の通りです。

- スライドモードのプレビュー

これらの機能に関しましては、正式版リリースまでに開発を行っていきます。
正式リリースまでは破壊的なアップデートなどが頻繁にされる可能性がございますのでご了承ください。

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

Expand Down Expand Up @@ -147,6 +141,7 @@ private: false # true: 限定共有記事 / false: 公開記事
updated_at: "" # 記事を投稿した際に自動的に記事の更新日時に変わります
id: null # 記事を投稿した際に自動的に記事のUUIDに変わります
organization_url_name: null # 関連付けるOrganizationのURL名
slide: false # true: スライドモードON / false: スライドモードOFF
---
# new article body
```
Expand Down
32 changes: 10 additions & 22 deletions src/client/components/Article.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,25 @@
import { css } from "@emotion/react";
import { useRef } from "react";
import {
Colors,
getSpace,
LineHeight,
Typography,
Weight,
getSpace,
} from "../lib/variables";
import { MaterialSymbol } from "./MaterialSymbol";
import { useState, useEffect, useRef } from "react";
import {
applyMathJax,
executeScriptTagsInElement,
} from "../lib/embed-init-scripts";
import { QiitaMarkdownHtmlBody } from "./QiitaMarkdownHtmlBody";
import { Slide } from "./Slide";

interface Props {
renderedBody: string;
tags: string[];
title: string;
slide: boolean;
}

export const Article = ({ renderedBody, tags, title }: Props) => {
const bodyElement = useRef<HTMLDivElement>(null);
const [isRendered, setIsRendered] = useState(false);

useEffect(() => {
if (isRendered) {
bodyElement.current && executeScriptTagsInElement(bodyElement.current);
bodyElement.current && applyMathJax(bodyElement.current);
}
}, [isRendered, bodyElement, renderedBody]);

useEffect(() => {
setIsRendered(true);
}, []);
export const Article = ({ renderedBody, tags, title, slide }: Props) => {
const bodyRef = useRef<HTMLDivElement>(null);

return (
<article css={containerStyle}>
Expand All @@ -50,8 +37,9 @@ export const Article = ({ renderedBody, tags, title }: Props) => {
))}
</ul>
</div>
<div css={bodyStyle} className="it-MdContent" ref={bodyElement}>
<div dangerouslySetInnerHTML={{ __html: renderedBody }} />
<div css={bodyStyle} className="it-MdContent">
{slide && <Slide renderedBody={renderedBody} title={title} />}
<QiitaMarkdownHtmlBody renderedBody={renderedBody} bodyRef={bodyRef} />
</div>
</article>
);
Expand Down
3 changes: 3 additions & 0 deletions src/client/components/ArticleInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface Props {
published: boolean;
errorMessages: string[];
qiitaItemUrl: string | null;
slide: boolean;
}

export const ArticleInfo = ({
Expand All @@ -26,6 +27,7 @@ export const ArticleInfo = ({
published,
errorMessages,
qiitaItemUrl,
slide,
}: Props) => {
const [isOpen, setIsOpen] = useState(
localStorage.getItem("openInfoState") === "true" ? true : false
Expand Down Expand Up @@ -84,6 +86,7 @@ export const ArticleInfo = ({
<InfoItem title="Organization">
{organizationUrlName || "紐付けなし"}
</InfoItem>
<InfoItem title="スライドモード">{slide ? "ON" : "OFF"}</InfoItem>
</details>
{errorMessages.length > 0 && (
<div css={errorContentsStyle}>
Expand Down
30 changes: 30 additions & 0 deletions src/client/components/QiitaMarkdownHtmlBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { RefObject, useEffect, useState } from "react";
import {
applyMathJax,
executeScriptTagsInElement,
} from "../lib/embed-init-scripts";

export const QiitaMarkdownHtmlBody = ({
renderedBody,
bodyRef,
}: {
renderedBody: string;
bodyRef: RefObject<HTMLDivElement>;
}) => {
const [isRendered, setIsRendered] = useState(false);

useEffect(() => {
setIsRendered(true);
}, []);

useEffect(() => {
if (isRendered && bodyRef.current) {
executeScriptTagsInElement(bodyRef.current);
applyMathJax(bodyRef.current);
}
}, [isRendered, bodyRef, renderedBody]);

return (
<div dangerouslySetInnerHTML={{ __html: renderedBody }} ref={bodyRef}></div>
);
};
71 changes: 71 additions & 0 deletions src/client/components/Slide/SlideViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { SlideViewerContent } from "./SlideViewerContent";
import { SlideViewerDashboard } from "./SlideViewerDashboard";

const LEFT_KEY = 37;
const RIGHT_KEY = 39;

export const SlideViewer = ({ pages }: { pages: string[] }) => {
const [isFullScreen, setIsFullScreen] = useState(false);

const contentRef = useRef<HTMLDivElement>(null);
const scrollToTopOfContent = useCallback(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, []);

const [currentPageIndex, setCurrentPageIndex] = useState(0);
const next = useCallback(() => {
if (currentPageIndex + 1 < pages.length) {
setCurrentPageIndex(currentPageIndex + 1);
scrollToTopOfContent();
}
}, [currentPageIndex, pages.length, scrollToTopOfContent]);
const prev = useCallback(() => {
if (currentPageIndex - 1 >= 0) {
setCurrentPageIndex(currentPageIndex - 1);
scrollToTopOfContent();
}
}, [currentPageIndex, scrollToTopOfContent]);

useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.keyCode === LEFT_KEY) {
prev();
} else if (event.keyCode === RIGHT_KEY) {
next();
}
};

window.addEventListener("keydown", handleGlobalKeyDown);

return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
};
}, [next, prev]);

return (
<div className={"slideMode" + (isFullScreen ? " fullscreen" : "")}>
<div className="slideMode-Viewer">
<SlideViewerContent
pages={pages}
currentPageIndex={currentPageIndex}
onPrevious={prev}
onNext={next}
contentRef={contentRef}
/>
</div>

<SlideViewerDashboard
currentPage={currentPageIndex + 1}
totalPage={pages.length}
isFullScreen={isFullScreen}
onNext={next}
onPrevious={prev}
onSetPage={(page) => setCurrentPageIndex(page - 1)}
onSwitchFullScreen={() => setIsFullScreen(!isFullScreen)}
/>
</div>
);
};
57 changes: 57 additions & 0 deletions src/client/components/Slide/SlideViewerContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent, RefObject, useCallback } from "react";
import { QiitaMarkdownHtmlBody } from "../QiitaMarkdownHtmlBody";

export const SlideViewerContent = ({
pages,
currentPageIndex,
onPrevious,
onNext,
contentRef,
}: {
pages: string[];
currentPageIndex: number;
onPrevious: () => void;
onNext: () => void;
contentRef: RefObject<HTMLDivElement>;
}) => {
const handleClickScreen = useCallback<
(event: ReactMouseEvent<HTMLDivElement, MouseEvent>) => void
>(
(event) => {
const clickedElement = event.target as HTMLElement;

// If a viewer clicks <img> or <a> element, we don't navigate.
if (clickedElement.tagName === "IMG" || clickedElement.tagName === "A") {
return;
}

// We want to use getBoundingClientRect because it always returns
// the actual rendered element dimensions, even if there are CSS
// transformations applied to it.
const rect = event.currentTarget.getBoundingClientRect();

// Should we transition to the next or the previous slide?
if (event.clientX - rect.left > rect.width / 2) {
onNext();
} else {
onPrevious();
}
},
[onPrevious, onNext]
);

return (
<div
className={classNames("slideMode-Viewer_content", "markdownContent", {
"slideMode-Viewer_content--firstSlide": currentPageIndex === 0,
})}
onClick={handleClickScreen}
>
<QiitaMarkdownHtmlBody
renderedBody={pages[currentPageIndex] || ""}
bodyRef={contentRef}
/>
</div>
);
};
87 changes: 87 additions & 0 deletions src/client/components/Slide/SlideViewerDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState } from "react";
import { MaterialSymbol } from "../MaterialSymbol";
import { getMagnitudeFromRange } from "./get-magnitude-from-range";
import { SlideViewerDashboardNavigation } from "./SlideViewerDashboardNavigation";
import { SlideViewerDashboardQiitaLogo } from "./SlideViewerDashboardQiitaLogo";
import { SlideViewerDashboardTooltip } from "./SlideViewerDashboardTooltip";

export const SlideViewerDashboard = ({
currentPage,
totalPage,
isFullScreen,
onPrevious,
onNext,
onSwitchFullScreen,
onSetPage,
}: {
currentPage: number;
totalPage: number;
isFullScreen: boolean;
onPrevious: () => void;
onNext: () => void;
onSwitchFullScreen: () => void;
onSetPage: (page: number) => void;
}) => {
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const [destinationPage, setDestinationPage] = useState(currentPage);
const [tooltipLeftDistance, setTooltipLeftDistance] = useState(0);

return (
<div className="slideMode-Dashboard">
{isTooltipVisible && (
<SlideViewerDashboardTooltip leftDistance={tooltipLeftDistance}>
{destinationPage}/{totalPage}
</SlideViewerDashboardTooltip>
)}

<SlideViewerDashboardNavigation
currentPage={currentPage}
totalPage={totalPage}
onPrevious={onPrevious}
onNext={onNext}
/>

<span className="slideMode-Dashboard_pageCount">
{currentPage} / {totalPage}
</span>

<div
className="slideMode-Dashboard_progress"
onMouseMove={(event) => {
setIsTooltipVisible(true);
setTooltipLeftDistance(
event.clientX - event.currentTarget.getBoundingClientRect().left
);
setDestinationPage(
getMagnitudeFromRange(event.currentTarget, event.clientX, totalPage)
);
}}
onMouseLeave={() => {
setIsTooltipVisible(false);
}}
onClick={() => {
onSetPage(destinationPage);
}}
>
<div
className="slideMode-Dashboard_progressFill"
style={{
width: `${(currentPage / totalPage) * 100}%`,
}}
/>
</div>

<button
aria-label={"スライドショー"}
className="slideMode-Dashboard_button slideMode-Dashboard_button--fullscreen slideMode-Dashboard_button--clickable"
onClick={onSwitchFullScreen}
>
<MaterialSymbol fill={true} size={20}>
{isFullScreen ? "close_fullscreen" : "live_tv"}
</MaterialSymbol>
</button>

<SlideViewerDashboardQiitaLogo />
</div>
);
};
Loading