FE_Dev / hooks /use-precise-section-alignment.tsx
GitHub Actions
Deploy from GitHub Actions [dev] - 2025-10-31 07:28:50
68f7925
import { useCallback, useEffect, useRef, useState } from 'react';
interface SectionInfo {
id: string; // aria-label
top: number; // iframe内での絶対位置 (px)
height: number; // セクションの実際の高さ (px)
bottom: number; // top + height (px)
}
interface CardAlignment {
sectionId: string; // 対応するセクションID
translateY: number; // transform値 (px)
visible: boolean; // 表示制御フラグ
}
interface UsePreciseSectionAlignmentOptions {
enabled?: boolean; // フック有効化フラグ
debounceMs?: number; // デバウンス時間 (デフォルト: 150ms)
offsetTop?: number; // 追加オフセット (デフォルト: 0px)
}
interface UsePreciseSectionAlignmentReturn {
iframeRef: React.RefObject<HTMLIFrameElement | null>;
sectionInfos: SectionInfo[];
cardAlignments: CardAlignment[];
isReady: boolean;
updatePositions: () => void;
error: Error | null;
}
// デバウンス関数
function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function usePreciseSectionAlignment(options: UsePreciseSectionAlignmentOptions = {}): UsePreciseSectionAlignmentReturn {
const { enabled = true, debounceMs = 150, offsetTop = 0 } = options;
const iframeRef = useRef<HTMLIFrameElement>(null);
const [sectionInfos, setSectionInfos] = useState<SectionInfo[]>([]);
const [cardAlignments, setCardAlignments] = useState<CardAlignment[]>([]);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Observer refs
const mutationObserverRef = useRef<MutationObserver | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// iframe内セクション位置を測定
const measureSectionPositions = useCallback((): SectionInfo[] => {
if (!iframeRef.current?.contentDocument) {
console.warn('[PreciseSectionAlignment] iframe document not available');
return [];
}
try {
const iframeDoc = iframeRef.current.contentDocument;
const iframeWindow = iframeRef.current.contentWindow;
if (!iframeWindow) {
console.warn('[PreciseSectionAlignment] iframe window not available');
return [];
}
const sections = iframeDoc.querySelectorAll('section[aria-label]');
if (sections.length === 0) {
console.warn('[PreciseSectionAlignment] No sections found in iframe');
return [];
}
const infos: SectionInfo[] = [];
// iframe自体のビューポート情報を取得
const iframeRect = iframeRef.current.getBoundingClientRect();
sections.forEach((section) => {
const ariaLabel = section.getAttribute('aria-label');
if (!ariaLabel) return;
// 方法1: offsetTopを使用(より信頼性が高い)
const offsetTop = (section as HTMLElement).offsetTop;
const offsetHeight = (section as HTMLElement).offsetHeight;
// 方法2: getBoundingClientRectとscrollTopを組み合わせる(フォールバック)
const rect = section.getBoundingClientRect();
const scrollTop = iframeWindow.pageYOffset || iframeDoc.documentElement.scrollTop || iframeDoc.body.scrollTop || 0;
// offsetTopが有効な場合はそれを使用、そうでなければgetBoundingClientRectから計算
const top = offsetTop > 0 ? offsetTop : rect.top + scrollTop;
const height = offsetHeight > 0 ? offsetHeight : rect.height;
console.log(`[PreciseSectionAlignment] Section "${ariaLabel}":`, {
offsetTop,
offsetHeight,
rectTop: rect.top,
rectHeight: rect.height,
scrollTop,
calculatedTop: top,
calculatedHeight: height,
iframeViewportTop: iframeRect.top,
});
infos.push({
id: ariaLabel,
top: top,
height: height,
bottom: top + height,
});
});
console.log('[PreciseSectionAlignment] Measured sections:', infos);
return infos;
} catch (err) {
console.error('[PreciseSectionAlignment] Error measuring sections:', err);
setError(err as Error);
return [];
}
}, []);
// カード配置位置を計算
const calculateCardAlignments = useCallback(
(sections: SectionInfo[]): CardAlignment[] => {
return sections.map((section) => ({
sectionId: section.id,
translateY: section.top + offsetTop, // iframe内セクション位置に直接対応 + オフセット
visible: true,
}));
},
[offsetTop],
);
// 位置を更新
const updatePositions = useCallback(() => {
if (!enabled) return;
const sections = measureSectionPositions();
if (sections.length > 0) {
setSectionInfos(sections);
const alignments = calculateCardAlignments(sections);
setCardAlignments(alignments);
setIsReady(true);
setError(null);
console.log('[PreciseSectionAlignment] Updated positions:', {
sectionCount: sections.length,
alignments: alignments,
});
}
}, [enabled, measureSectionPositions, calculateCardAlignments]);
// デバウンス付き更新
const debouncedUpdatePositions = useCallback(debounce(updatePositions, debounceMs), [updatePositions, debounceMs]);
// Observer設定
const setupObservers = useCallback(() => {
if (!iframeRef.current?.contentDocument || !enabled) return;
const iframeDoc = iframeRef.current.contentDocument;
// 既存のObserverをクリーンアップ
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect();
}
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
// MutationObserver: DOM構造変更を監視
mutationObserverRef.current = new MutationObserver(() => {
debouncedUpdatePositions();
});
mutationObserverRef.current.observe(iframeDoc.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class', 'src', 'aria-label'],
});
// ResizeObserver: セクションサイズ変更を監視
resizeObserverRef.current = new ResizeObserver(() => {
debouncedUpdatePositions();
});
// 全セクションをResizeObserver対象に追加
const sections = iframeDoc.querySelectorAll('section[aria-label]');
sections.forEach((section) => {
resizeObserverRef.current?.observe(section);
});
console.log('[PreciseSectionAlignment] Observers setup complete');
}, [enabled, debouncedUpdatePositions]);
// iframe読み込み監視
useEffect(() => {
// SSR回避: クライアントサイドでのみ実行
if (typeof window === 'undefined') {
return;
}
console.log('[PreciseSectionAlignment] useEffect running:', {
hasIframeRef: !!iframeRef.current,
enabled,
iframeElementTagName: iframeRef.current?.tagName,
iframeElementSrc: iframeRef.current?.src,
timestamp: new Date().toISOString(),
});
if (!iframeRef.current || !enabled) {
console.log('[PreciseSectionAlignment] Skipping - no iframe or disabled:', {
noIframe: !iframeRef.current,
disabled: !enabled,
});
return;
}
const iframe = iframeRef.current;
let retryCount = 0;
const maxRetries = 10;
const trySetup = () => {
console.log('[PreciseSectionAlignment] trySetup called:', {
hasContentDocument: !!iframe.contentDocument,
hasBody: !!iframe.contentDocument?.body,
retryCount,
maxRetries,
});
if (!iframe.contentDocument?.body) {
if (retryCount < maxRetries) {
retryCount++;
console.log('[PreciseSectionAlignment] Waiting for iframe body, attempt:', retryCount);
retryTimeoutRef.current = setTimeout(trySetup, 200);
} else {
setError(new Error('iframe setup timeout'));
console.error('[PreciseSectionAlignment] Max retries reached - iframe body not available');
}
return;
}
// 初回測定
updatePositions();
// Observer設定
setupObservers();
};
const handleLoad = () => {
console.log('[PreciseSectionAlignment] iframe load event');
retryCount = 0;
// DOM構築を待つ
setTimeout(trySetup, 100);
};
iframe.addEventListener('load', handleLoad);
// 既に読み込み済みの場合
console.log('[PreciseSectionAlignment] Initial check:', {
readyState: iframe.contentDocument?.readyState,
hasContentDocument: !!iframe.contentDocument,
});
if (iframe.contentDocument?.readyState === 'complete') {
console.log('[PreciseSectionAlignment] Document already complete, calling handleLoad');
handleLoad();
} else {
// srcdoc iframeの場合
console.log('[PreciseSectionAlignment] Document not complete, scheduling trySetup');
setTimeout(trySetup, 100);
}
return () => {
iframe.removeEventListener('load', handleLoad);
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
if (mutationObserverRef.current) {
mutationObserverRef.current.disconnect();
}
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [enabled, updatePositions, setupObservers]);
// ウィンドウリサイズ時の再計算
useEffect(() => {
if (!enabled) return;
const handleResize = () => {
console.log('[PreciseSectionAlignment] Window resized');
debouncedUpdatePositions();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [enabled, debouncedUpdatePositions]);
// エラー時のリトライ処理
useEffect(() => {
if (!error || !enabled) return;
console.log('[PreciseSectionAlignment] Error detected, scheduling retry');
const retryTimeout = setTimeout(() => {
setError(null);
updatePositions();
}, 1000);
return () => clearTimeout(retryTimeout);
}, [error, enabled, updatePositions]);
return {
iframeRef,
sectionInfos,
cardAlignments,
isReady,
updatePositions,
error,
};
}