Open kirin-ri opened 8 months ago
import React, { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf';
import 'pdfjs-dist/legacy/build/pdf.worker.entry';
// PDFPreviewコンポーネントが受け取るpropsの型定義。
interface PDFPreviewProps {
fileUrl: string; // 表示するPDFのURL。
pageNumber: number; // レンダリングするPDFのページ番号。
}
const PDFPreview: React.FC<PDFPreviewProps> = ({ fileUrl, pageNumber }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); // PDFレンダリング用のキャンバス要素への参照。
const [imageSrc, setImageSrc] = useState<string | null>(null); // レンダリングされたPDFページの画像ソースURLを保持する状態。
useEffect(() => {
let isActive = true; // コンポーネントがマウントされているかどうかを追跡するフラグ。
const loadPdf = async () => {
// PDFデータの解析に必要なPDF.jsのワーカースクリプトのソースを設定。
pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
try {
const pdf = await pdfjsLib.getDocument(fileUrl).promise; // PDFドキュメントを取得。
const page = await pdf.getPage(pageNumber); // PDFから特定のページを取得。
const viewport = page.getViewport({ scale: 0.5 }); // ページのビューポートを決定し、パフォーマンスのために縮小表示。
const canvas = canvasRef.current; // 現在のキャンバス要素への参照を取得。
if (canvas && isActive) {
const context = canvas.getContext('2d');
if (context) {
// キャンバスのサイズをビューポートの寸法に合わせる。
canvas.height = viewport.height;
canvas.width = viewport.width;
// PDFページのレンダリングコンテキストを定義。
const renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise; // キャンバスにPDFページをレンダリング。
if (isActive) {
const imageDataUrl = canvas.toDataURL(); // キャンバスの内容をデータURLに変換。
setImageSrc(imageDataUrl); // レンダリングされたページで画像ソース状態を更新。
}
}
}
} catch (error) {
console.error("PDFの読み込みエラー: ", error); // 処理中に発生したエラーをログ。
}
};
setImageSrc(null); // 新しいPDFをロードする前に画像ソース状態をリセット。
loadPdf(); // PDFをロードしてレンダリングする関数を呼び出し。
return () => {
isActive = false; // コンポーネントがアンマウントされた時にフラグを更新し、状態更新を防止。
};
}, [fileUrl, pageNumber]); // useEffectフックの依存関係、これらが変更された場合にエフェクトを再実行。
// imageSrcがある場合は画像を表示し、そうでなければキャンバスを表示。
return imageSrc ? (
<img src={imageSrc} alt={`ページ ${pageNumber}`} style={{ width: '100%', height: 'auto' }} />
) : (
<canvas ref={canvasRef} style={{ width: '100%', height: 'auto' }}></canvas>
);
};
export default PDFPreview; // アプリケーション内の他の場所で使用するためにコンポーネントをエクスポート。
import { IconFile } from '@tabler/icons-react';
import { useState } from 'react';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { Reference } from '@/types/reference';
import {
OpenSidebarButton,
} from './components/OpenCloseButton';
import PDFPreview from '../Referencebar/PDFPreview';
interface Props<T> {
isOpen: boolean;
side: 'left' | 'right';
items: Reference[];
toggleOpen: () => void;
}
const ReferenceSidebar = <T,>({
isOpen,
side,
items,
toggleOpen,
}: Props<T>) => {
items.map((item,index) =>{
console.log(item)
})
const [hoveredItemId, setHoveredItemId] = useState<number | null>(null);
const titleStyle = {
display: 'flex',
alignItems: 'center',
marginBottom: '20px',
fontSize:'18px'
}
const listTitleStyle = {
marginBottom: '10px',
width: '100',
}
const listItemStyle = (isHovered: boolean) => ({
display: 'flex',
flexDirection: 'column' as 'column',
marginBottom: '10px',
border: '1px solid white',
padding: '20px',
borderRadius: '5px',
backgroundColor: isHovered ? '#0056b3' : '#343541',
transition: 'background-color 0.3s',
maxWidth: '400px',
width: '100%',
overflow: 'hidden',
});
const titleAndContentContainerStyle = {
display: 'flex',
flexDirection: 'row' as 'row',
alignItems: 'flex-start',
};
const contentStyle = {
flex:'1 1 70%',
display: '-webkit-box',
WebkitBoxOrient: 'vertical' as 'vertical',
WebkitLineClamp: 7,
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const pdfPreviewStyle = {
flex:'1 1 30%',
marginRight: '20px',
};
const getPageNumberFromTitle = (title: string) => {
const match = title.match(/\((\d+)[,)]/);
return match ? `#page=${match[1]}` : '#page=1';
};
const extractPageNumberFromTitle = (title: string): number => {
const match = title.match(/\((\d+)[,)]/);
return match ? parseInt(match[1], 10) : 1;
};
return isOpen ? (
<div>
<div className={`fixed top-0 ${side}-0 z-40 flex h-full w-[400px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: '0 10px' }}>
<div style={titleStyle}>
<IconFile style={{ marginRight: '8px' }} />
参照リスト
</div>
<div onClick={toggleOpen} style={titleStyle}>
{side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
</div>
</div>
<div style={{overflowY:'auto',flex:1,padding:'0 10px'}}>
<ul>
{items.map((item, index) => (
<a href={`${item.source}?v=timestamp=${new Date().getTime()}${getPageNumberFromTitle(item.title)}`} target="_blank" rel="noopener noreferrer">
<li
style={listItemStyle(index === hoveredItemId)}
onMouseEnter={() => setHoveredItemId(index)}
onMouseLeave={() => setHoveredItemId(null)}
>
<div style={listTitleStyle}>{item.title}</div>
<div style={titleAndContentContainerStyle}>
<div style={pdfPreviewStyle}>
<PDFPreview fileUrl={item.source} pageNumber={extractPageNumberFromTitle(item.title)}/>
</div>
<div style={contentStyle}>
<p>{item.content}</p>
</div>
</div>
</li>
</a>
))}
</ul>
</div>
</div>
</div>
) : (
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
export default ReferenceSidebar;
{/* 参照リストのコンテンツ部分 */}
<div style={{overflowY:'auto', flex:1, padding:'0 10px'}}>
<ul>
{items.map((item, index) => (
// 各アイテムをリンクとして表示。クリックすると新しいタブでPDFの特定のページが開く。
<a href={`${item.source}?v=timestamp=${new Date().getTime()}${getPageNumberFromTitle(item.title)}`} target="_blank" rel="noopener noreferrer">
<li
style={listItemStyle(index === hoveredItemId)} // ホバー状態に応じてスタイルを適用
onMouseEnter={() => setHoveredItemId(index)} // マウスエンターでホバー状態を設定
onMouseLeave={() => setHoveredItemId(null)} // マウスリーブでホバー状態を解除
>
{/* アイテムのタイトル */}
<div style={listTitleStyle}>{item.title}</div>
{/* PDFプレビューとコンテンツのコンテナ */}
<div style={titleAndContentContainerStyle}>
{/* PDFプレビュー部分 */}
<div style={pdfPreviewStyle}>
<PDFPreview fileUrl={item.source} pageNumber={extractPageNumberFromTitle(item.title)}/>
</div>
{/* コンテンツ表示部分 */}
<div style={contentStyle}>
<p>{item.content}</p>
</div>
</div>
</li>
</a>
))}
</ul>
</div>
</div>
</div>
) : (
// サイドバーが閉じている時に表示される開くボタン
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
export default ReferenceSidebar;
import { IconFile } from '@tabler/icons-react';
import { useState } from 'react';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { Reference } from '@/types/reference';
import {
OpenSidebarButton,
} from './components/OpenCloseButton';
import PDFPreview from '../Referencebar/PDFPreview';
// Propsの型定義。ジェネリック型<T>は使用されていないため、削除可能。
interface Props<T> {
isOpen: boolean; // サイドバーが開いているかどうか。
side: 'left' | 'right'; // サイドバーの位置。
items: Reference[]; // 参照リストのアイテム。
toggleOpen: () => void; // サイドバーの開閉状態を切り替える関数。
}
// ReferenceSidebarコンポーネント定義。
const ReferenceSidebar = <T,>({
isOpen,
side,
items,
toggleOpen,
}: Props<T>) => {
// ホバーされたアイテムのIDを追跡するための状態。
const [hoveredItemId, setHoveredItemId] = useState<number | null>(null);
// 各種スタイル定義。
const titleStyle = {
display: 'flex',
alignItems: 'center',
marginBottom: '20px',
fontSize:'18px'
};
const listTitleStyle = {
marginBottom: '10px',
width: '100',
};
// ホバー状態に応じてスタイルを変更する関数。
const listItemStyle = (isHovered: boolean) => ({
display: 'flex',
flexDirection: 'column',
marginBottom: '10px',
border: '1px solid white',
padding: '20px',
borderRadius: '5px',
backgroundColor: isHovered ? '#0056b3' : '#343541', // ホバー状態で背景色を変更。
transition: 'background-color 0.3s',
maxWidth: '400px',
width: '100%',
overflow: 'hidden',
});
// タイトルからページ番号を抽出する関数。
const getPageNumberFromTitle = (title: string) => {
const match = title.match(/\((\d+)[,)]/);
return match ? `#page=${match[1]}` : '#page=1';
};
// タイトルからページ番号を整数で抽出する関数。
const extractPageNumberFromTitle = (title: string): number => {
const match = title.match(/\((\d+)[,)]/);
return match ? parseInt(match[1], 10) : 1;
};
// サイドバーの表示状態に応じて異なる要素をレンダリング。
return isOpen ? (
<div>
<div className={`fixed top-0 ${side}-0 z-40 flex h-full w-[400px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}>
{/* タイトルと閉じるボタン */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: '0 10px' }}>
<div style={titleStyle}>
<IconFile style={{ marginRight: '8px' }} />
参照リスト
</div>
<div onClick={toggleOpen} style={titleStyle}>
{side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
</div>
</div>
{/* 参照リストのコンテ
お疲れ様です。 LIが着手していた改修部分について整理しましたので、ご確認をお願い致します。 もっと詳しく記載して欲しいければ、ご指示ください。
また、コメント追記分のソースはマージしました。
平素より大変お世話になっております。この度は、LIが着手していた改修部分について、整理させていただきました。お忙しいところ恐縮ですが、ご確認いただけますと幸いです。もし、さらに詳細な記載が必要であれば、お手数をおかけいたしますが、ご指示いただけますとありがたいです。
また、コメントでの追記分につきましては、ソースへのマージを完了いたしました。
どうぞよろしくお願い申し上げます。
import { useEffect, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { GetServerSideProps } from 'next';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Head from 'next/head';
import { useCreateReducer } from '@/hooks/useCreateReducer';
import useErrorService from '@/services/errorService';
import useApiService from '@/services/useApiService';
import {
cleanConversationHistory,
cleanSelectedConversation,
} from '@/utils/app/clean';
import { DEFAULT_SYSTEM_PROMPT, DEFAULT_TEMPERATURE } from '@/utils/app/const';
import {
saveConversation,
saveConversations,
updateConversation,
} from '@/utils/app/conversation';
import { saveFolders } from '@/utils/app/folders';
import { savePrompts } from '@/utils/app/prompts';
import { getSettings } from '@/utils/app/settings';
import { Conversation } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { FolderInterface, FolderType } from '@/types/folder';
import { OpenAIModelID, OpenAIModels, fallbackModelID } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { Chat } from '@/components/Chat/Chat';
import { Chatbar } from '@/components/Chatbar/Chatbar';
import { Navbar } from '@/components/Mobile/Navbar';
import Promptbar from '@/components/Promptbar';
import Referencebar from '@/components/Referencebar';
import HomeContext from './home.context';
import { HomeInitialState, initialState } from './home.state';
import { v4 as uuidv4 } from 'uuid';
interface Props {
serverSideApiKeyIsSet: boolean;
serverSidePluginKeysSet: boolean;
defaultModelId: OpenAIModelID;
}
const Home = ({
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
defaultModelId,
}: Props) => {
const { t } = useTranslation('chat');
const { getModels } = useApiService();
const { getModelsError } = useErrorService();
const [initialRender, setInitialRender] = useState<boolean>(true);
const contextValue = useCreateReducer<HomeInitialState>({
initialState,
});
const {
state: {
apiKey,
lightMode,
folders,
conversations,
selectedConversation,
prompts,
temperature,
references,
currentReference,
},
dispatch,
} = contextValue;
const stopConversationRef = useRef<boolean>(false);
const { data, error, refetch } = useQuery(
['GetModels', apiKey, serverSideApiKeyIsSet],
({ signal }) => {
if (!apiKey && !serverSideApiKeyIsSet) return null;
return getModels(
{
key: apiKey,
},
signal,
);
},
{ enabled: true, refetchOnMount: false },
);
useEffect(() => {
if (data) dispatch({ field: 'models', value: data });
}, [data, dispatch]);
useEffect(() => {
dispatch({ field: 'modelError', value: getModelsError(error) });
}, [dispatch, error, getModelsError]);
// FETCH MODELS ----------------------------------------------
const handleSelectConversation = (conversation: Conversation) => {
dispatch({
field: 'selectedConversation',
value: conversation,
});
saveConversation(conversation);
let changeflg = false;
console.log(references)
for(let i = 0 ; i < references.length ; i ++){
console.log(conversation!.id)
if(references[i].conversationId === conversation!.id){
dispatch({ field: 'currentReference', value:references[i].references })
changeflg = true;
break;
}
}
if(!changeflg){
dispatch({ field: 'currentReference', value:[]})
}
};
// FOLDER OPERATIONS --------------------------------------------
const handleCreateFolder = (name: string, type: FolderType) => {
const newFolder: FolderInterface = {
id: uuidv4(),
name,
type,
};
const updatedFolders = [...folders, newFolder];
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
};
const handleDeleteFolder = (folderId: string) => {
const updatedFolders = folders.filter((f) => f.id !== folderId);
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
const updatedConversations: Conversation[] = conversations.map((c) => {
if (c.folderId === folderId) {
return {
...c,
folderId: null,
};
}
return c;
});
dispatch({ field: 'conversations', value: updatedConversations });
saveConversations(updatedConversations);
const updatedPrompts: Prompt[] = prompts.map((p) => {
if (p.folderId === folderId) {
return {
...p,
folderId: null,
};
}
return p;
});
dispatch({ field: 'prompts', value: updatedPrompts });
savePrompts(updatedPrompts);
};
const handleUpdateFolder = (folderId: string, name: string) => {
const updatedFolders = folders.map((f) => {
if (f.id === folderId) {
return {
...f,
name,
};
}
return f;
});
dispatch({ field: 'folders', value: updatedFolders });
saveFolders(updatedFolders);
};
// CONVERSATION OPERATIONS --------------------------------------------
const handleNewConversation = () => {
const lastConversation = conversations[conversations.length - 1];
const newConversation: Conversation = {
id: uuidv4(),
name: t('New Conversation'),
messages: [],
model: lastConversation?.model || {
id: OpenAIModels[defaultModelId].id,
name: OpenAIModels[defaultModelId].name,
maxLength: OpenAIModels[defaultModelId].maxLength,
tokenLimit: OpenAIModels[defaultModelId].tokenLimit,
},
prompt: DEFAULT_SYSTEM_PROMPT,
temperature: lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
folderId: null,
};
const updatedConversations = [...conversations, newConversation];
dispatch({ field: 'selectedConversation', value: newConversation });
dispatch({ field: 'conversations', value: updatedConversations });
dispatch({ field: 'currentReference',value:[] })
saveConversation(newConversation);
saveConversations(updatedConversations);
dispatch({ field: 'loading', value: false });
};
const handleUpdateConversation = (
conversation: Conversation,
data: KeyValuePair,
) => {
const updatedConversation = {
...conversation,
[data.key]: data.value,
};
const { single, all } = updateConversation(
updatedConversation,
conversations,
);
dispatch({ field: 'selectedConversation', value: single });
dispatch({ field: 'conversations', value: all });
const conversationIdList = conversations.map((elem) => elem.id);
const newReferenceList = references.map((elem) =>{
if(conversationIdList.includes(elem.conversationId)){
return elem
}
})
dispatch({ field : 'references', value:newReferenceList});
let changeflg = false;
for(let i = 0 ; i < newReferenceList.length ; i ++){
console.log(conversation!.id)
if(newReferenceList[i]!.conversationId === conversation!.id){
dispatch({ field: 'currentReference', value:newReferenceList[i]!.references });
changeflg = true;
break;
}
}
if(!changeflg){
dispatch({ field: 'currentReference', value:[]});
}
};
// EFFECTS --------------------------------------------
useEffect(() => {
if (window.innerWidth < 640) {
dispatch({ field: 'showChatbar', value: false });
}
}, [selectedConversation]);
useEffect(() => {
defaultModelId &&
dispatch({ field: 'defaultModelId', value: defaultModelId });
serverSideApiKeyIsSet &&
dispatch({
field: 'serverSideApiKeyIsSet',
value: serverSideApiKeyIsSet,
});
serverSidePluginKeysSet &&
dispatch({
field: 'serverSidePluginKeysSet',
value: serverSidePluginKeysSet,
});
}, [defaultModelId, serverSideApiKeyIsSet, serverSidePluginKeysSet]);
// ON LOAD --------------------------------------------
useEffect(() => {
const settings = getSettings();
if (settings.theme) {
dispatch({
field: 'lightMode',
value: settings.theme,
});
}
const apiKey = localStorage.getItem('apiKey');
if (serverSideApiKeyIsSet) {
dispatch({ field: 'apiKey', value: '' });
localStorage.removeItem('apiKey');
} else if (apiKey) {
dispatch({ field: 'apiKey', value: apiKey });
}
const pluginKeys = localStorage.getItem('pluginKeys');
if (serverSidePluginKeysSet) {
dispatch({ field: 'pluginKeys', value: [] });
localStorage.removeItem('pluginKeys');
} else if (pluginKeys) {
dispatch({ field: 'pluginKeys', value: pluginKeys });
}
if (window.innerWidth < 640) {
dispatch({ field: 'showChatbar', value: false });
dispatch({ field: 'showPromptbar', value: false });
}
const showChatbar = localStorage.getItem('showChatbar');
if (showChatbar) {
dispatch({ field: 'showChatbar', value: showChatbar === 'true' });
}
const showPromptbar = localStorage.getItem('showPromptbar');
if (showPromptbar) {
dispatch({ field: 'showPromptbar', value: showPromptbar === 'true' });
}
const folders = localStorage.getItem('folders');
if (folders) {
dispatch({ field: 'folders', value: JSON.parse(folders) });
}
const prompts = localStorage.getItem('prompts');
if (prompts) {
dispatch({ field: 'prompts', value: JSON.parse(prompts) });
}
const conversationHistory = localStorage.getItem('conversationHistory');
if (conversationHistory) {
const parsedConversationHistory: Conversation[] =
JSON.parse(conversationHistory);
const cleanedConversationHistory = cleanConversationHistory(
parsedConversationHistory,
);
dispatch({ field: 'conversations', value: cleanedConversationHistory });
}
const selectedConversation = localStorage.getItem('selectedConversation');
if (selectedConversation) {
const parsedSelectedConversation: Conversation =
JSON.parse(selectedConversation);
const cleanedSelectedConversation = cleanSelectedConversation(
parsedSelectedConversation,
);
dispatch({
field: 'selectedConversation',
value: cleanedSelectedConversation,
});
} else {
const lastConversation = conversations[conversations.length - 1];
dispatch({
field: 'selectedConversation',
value: {
id: uuidv4(),
name: t('New Conversation'),
messages: [],
model: OpenAIModels[defaultModelId],
prompt: DEFAULT_SYSTEM_PROMPT,
temperature: lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
folderId: null,
},
});
}
}, [
defaultModelId,
dispatch,
serverSideApiKeyIsSet,
serverSidePluginKeysSet,
]);
return (
<HomeContext.Provider
value={{
...contextValue,
handleNewConversation,
handleCreateFolder,
handleDeleteFolder,
handleUpdateFolder,
handleSelectConversation,
handleUpdateConversation,
}}
>
<Head>
<title>Chatbot UI</title>
<meta name="description" content="ChatGPT but better." />
<meta
name="viewport"
content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
{selectedConversation && (
<main
className={`flex h-screen w-screen flex-col text-sm text-white dark:text-white ${lightMode}`}
>
<div className="fixed top-0 w-full sm:hidden">
<Navbar
selectedConversation={selectedConversation}
onNewConversation={handleNewConversation}
/>
</div>
<div className="flex h-full w-full pt-[48px] sm:pt-0">
<Chatbar />
<div className="flex flex-1">
<Chat stopConversationRef={stopConversationRef} />
</div>
<Referencebar />
</div>
</main>
)}
</HomeContext.Provider>
);
};
export default Home;
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
const defaultModelId =
(process.env.DEFAULT_MODEL &&
Object.values(OpenAIModelID).includes(
process.env.DEFAULT_MODEL as OpenAIModelID,
) &&
process.env.DEFAULT_MODEL) ||
fallbackModelID;
let serverSidePluginKeysSet = false;
const googleApiKey = process.env.GOOGLE_API_KEY;
const googleCSEId = process.env.GOOGLE_CSE_ID;
if (googleApiKey && googleCSEId) {
serverSidePluginKeysSet = true;
}
return {
props: {
serverSideApiKeyIsSet: !!process.env.OPENAI_API_KEY,
defaultModelId,
serverSidePluginKeysSet,
...(await serverSideTranslations(locale ?? 'en', [
'common',
'chat',
'sidebar',
'markdown',
'promptbar',
'settings',
])),
},
};
};
import { IconFile } from '@tabler/icons-react';
import { useState } from 'react';
import { IconArrowBarLeft, IconArrowBarRight } from '@tabler/icons-react';
import { Reference } from '@/types/reference';
import {
OpenSidebarButton,
} from './components/OpenCloseButton';
import PDFPreview from '../Referencebar/PDFPreview';
interface Props<T> {
isOpen: boolean;
side: 'left' | 'right';
items: Reference[];
toggleOpen: () => void;
}
const ReferenceSidebar = <T,>({
isOpen,
side,
items,
toggleOpen,
}: Props<T>) => {
items.map((item,index) =>{
console.log(item)
})
const [hoveredItemId, setHoveredItemId] = useState<number | null>(null);
const titleStyle = {
display: 'flex',
alignItems: 'center',
marginBottom: '20px',
fontSize:'18px'
}
const listTitleStyle = {
marginBottom: '10px',
width: '100',
}
// ホバー状態に応じてスタイルを変更する関数
const listItemStyle = (isHovered: boolean) => ({
display: 'flex',
flexDirection: 'column' as 'column',
marginBottom: '10px',
border: '1px solid white',
padding: '20px',
borderRadius: '5px',
backgroundColor: isHovered ? '#0056b3' : '#343541',
transition: 'background-color 0.3s',
maxWidth: '400px',
width: '100%',
overflow: 'hidden',
});
const titleAndContentContainerStyle = {
display: 'flex',
flexDirection: 'row' as 'row',
alignItems: 'flex-start',
};
const contentStyle = {
flex:'1 1 70%',
display: '-webkit-box',
WebkitBoxOrient: 'vertical' as 'vertical',
WebkitLineClamp: 7,
overflow: 'hidden',
textOverflow: 'ellipsis',
};
const pdfPreviewStyle = {
flex:'1 1 30%',
marginRight: '20px',
};
// タイトルからページ番号を抽出する関数。
const getPageNumberFromTitle = (title: string) => {
const match = title.match(/\((\d+)[,)]/);
return match ? `#page=${match[1]}` : '#page=1';
};
// タイトルからページ番号を整数で抽出する関数。
const extractPageNumberFromTitle = (title: string): number => {
const match = title.match(/\((\d+)[,)]/);
return match ? parseInt(match[1], 10) : 1;
};
return isOpen ? (
<div>
<div className={`fixed top-0 ${side}-0 z-40 flex h-full w-[400px] flex-none flex-col space-y-2 bg-[#202123] p-2 text-[14px] transition-all sm:relative sm:top-0`}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', padding: '0 10px' }}>
<div style={titleStyle}>
<IconFile style={{ marginRight: '8px' }} />
参照リスト
</div>
<div onClick={toggleOpen} style={titleStyle}>
{side === 'right' ? <IconArrowBarRight /> : <IconArrowBarLeft />}
</div>
</div>
<div style={{overflowY:'auto',flex:1,padding:'0 10px'}}>
<ul>
{items.map((item, index) => (
<a href={`${item.source}?v=timestamp=${new Date().getTime()}${getPageNumberFromTitle(item.title)}`} target="_blank" rel="noopener noreferrer">
<li
style={listItemStyle(index === hoveredItemId)} // ホバー状態に応じてスタイルを適用
onMouseEnter={() => setHoveredItemId(index)} // マウスエンターでホバー状態を設定
onMouseLeave={() => setHoveredItemId(null)} // マウスリーブでホバー状態を解除
>
{/* アイテムのタイトル */}
<div style={listTitleStyle}>{item.title}</div>
<div style={titleAndContentContainerStyle}>
{/* PDFプレビュー部分 */}
<div style={pdfPreviewStyle}>
<PDFPreview fileUrl={item.source} pageNumber={extractPageNumberFromTitle(item.title)}/>
</div>
{/* コンテンツ表示部分 */}
<div style={contentStyle}>
<p>{item.content}</p>
</div>
</div>
</li>
</a>
))}
</ul>
</div>
</div>
</div>
) : (
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
export default ReferenceSidebar;
マウスオーバーによる関連テキストのハイライト表示機能の実装
概要 本機能は、ユーザーが参照サイドバー内の項目にマウスを重ねると、関連する会話テキストがハイライト表示されることで、参照資料と会話テキストの関連を直感的に理解できるようにするものです。
実装方法 2.1. 全体状態の管理 ReactのContext APIを使用して、アプリケーション全体で現在マウスオーバーされている項目のIDを管理します。
2.1.1. Contextの設定 GlobalStateContext.jsというファイルを作成し、以下のコードを追加します。
jsx Copy code import React, { createContext, useContext, useState } from 'react';
const GlobalStateContext = createContext();
export const useGlobalState = () => useContext(GlobalStateContext);
export const GlobalStateProvider = ({ children }) => { const [hoveredItemId, setHoveredItemId] = useState(null);
return ( <GlobalStateContext.Provider value={{ hoveredItemId, setHoveredItemId }}> {children} </GlobalStateContext.Provider> ); }; これにより、マウスオーバーされている項目のIDを全体で共有するための準備が整います。
2.2. ReferenceSidebarでの状態使用 ReferenceSidebarコンポーネント内で、マウスのイベントに応じて全体状態を更新します。
2.2.1. マウスイベントのハンドリング 項目にマウスを重ねたときと離れたときに、setHoveredItemId関数を呼び出して状態を更新します。
jsx Copy code import { useGlobalState } from './GlobalStateContext';
const ReferenceSidebar = ({ isOpen, side, items, toggleOpen }) => { const { setHoveredItemId } = useGlobalState();
return isOpen ? (
) : (
); }; 2.3. 会話表示コンポーネントでのハイライト表示 全体状態を使用して、対応するテキストをハイライト表示します。
2.3.1. 条件付きスタイルの適用 hoveredItemIdの値に基づいて、特定の会話テキストにハイライトスタイルを適用します。
jsx Copy code import { useGlobalState } from './GlobalStateContext';
const ConversationDisplay = ({ conversations }) => { const { hoveredItemId } = useGlobalState();
return (
))}
</div>
); }; 2.3.2. CSSでのハイライトスタイルの定義 ハイライト表示用のスタイルをCSSに追加します。
css Copy code .highlighted { background-color: yellow; / ハイライト色の設定 / }
報告書の要件に応じて、さらに詳細な情報を追加したり、特定の部分を強調したりすることができます。この報告書がプロジェクトのドキュメントやレビューに役立つことを願っています。
import React, { createContext, useContext, useState } from 'react';
const GlobalStateContext = createContext();
export const useGlobalState = () => useContext(GlobalStateContext);
export const GlobalStateProvider = ({ children }) => {
const [hoveredItemId, setHoveredItemId] = useState(null);
return (
<GlobalStateContext.Provider value={{ hoveredItemId, setHoveredItemId }}>
{children}
</GlobalStateContext.Provider>
);
};
import { useGlobalState } from './GlobalStateContext';
const ReferenceSidebar = ({ isOpen, side, items, toggleOpen }) => {
const { setHoveredItemId } = useGlobalState();
return isOpen ? (
<div>
{/* 中略 */}
{items.map((item, index) => (
<li onMouseEnter={() => setHoveredItemId(index)}
onMouseLeave={() => setHoveredItemId(null)}>
{/* liの内容 */}
</li>
))}
{/* 中略 */}
</div>
) : (
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
import { useGlobalState } from './GlobalStateContext';
const ReferenceSidebar = ({ isOpen, side, items, toggleOpen }) => {
const { setHoveredItemId } = useGlobalState();
return isOpen ? (
<div>
{/* 中略 */}
{items.map((item, index) => (
<li onMouseEnter={() => setHoveredItemId(index)}
onMouseLeave={() => setHoveredItemId(null)}>
{/* liの内容 */}
</li>
))}
{/* 中略 */}
</div>
) : (
<OpenSidebarButton onClick={toggleOpen} side={side} />
);
};
import { useGlobalState } from './GlobalStateContext';
const ConversationDisplay = ({ conversations }) => {
const { hoveredItemId } = useGlobalState();
return (
<div>
{conversations.map((conversation, index) => (
<div key={index} className={hoveredItemId === index ? 'highlighted' : ''}>
{conversation.text}
</div>
))}
</div>
);
};
.highlighted {
background-color: yellow; /* ハイライト色の設定 */
}