Skip to content

Table of content 기능 추가 #45

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

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
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: 7 additions & 0 deletions component/common/ICommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ export declare namespace ICommon {
* 각 Section Payload 의 공통 요소
*/
export interface Payload {
/**
* Section ID
*
* @description 목차 이동을 위한 ID 정보
*/
sectionId: string;

/**
* Section Enable Flag
*
Expand Down
2 changes: 1 addition & 1 deletion component/common/PreProcessingComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ export function PreProcessingComponent<T extends ICommon.Payload>({
return <></>;
}

return component({ payload });
return <div id={payload.sectionId}>{component({ payload })}</div>;
}
103 changes: 102 additions & 1 deletion component/common/Style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const Style: Record<TStyleKey, CSSProperties> = {
backgroundColor: '#f5f5f5',
paddingLeft: 0,
paddingRight: 0,
marginTop: '50px',
marginTop: '100px',
height: '80px',
},

Expand All @@ -53,3 +53,104 @@ export const Style: Record<TStyleKey, CSSProperties> = {
fontWeight: 400,
},
};

type TTocStyleKey =
| 'tocBarContainer'
| 'progressBar'
| 'progressBarActive'
| 'toc'
| 'tocButton'
| 'tocItem'
| 'tocLink'
| 'tocLinkActive'
| 'tocItemActive'
| 'tocItemHover'
| 'tocItemDisabled';

export const TocStyle: Record<TTocStyleKey, CSSProperties> = {
tocBarContainer: {
width: '30px',
position: 'fixed',
right: '10%',
top: '15%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
zIndex: 1000,
},

progressBar: {
borderRadius: '5px',
backgroundColor: '#E3E2E0',
height: '5px',
width: '5px',
margin: '10px 0',
transition: 'background 0.2s',
},

progressBarActive: {
borderRadius: '5px',
backgroundColor: '#3c78d8',
height: '5px',
width: '5px',
margin: '10px 0',
transition: 'background 0.2s',
},

toc: {
position: 'fixed',
right: '10%',
top: '15%',
maxHeight: '400px',
width: '150px',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#f9f9f9',
padding: '5px',
borderRadius: '5px',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
zIndex: 1000,
opacity: 1,
transition: 'opacity 0.3s ease-in-out',
},

tocButton: {
position: 'fixed',
right: '20px',
top: '20%',
width: '30px',
height: '100px',
backgroundColor: '#ccc',
cursor: 'pointer',
},

tocItem: {
margin: '5px 0',
cursor: 'pointer',
color: '#333',
},

tocLink: {
display: 'block',
color: '#1D1B16', // 링크의 기본 색상을 회색으로 변경
textDecoration: 'none', // 링크의 밑줄 제거
},

tocLinkActive: {
display: 'block',
color: '#3c78d8', // 활성화된 링크는 파란색으로
},

tocItemActive: {
fontWeight: 'bold',
color: '#3c78d8', // 활성화된 항목의 색상
},

tocItemHover: {
backgroundColor: '#e0e0e0', // 호버 시 배경색 (짙은 회색)
},

tocItemDisabled: {
color: '#a0a0a0', // 비활성화된 항목 색상 (회색)
},
};
187 changes: 187 additions & 0 deletions component/toc/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { TocStyle } from '../common/Style';
import { sectionIds } from '../../payload'; // Payload import

interface TableOfContentsProps {
showToc: boolean; // TOC를 표시할지 여부를 props로 제어
}

export const TableOfContents = {
Component: ({ showToc }: TableOfContentsProps) => {
const [activeSection, setActiveSection] = useState<string | null>(null); // 활성화된 섹션
const [isTocVisible, setIsTocVisible] = useState(false); // TODO) CSSProperties는 hover를 지원하지 않아 의사클래스 지원이 되는 라이브러리 사용시 state제거 필요
const [hoveredItem, setHoveredItem] = useState<string | null>(null); // 호버된 항목
const [, setVisibleSections] = useState<string[]>([]); // 보이는 섹션 목록
const [isScreenSmall, setIsScreenSmall] = useState(false); // 작은 화면인지 여부
const prevScrollTopRef = useRef<number>(0);
const isManualScrollRef = useRef<boolean>(false); // 수동 스크롤(클릭) 여부
const manualScrollTimeoutRef = useRef<NodeJS.Timeout | null>(null); // 수동 스크롤 후 타이머

const getTocItemStyle = (id: string) => {
let style = { ...TocStyle.tocItem };

if (activeSection === id) {
style = { ...style, ...TocStyle.tocItemActive }; // 활성화된 항목 스타일
} else if (hoveredItem === id) {
style = { ...style, ...TocStyle.tocItemHover }; // 호버된 항목 스타일
}

return style;
};

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (isManualScrollRef.current) return; // 수동으로 스크롤 중일 때는 자동 스크롤 감지 비활성화

setVisibleSections((prevVisibleSections) => {
let updatedVisibleSections = [...prevVisibleSections];

entries.forEach((entry) => {
const sectionId = entry.target.id;
if (entry.isIntersecting) {
// 섹션이 화면에 나타났다면 리스트에 추가
if (!updatedVisibleSections.includes(sectionId)) {
updatedVisibleSections.push(sectionId);
}
} else {
// 섹션이 화면에서 사라졌다면 리스트에서 제거
updatedVisibleSections = updatedVisibleSections.filter((id) => id !== sectionId);
}
});

updatedVisibleSections = updatedVisibleSections.sort((a, b) => {
const elementA = document.getElementById(a);
const elementB = document.getElementById(b);

if (elementA && elementB) {
return elementA.getBoundingClientRect().top - elementB.getBoundingClientRect().top;
}

return 0;
});

if (updatedVisibleSections.length > 0) {
const currentScrollTop =
document.documentElement.scrollTop || document.body.scrollTop;

// 스크롤 방향에 따라 활성화할 섹션을 설정
if (currentScrollTop > prevScrollTopRef.current) {
// 스크롤을 내릴 때: 첫 번째 보이는 섹션을 활성화
setActiveSection(updatedVisibleSections[updatedVisibleSections.length - 1]);
} else {
// 스크롤을 올릴 때: 마지막 보이는 섹션을 활성화
setActiveSection(updatedVisibleSections[0]);
}
prevScrollTopRef.current = currentScrollTop;
}

return updatedVisibleSections;
});
},
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100), // 0% ~ 100% 비율을 감지하도록 설정
},
);

// 각 섹션에 대해 observer 적용
sectionIds.forEach((id) => {
const sectionElement = document.getElementById(id);
if (sectionElement) {
observer.observe(sectionElement);
}
});

// cleanup: 컴포넌트가 언마운트될 때 observer 해제
return () => {
sectionIds.forEach((id) => {
const sectionElement = document.getElementById(id);
if (sectionElement) {
observer.unobserve(sectionElement);
}
});

if (manualScrollTimeoutRef.current) {
clearTimeout(manualScrollTimeoutRef.current);
}
};
}, [sectionIds]);

useEffect(() => {
// TODO) 반응형 기준 TOC를 어떻게 처리할까에 대한 고민이 되면 좋을것 같음.
const handleResize = () => {
setIsScreenSmall(window.innerWidth <= 960); // 1920px / 2
};

// 처음 로딩 시 화면 크기 체크
handleResize();

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

if (!showToc || isScreenSmall) {
return null; // TOC를 숨김
}

const handleClick = (id: string) => {
setActiveSection(id); // 수동으로 활성화된 섹션 설정
isManualScrollRef.current = true; // 수동 스크롤 상태로 설정

// 일정 시간 동안만 수동 스크롤 상태를 유지
if (manualScrollTimeoutRef.current) {
clearTimeout(manualScrollTimeoutRef.current);
}
manualScrollTimeoutRef.current = setTimeout(() => {
isManualScrollRef.current = false; // 자동 스크롤 감지 다시 활성화
}, 1000); // 1초 동안 수동 스크롤 상태 유지
};

return (
<>
<div style={TocStyle.tocBarContainer} onMouseEnter={() => setIsTocVisible(true)}>
{sectionIds.map((id: string) => (
<div
key={id}
style={activeSection === id ? TocStyle.progressBarActive : TocStyle.progressBar}
/>
))}
</div>
<div
style={{
...TocStyle.toc,
opacity: isTocVisible ? 1 : 0,
pointerEvents: isTocVisible ? 'auto' : 'none',
}}
onMouseLeave={() => setIsTocVisible(false)}
>
<ul style={{ listStyle: 'none', padding: 10 }}>
{sectionIds.map((id) => (
<li
style={getTocItemStyle(id)}
key={`li-#${id}`}
onMouseEnter={() => setHoveredItem(id)}
onMouseLeave={() => setHoveredItem(null)}
>
<a
href={`#${id}`}
style={
activeSection === id
? TocStyle.tocLinkActive // 활성화된 링크는 파란색으로
: TocStyle.tocLink // 비활성화된 링크는 회색으로
}
onClick={() => handleClick(id)}
>
{id.charAt(0).toUpperCase() + id.slice(1)}
</a>
</li>
))}
</ul>
</div>
</>
);
},
};
1 change: 1 addition & 0 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'jquery/dist/jquery.slim';
import 'bootstrap/dist/css/bootstrap.min.css';
import '../style/global.css';

import { NextComponentType } from 'next';

Expand Down
11 changes: 10 additions & 1 deletion pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Container } from 'reactstrap';

import Head from 'next/head';
import { NextSeo } from 'next-seo';
import { useLayoutEffect } from 'react';
import { Education } from '../component/education';
import { Etc } from '../component/etc';
import { Experience } from '../component/experience';
Expand All @@ -14,10 +15,17 @@ import { Profile } from '../component/profile';
import { Project } from '../component/project';
import { Skill } from '../component/skill';
import { Style } from '../component/common/Style';
import Payload from '../payload';
import { Payload } from '../payload';
import { Article } from '../component/article';
import { TableOfContents } from '../component/toc';

function Yosume() {
useLayoutEffect(() => {
if (window.location.hash) {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
}, []);

return (
<>
<NextSeo {...Payload._global.seo} />
Expand All @@ -38,6 +46,7 @@ function Yosume() {
<Etc.Component payload={Payload.etc} />
<Footer.Component payload={Payload.footer} />
</Container>
<TableOfContents.Component showToc />
</>
);
}
Expand Down
1 change: 1 addition & 0 deletions payload/article.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IArticle } from '../component/article/IArticle';

const article: IArticle.Payload = {
sectionId: 'article',
disable: false,

list: [
Expand Down
1 change: 1 addition & 0 deletions payload/education.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IEducation } from '../component/education/IEducation';

const education: IEducation.Payload = {
sectionId: 'education',
disable: false,

list: [
Expand Down
1 change: 1 addition & 0 deletions payload/etc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IEtc } from '../component/etc/IEtc';

const etc: IEtc.Payload = {
sectionId: 'etc',
disable: false,

list: [
Expand Down
1 change: 1 addition & 0 deletions payload/experience.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IExperience } from '../component/experience/IExperience';

const experience: IExperience.Payload = {
sectionId: 'experience',
disable: false,
disableTotalPeriod: false,
list: [
Expand Down
Loading