Skip to content

Commit

Permalink
TOCコンポーネントを追加し、見出しに自動IDを付与する機能を実装しました。また、スタイルを改善しました。
Browse files Browse the repository at this point in the history
  • Loading branch information
ttizze committed Feb 25, 2025
1 parent 4be1e75 commit c82f9fe
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 54 deletions.
Binary file modified next/bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,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 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
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import * as tocbot from "tocbot";
import "tocbot/dist/tocbot.css";

export default function PostContent() {
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" />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function BaseHeaderLayout({
<div className="flex items-center gap-4">
{rightExtra}
{showUserMenu && currentUser && (
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<Avatar className="w-6 h-6">
<AvatarImage
Expand Down
17 changes: 17 additions & 0 deletions next/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,20 @@
body.virtual-keyboard-shown {
margin-top: var(--visual-viewport-offset-top, 0px);
}

.toc-link:before {
@apply !hidden;
}

.toc-list {
@apply text-slate-400 !list-none;
}
.toc-link {
@apply !no-underline;
}
.toc-link:hover {
@apply !underline;
}
.is-active-link {
@apply !text-primary;
}

0 comments on commit c82f9fe

Please sign in to comment.