Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -153,6 +154,13 @@ export function App() {
noVoice={noVoice}
onToggleNoVoice={toggleNoVoice}
/>
{dialogues.length > 0 && (
<ScriptNav
dialogues={dialogues}
selected={selected}
noVoice={noVoice}
/>
)}
<FloatingActions onExportText={exportText} />
</>
);
Expand Down
24 changes: 0 additions & 24 deletions src/components/FloatingActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div id="floatingActions">
<button
type="button"
onClick={scrollToStart}
title="先頭に戻る"
aria-label="先頭に戻る"
>
</button>
<button
type="button"
onClick={scrollToEnd}
title="末尾に進む"
aria-label="末尾に進む"
>
</button>
<button
type="button"
onClick={onExportText}
Expand Down
176 changes: 176 additions & 0 deletions src/components/ScriptNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { findAdjacentDialogue } from "../lib/scriptNav";
import type { Dialogue } from "../types";

type Props = {
// 内容は使わず、ハイライト集合が変化したら可否を再計算するための依存として受け取る
dialogues: Dialogue[];
selected: Set<string>;
noVoice: Set<number>;
};

type NavState = {
areaCenter: number | null;
atStart: boolean;
atEnd: boolean;
canNext: boolean;
canPrev: boolean;
};

// 読める領域の右端 x を返す。サマリ(キャラリスト等)は右側に sticky で
// 居座るため、その左端までを読める領域とみなす。サマリが無ければビューポート幅。
function readAreaRightEdge(): number {
const summary = document.getElementById("scriptSummary");
if (!summary) return window.innerWidth;
const rect = summary.getBoundingClientRect();
const marginLeft = parseFloat(getComputedStyle(summary).marginLeft) || 0;
return rect.left - marginLeft;
}

function highlightPositions() {
const els = Array.from(
document.querySelectorAll<HTMLElement>(
"#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) {
const startLeft = window.scrollX;
const dist = targetLeft - startLeft;
if (dist === 0) return;
const duration = 250;
let startTime: number | null = null;
const step = (now: number) => {
if (startTime === null) startTime = now;
const t = Math.min(1, (now - startTime) / duration);
const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic
window.scrollTo({ left: startLeft + dist * ease, top: 0 });
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}

export function ScriptNav({ dialogues, selected, noVoice }: Props) {
const [state, setState] = useState<NavState>({
areaCenter: null,
atStart: true,
atEnd: false,
canNext: false,
canPrev: false,
});

useEffect(() => {
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(recompute);
ro.observe(summary);
}
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", recompute);
ro?.disconnect();
};
}, [dialogues, selected, noVoice]);

const scrollToStart = () => animateScrollX(0);
const scrollToEnd = () =>
animateScrollX(-document.documentElement.scrollWidth);

// ハイライト中セリフのうち、読める領域の中央から見た隣のセリフを中央へ寄せる
const scrollToAdjacent = (direction: "next" | "prev") => {
const center = readAreaRightEdge() / 2;
const positions = highlightPositions();
const targetId = findAdjacentDialogue(positions, center, direction);
if (targetId === null) return;
const target = document.querySelector<HTMLElement>(
`#scriptContainer [data-dialogue-id="${targetId}"]`,
);
if (!target) return;
const r = target.getBoundingClientRect();
// 対象の中心を読める領域の左右中央へ。目標 scrollX を直接指定して正確に合わせる
animateScrollX(window.scrollX + (r.left + r.width / 2 - center));
};

return (
<div
id="scriptNav"
style={{
left: state.areaCenter != null ? `${state.areaCenter}px` : undefined,
}}
>
<button
type="button"
onClick={scrollToEnd}
disabled={state.atEnd}
title="最後へ"
aria-label="最後へ"
>
«
</button>
<button
type="button"
onClick={() => scrollToAdjacent("next")}
disabled={!state.canNext}
title="次のセリフへ"
aria-label="次のセリフへ"
>
</button>
<button
type="button"
onClick={() => scrollToAdjacent("prev")}
disabled={!state.canPrev}
title="前のセリフへ"
aria-label="前のセリフへ"
>
</button>
<button
type="button"
onClick={scrollToStart}
disabled={state.atStart}
title="最初へ"
aria-label="最初へ"
>
»
</button>
</div>
);
}
1 change: 1 addition & 0 deletions src/components/ScriptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function ScriptView({
<button
key={d.id}
type="button"
data-dialogue-id={d.id}
className={
"character-dialogue" + (highlighted ? " highlighted" : "")
}
Expand Down
47 changes: 36 additions & 11 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,13 @@ 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;
inset-block-start: 0;
background: white;
z-index: 10;

&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: calc(-1 * var(--page-margin));
width: var(--page-margin);
background: linear-gradient(to right, transparent, white);
pointer-events: none;
}
input,
textarea {
font: inherit;
Expand Down Expand Up @@ -291,6 +280,39 @@ button.character-dialogue:disabled {
color: white;
}

/* 台本下部中央のセリフ送りナビ */
#scriptNav {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
writing-mode: horizontal-tb;
}
#scriptNav button {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--highlight-color);
background: white;
color: var(--highlight-color);
cursor: pointer;
font-family: var(--font-sans);
font-size: 18px;
line-height: 1;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
#scriptNav button:hover:not(:disabled) {
background: var(--highlight-color);
color: white;
}
#scriptNav button:disabled {
opacity: 0.4;
cursor: default;
}

/* 印刷時のスタイル */
@media print {
@page {
Expand All @@ -312,4 +334,7 @@ button.character-dialogue:disabled {
#floatingActions {
display: none;
}
#scriptNav {
display: none;
}
}
39 changes: 39 additions & 0 deletions src/lib/scriptNav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { findAdjacentDialogue } from "./scriptNav";

// start 寄り(右・中心大)から end 寄り(左・中心小)の順
const positions = [
{ id: 1, center: 900 },
{ id: 2, center: 600 },
{ id: 3, center: 300 },
];

describe("findAdjacentDialogue", () => {
it("next は基準より左で最も近いセリフを選ぶ", () => {
expect(findAdjacentDialogue(positions, 650, "next")).toBe(2);
});

it("prev は基準より右で最も近いセリフを選ぶ", () => {
expect(findAdjacentDialogue(positions, 650, "prev")).toBe(1);
});

it("中央のセリフからさらに next で次へ進む", () => {
expect(findAdjacentDialogue(positions, 600, "next")).toBe(3);
});

it("中央のセリフから prev で前へ戻る", () => {
expect(findAdjacentDialogue(positions, 600, "prev")).toBe(1);
});

it("next 方向に対象が無ければ null", () => {
expect(findAdjacentDialogue(positions, 200, "next")).toBe(null);
});

it("prev 方向に対象が無ければ null", () => {
expect(findAdjacentDialogue(positions, 1000, "prev")).toBe(null);
});

it("空配列は null", () => {
expect(findAdjacentDialogue([], 500, "next")).toBe(null);
});
});
29 changes: 29 additions & 0 deletions src/lib/scriptNav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type DialoguePosition = { id: number; center: number };

/**
* 縦書き(vertical-rl)の台本で、基準位置から見た隣のセリフ id を返す。
* start は右(中心 x が大きい)、end は左(中心 x が小さい)にあるため
* - "next"(次のセリフ=end 方向)は基準より左で最も近いもの
* - "prev"(前のセリフ=start 方向)は基準より右で最も近いもの
* を選ぶ。該当が無ければ null。
*/
export function findAdjacentDialogue(
positions: DialoguePosition[],
reference: number,
direction: "next" | "prev",
): number | null {
const eps = 1; // 中央付近に居座るセリフを自分自身として拾わないための遊び
let best: DialoguePosition | null = null;
for (const p of positions) {
if (direction === "next") {
if (p.center < reference - eps && (!best || p.center > best.center)) {
best = p;
}
} else {
if (p.center > reference + eps && (!best || p.center < best.center)) {
best = p;
}
}
}
return best ? best.id : null;
}