Skip to content

Commit

Permalink
TOCコンポーネントを追加し、見出しに自動IDを付与する機能を実装しました。また、スタイルを改善しました。 (#618)
Browse files Browse the repository at this point in the history
  • Loading branch information
ttizze authored Feb 25, 2025
2 parents ccd2798 + 5178732 commit 3239292
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 125 deletions.
Binary file modified next/bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"p-limit": "6.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-headroom": "^3.2.1",
"react-icons": "5.5.0",
"react-select": "5.10.0",
"react-share": "5.2.1",
Expand All @@ -92,6 +93,7 @@
"sonner": "2.0.1",
"tailwind-merge": "3.0.1",
"tailwindcss-animate": "1.0.7",
"tocbot": "4.35.0",
"tsx": "4.19.3",
"unified": "11.0.5",
"unist-util-visit": "5.0.0",
Expand All @@ -111,6 +113,7 @@
"@types/node": "22.13.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/react-headroom": "^3.2.3",
"@vitejs/plugin-react": "4.3.4",
"bufferutil": "^4.0.9",
"jest-mock-extended": "^4.0.0-beta1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import { LikeButton } from "@/app/[locale]/components/like-button/client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Languages, Text } from "lucide-react";
import { Languages, Text } from "lucide-react"; // List アイコンをインポート
import { useQueryState } from "nuqs";
import { useCallback, useEffect, useRef, useState } from "react";
import { ShareDialog } from "./share-dialog";
import Toc from "./toc";

interface FloatingControlsProps {
liked: boolean;
likeCount: number;
Expand Down Expand Up @@ -36,91 +38,110 @@ export function FloatingControls({
shallow: true,
},
);

const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);

const ignoreScrollRef = useRef(false);

const handleScroll = useCallback(() => {
const handleShowFloatingControls = useCallback(() => {
if (ignoreScrollRef.current) return;

const currentScrollY = window.scrollY;
const viewportHeight = window.innerHeight;

// スクロール量をビューポートの高さに対する割合で計算
const scrollDelta = currentScrollY - lastScrollY;

if (scrollDelta > 100) {
// スクロール方向を検出
if (scrollDelta > 0) {
// 下方向へのスクロール - コントロールを非表示
setIsVisible(false);
} else if (scrollDelta < 0 || currentScrollY < 100) {
} else {
// 上方向へのスクロール - コントロールを表示
setIsVisible(true);
}

// ページ最上部付近(ビューポートの3%以内)では常に表示
if (currentScrollY < viewportHeight * 0.03) {
setIsVisible(true);
}

setLastScrollY(currentScrollY);
}, [lastScrollY]);

useEffect(() => {
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("scroll", handleShowFloatingControls, {
passive: true,
});
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("scroll", handleShowFloatingControls);
};
}, [handleScroll]);
}, [handleShowFloatingControls]);

const baseClasses =
"drop-shadow-xl dark:drop-shadow-[0_9px_7px_rgba(255,255,255,0.1)] h-12 w-12 rounded-full";
const baseButtonClasses = `${baseClasses} border relative bg-background`;
/** 非選択状態(トグルOFF)時の追加クラス */
const toggledOffClasses = `bg-muted after:absolute after:w-full after:h-[1px] after:bg-current after:top-1/2
after:left-0 after:origin-center after:-rotate-45`;
/** アイコンラッパーのクラス */

return (
<div
className={cn(
"fixed bottom-4 right-4 lg:right-8 xl:right-[15%] 2xl:right-[20%] flex gap-3 transition-all duration-300 transform",
"fixed bottom-4 right-4 lg:right-8 xl:right-[15%] 2xl:right-[20%] transition-all duration-300 transform",
isVisible ? "translate-y-0 opacity-100" : "translate-y-20 opacity-0",
)}
>
<Button
variant="ghost"
size="icon"
className={cn(baseButtonClasses, !showOriginal && toggledOffClasses)}
onClick={() => {
setShowOriginal(!showOriginal);
ignoreScrollRef.current = true;
setTimeout(() => {
ignoreScrollRef.current = false;
}, 100);
}}
title={showOriginal ? "Hide original text" : "Show original text"}
>
<Text
className={cn("h-5 w-5 opacity-100", !showOriginal && "opacity-50")}
/>
</Button>
<Button
variant="ghost"
size="icon"
className={cn(baseButtonClasses, !showTranslation && toggledOffClasses)}
onClick={() => {
setShowTranslation(!showTranslation);
ignoreScrollRef.current = true;
setTimeout(() => {
ignoreScrollRef.current = false;
}, 100);
}}
title={showTranslation ? "Hide translation" : "Show translation"}
>
<Languages
<div className=" bg-background mb-3 p-4 rounded-xl drop-shadow-xl dark:drop-shadow-[0_9px_7px_rgba(255,255,255,0.1)] border border-border">
<Toc />
</div>
<div className="flex gap-3">
<Button
variant="ghost"
size="icon"
className={cn(baseButtonClasses, !showOriginal && toggledOffClasses)}
onClick={() => {
setShowOriginal(!showOriginal);
ignoreScrollRef.current = true;
setTimeout(() => {
ignoreScrollRef.current = false;
}, 100);
}}
title={showOriginal ? "Hide original text" : "Show original text"}
>
<Text
className={cn("h-5 w-5 opacity-100", !showOriginal && "opacity-50")}
/>
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-5 w-5 opacity-100",
!showTranslation && "opacity-50",
baseButtonClasses,
!showTranslation && toggledOffClasses,
)}
/>
</Button>
<div className={baseClasses}>
<LikeButton liked={liked} likeCount={likeCount} slug={slug} />
</div>
<div className={baseClasses}>
<ShareDialog title={shareTitle} firstImageUrl={firstImageUrl} />
onClick={() => {
setShowTranslation(!showTranslation);
ignoreScrollRef.current = true;
setTimeout(() => {
ignoreScrollRef.current = false;
}, 100);
}}
title={showTranslation ? "Hide translation" : "Show translation"}
>
<Languages
className={cn(
"h-5 w-5 opacity-100",
!showTranslation && "opacity-50",
)}
/>
</Button>

<div className={baseClasses}>
<LikeButton liked={liked} likeCount={likeCount} slug={slug} />
</div>
<div className={baseClasses}>
<ShareDialog title={shareTitle} firstImageUrl={firstImageUrl} />
</div>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import parse, {
type DOMNode,
} from "html-react-parser";
import DOMPurify from "isomorphic-dompurify";
import { customAlphabet } from "nanoid";
import Image from "next/image";
import { memo } from "react";
import { SegmentAndTranslationSection } from "./segment-and-translation-section";
Expand All @@ -33,6 +34,15 @@ export function ParsedContent({

const options: HTMLReactParserOptions = {
replace: (domNode) => {
if (domNode.type === "tag" && /^h[1-6]$/.test(domNode.name)) {
// 既に id が存在するかチェック
if (!domNode.attribs.id) {
const ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const uuid = customAlphabet(ALPHABET, 8)();
domNode.attribs.id = uuid;
}
}
if (domNode.type === "tag" && domNode.attribs["data-number-id"]) {
const number = Number(domNode.attribs["data-number-id"]);
const segmentWithTranslation = segmentWithTranslations?.find(
Expand Down Expand Up @@ -73,6 +83,5 @@ export function ParsedContent({
return domNode;
},
};

return parse(sanitizedContent, options);
return <span className="js-content">{parse(sanitizedContent, options)}</span>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function SegmentAndTranslationSection({
return (
<>
{showOriginal && (
<span className="flex items-center">
<span className="flex justify-between">
<span
className={`inline-block ${
segmentWithTranslations.segmentTranslationsWithVotes.length ===
Expand All @@ -59,7 +59,7 @@ export function SegmentAndTranslationSection({
{elements}
</span>
{isOwner && slug && (
<div className="ml-auto">
<div className="ml-2">
<Link href={`/user/${currentHandle}/page/${slug}/edit`}>
<SquarePen className="w-5 h-5" />
</Link>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import * as tocbot from "tocbot";
import "tocbot/dist/tocbot.css";

export default function TableOfContents() {
useEffect(() => {
// tocbotの初期化
tocbot.init({
tocSelector: ".js-toc",
contentSelector: ".js-content",
headingSelector: "h1, h2, h3",
collapseDepth: 10,
orderedList: false,
headingLabelCallback: (text) => {
// 10文字以上の場合は10文字に切り詰めて「...」を追加
return text.length > 10 ? `${text.substring(0, 10)}...` : text;
},
});

// コンポーネントのアンマウント時にリソースを解放
return () => {
tocbot.destroy();
};
}, []);
return <nav className="js-toc" />;
}
Loading

0 comments on commit 3239292

Please sign in to comment.