From defe068e23bdc7bfc26edaeaf5e4e18f3ef20d21 Mon Sep 17 00:00:00 2001 From: Daiki Ikeda Date: Sat, 6 Jun 2026 15:29:53 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=E5=8F=B0=E6=9C=AC=E4=B8=8B=E9=83=A8?= =?UTF-8?q?=E4=B8=AD=E5=A4=AE=E3=81=AB=E3=82=BB=E3=83=AA=E3=83=95=E9=80=81?= =?UTF-8?q?=E3=82=8A=E3=83=8A=E3=83=93=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 選択キャラの次/前のセリフへスクロールするボタンを新設し、最初へ・最後へと 合わせて画面下部中央へ横並びで配置(左から 最後へ・次のセリフ・前のセリフ・最初へ)。 スクロール系は FloatingActions から ScriptNav へ移し、FAB は txt・印刷のみに整理。 次/前の判定は vertical-rl の幾何(start=右・end=左)を踏まえた純粋関数 findAdjacentDialogue に切り出してテスト。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 4 ++ src/components/FloatingActions.tsx | 24 ---------- src/components/ScriptNav.tsx | 73 ++++++++++++++++++++++++++++++ src/components/ScriptView.tsx | 1 + src/index.css | 36 +++++++++++++++ src/lib/scriptNav.test.ts | 39 ++++++++++++++++ src/lib/scriptNav.ts | 29 ++++++++++++ 7 files changed, 182 insertions(+), 24 deletions(-) create mode 100644 src/components/ScriptNav.tsx create mode 100644 src/lib/scriptNav.test.ts create mode 100644 src/lib/scriptNav.ts diff --git a/src/App.tsx b/src/App.tsx index e4bc283..b881cb4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { FloatingActions } from "./components/FloatingActions"; +import { ScriptNav } from "./components/ScriptNav"; import { ScriptSummary } from "./components/ScriptSummary"; import { ScriptView } from "./components/ScriptView"; import { toggleCharactersByMatch } from "./lib/characterToggle"; @@ -153,6 +154,9 @@ export function App() { noVoice={noVoice} onToggleNoVoice={toggleNoVoice} /> + {dialogues.length > 0 && ( + 0} /> + )} ); diff --git a/src/components/FloatingActions.tsx b/src/components/FloatingActions.tsx index 487e10b..42b7a3e 100644 --- a/src/components/FloatingActions.tsx +++ b/src/components/FloatingActions.tsx @@ -3,34 +3,10 @@ type Props = { }; export function FloatingActions({ onExportText }: Props) { - const scrollToStart = () => - window.scrollTo({ left: 0, top: 0, behavior: "smooth" }); - const scrollToEnd = () => - window.scrollTo({ - left: -document.documentElement.scrollWidth, - top: 0, - behavior: "smooth", - }); const handlePrint = () => window.print(); return (
- - + + + +
+ ); +} diff --git a/src/components/ScriptView.tsx b/src/components/ScriptView.tsx index a1fc609..837d5c0 100644 --- a/src/components/ScriptView.tsx +++ b/src/components/ScriptView.tsx @@ -36,6 +36,7 @@ export function ScriptView({ diff --git a/src/index.css b/src/index.css index 69dd214..01a836f 100644 --- a/src/index.css +++ b/src/index.css @@ -99,7 +99,7 @@ button.character-dialogue:disabled { bottom: 0; left: calc(-1 * var(--page-margin)); width: var(--page-margin); - background: linear-gradient(to right, transparent, white); + background: white; pointer-events: none; } input, From e62711963e1fce528330179b9fa99a9d2be4776c Mon Sep 17 00:00:00 2001 From: Daiki Ikeda Date: Sat, 6 Jun 2026 15:50:53 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=E3=82=B5=E3=83=9E=E3=83=AA=E5=B7=A6?= =?UTF-8?q?=E3=81=AE=E4=BD=99=E5=88=86=E3=81=AA=E3=83=9E=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=B3=E3=81=A8=E3=83=9E=E3=82=B9=E3=82=AF=E3=82=92=E6=92=A4?= =?UTF-8?q?=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フェード用だった margin-left と ::after マスクを廃止。サマリ自身の白背景が 背後の台本をマスクするため、余白なしで境界がくっきり揃う。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.css | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/index.css b/src/index.css index 01a836f..d3c6fa4 100644 --- a/src/index.css +++ b/src/index.css @@ -84,7 +84,6 @@ button.character-dialogue:disabled { /* サマリのスタイル */ #scriptSummary { - margin-left: var(--page-margin); margin-block-start: calc(-1 * var(--page-margin)); padding-block-start: var(--page-margin); position: sticky; @@ -92,16 +91,6 @@ button.character-dialogue:disabled { background: white; z-index: 10; - &::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: calc(-1 * var(--page-margin)); - width: var(--page-margin); - background: white; - pointer-events: none; - } input, textarea { font: inherit; From d22824a7d7205e556f99971994d3cbd6ba7ef08f Mon Sep 17 00:00:00 2001 From: Daiki Ikeda Date: Sat, 6 Jun 2026 15:56:02 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=E7=AB=AF=E3=81=BE=E3=81=A7=E5=88=B0?= =?UTF-8?q?=E9=81=94=E3=81=97=E3=81=9F=E3=83=8A=E3=83=93=E3=83=9C=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=92=E7=84=A1=E5=8A=B9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit スクロール位置を scroll/resize/サマリ変化/ハイライト集合変化で監視し、 先頭で「最初へ・前のセリフ」、末尾で「最後へ・次のセリフ」、対象が無い方向の 送りボタンを無効化する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 6 +- src/components/ScriptNav.tsx | 111 +++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b881cb4..938f6bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -155,7 +155,11 @@ export function App() { onToggleNoVoice={toggleNoVoice} /> {dialogues.length > 0 && ( - 0} /> + )} diff --git a/src/components/ScriptNav.tsx b/src/components/ScriptNav.tsx index 44a0134..100f471 100644 --- a/src/components/ScriptNav.tsx +++ b/src/components/ScriptNav.tsx @@ -1,13 +1,24 @@ import { useEffect, useState } from "react"; import { findAdjacentDialogue } from "../lib/scriptNav"; +import type { Dialogue } from "../types"; type Props = { - hasHighlight: boolean; + // 内容は使わず、ハイライト集合が変化したら可否を再計算するための依存として受け取る + dialogues: Dialogue[]; + selected: Set; + noVoice: Set; +}; + +type NavState = { + areaCenter: number | null; + atStart: boolean; + atEnd: boolean; + canNext: boolean; + canPrev: boolean; }; // 読める領域の右端 x を返す。サマリ(キャラリスト等)は右側に sticky で -// 白マスクごと居座るため、その白マスク左端(=サマリの margin-box 左端)までを -// 読める領域とみなす。サマリが無ければビューポート幅。 +// 居座るため、その左端までを読める領域とみなす。サマリが無ければビューポート幅。 function readAreaRightEdge(): number { const summary = document.getElementById("scriptSummary"); if (!summary) return window.innerWidth; @@ -16,6 +27,18 @@ function readAreaRightEdge(): number { return rect.left - marginLeft; } +function highlightPositions() { + const els = Array.from( + document.querySelectorAll( + "#scriptContainer .character-dialogue.highlighted", + ), + ); + return els.map((el) => { + const r = el.getBoundingClientRect(); + return { id: Number(el.dataset.dialogueId), center: r.left + r.width / 2 }; + }); +} + // 指定の scrollX まで素早く(250ms)スクロールする。 // 縦書き(vertical-rl)では scrollX は負。ネイティブの smooth より速い。 function animateScrollX(targetLeft: number) { @@ -34,24 +57,57 @@ function animateScrollX(targetLeft: number) { requestAnimationFrame(step); } -export function ScriptNav({ hasHighlight }: Props) { - // ナビバー自体も読める領域の左右中央へ置く - const [areaCenter, setAreaCenter] = useState(null); +export function ScriptNav({ dialogues, selected, noVoice }: Props) { + const [state, setState] = useState({ + areaCenter: null, + atStart: true, + atEnd: false, + canNext: false, + canPrev: false, + }); + useEffect(() => { - const update = () => setAreaCenter(readAreaRightEdge() / 2); - update(); - window.addEventListener("resize", update); + const recompute = () => { + const doc = document.documentElement; + const maxScroll = doc.scrollWidth - doc.clientWidth; // overflow 量(正) + const x = -window.scrollX; // 0(先頭・右)〜 maxScroll(末尾・左) + const center = readAreaRightEdge() / 2; + const positions = highlightPositions(); + setState({ + areaCenter: center, + atStart: x <= 1, + atEnd: x >= maxScroll - 1, + canNext: findAdjacentDialogue(positions, center, "next") !== null, + canPrev: findAdjacentDialogue(positions, center, "prev") !== null, + }); + }; + + // スクロール中は rAF で間引く + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + recompute(); + }); + }; + + recompute(); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", recompute); const summary = document.getElementById("scriptSummary"); let ro: ResizeObserver | null = null; if (summary && typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(update); + ro = new ResizeObserver(recompute); ro.observe(summary); } return () => { - window.removeEventListener("resize", update); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", recompute); ro?.disconnect(); }; - }, []); + }, [dialogues, selected, noVoice]); const scrollToStart = () => animateScrollX(0); const scrollToEnd = () => @@ -59,19 +115,13 @@ export function ScriptNav({ hasHighlight }: Props) { // ハイライト中セリフのうち、読める領域の中央から見た隣のセリフを中央へ寄せる const scrollToAdjacent = (direction: "next" | "prev") => { - const els = Array.from( - document.querySelectorAll( - "#scriptContainer .character-dialogue.highlighted", - ), - ); const center = readAreaRightEdge() / 2; - const positions = els.map((el) => { - const r = el.getBoundingClientRect(); - return { id: Number(el.dataset.dialogueId), center: r.left + r.width / 2 }; - }); + const positions = highlightPositions(); const targetId = findAdjacentDialogue(positions, center, direction); if (targetId === null) return; - const target = els.find((el) => Number(el.dataset.dialogueId) === targetId); + const target = document.querySelector( + `#scriptContainer [data-dialogue-id="${targetId}"]`, + ); if (!target) return; const r = target.getBoundingClientRect(); // 対象の中心を読める領域の左右中央へ。目標 scrollX を直接指定して正確に合わせる @@ -81,15 +131,23 @@ export function ScriptNav({ hasHighlight }: Props) { return (
-