From f6a8544c5b437dd9f864b51d8f2d3d2b75d56c43 Mon Sep 17 00:00:00 2001 From: Daiki Ikeda Date: Sat, 6 Jun 2026 13:50:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E8=A4=87=E6=95=B0=20txt=20=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=83=BB=E4=B8=A6=E3=81=B3=E6=9B=BF=E3=81=88=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 複数ファイルの生テキストを配列で保持し、改行で連結して 1 本の台本として 扱う。連結・並び替え・削除は src/lib/loadedFiles.ts の純粋関数に集約。 サマリにファイル一覧(▲▼ で並び替え・✕ 削除・追加読み込み)を追加し、 印刷時は非表示にした。並び替え・削除は id がずれるためボイス不要を リセット、追加読み込みは id が不変なので維持する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 103 +++++++++++++++++++++++-------- src/components/FileList.tsx | 62 +++++++++++++++++++ src/components/ScriptSummary.tsx | 30 +++++++-- src/index.css | 66 ++++++++++++++++++++ src/lib/loadedFiles.test.ts | 67 ++++++++++++++++++++ src/lib/loadedFiles.ts | 30 +++++++++ src/types.ts | 7 +++ 7 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 src/components/FileList.tsx create mode 100644 src/lib/loadedFiles.test.ts create mode 100644 src/lib/loadedFiles.ts diff --git a/src/App.tsx b/src/App.tsx index af3a89c..1a17454 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,23 +1,48 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FloatingActions } from "./components/FloatingActions"; import { ScriptSummary } from "./components/ScriptSummary"; import { ScriptView } from "./components/ScriptView"; import { toggleCharactersByMatch } from "./lib/characterToggle"; import { isHighlighted } from "./lib/dialogue"; import { buildDialogueText, exportFileName } from "./lib/exportDialogue"; -import { parseScript, type ParseWarning } from "./lib/parseScript"; +import { + combineContents, + deriveTitle, + moveFile, + removeFile, +} from "./lib/loadedFiles"; +import { parseScript } from "./lib/parseScript"; import { toggleInSet } from "./lib/setOps"; -import type { Dialogue } from "./types"; +import type { LoadedFile } from "./types"; + +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const result = e.target?.result; + if (typeof result === "string") resolve(result); + else reject(new Error("read failed")); + }; + reader.onerror = () => reject(reader.error ?? new Error("read failed")); + reader.readAsText(file, "UTF-8"); + }); +} export function App() { const [title, setTitle] = useState(""); - const [fileLoaded, setFileLoaded] = useState(false); - const [dialogues, setDialogues] = useState([]); - const [characters, setCharacters] = useState([]); + const [files, setFiles] = useState([]); const [selected, setSelected] = useState>(new Set()); const [noVoice, setNoVoice] = useState>(new Set()); - const [warnings, setWarnings] = useState([]); const [error, setError] = useState(null); + const nextId = useRef(0); + + const { dialogues, characters, warnings } = useMemo( + () => + files.length + ? parseScript(combineContents(files)) + : { dialogues: [], characters: [], warnings: [] }, + [files], + ); useEffect(() => { if (title) document.title = title; @@ -41,28 +66,49 @@ export function App() { [dialogues, selected, noVoice], ); - const onFile = (file: File) => { - const reader = new FileReader(); - reader.onload = (e) => { - const result = e.target?.result; - if (typeof result !== "string") { - setError("ファイルの読み込みに失敗いたしました"); - return; - } - const { dialogues, characters, warnings } = parseScript(result); - setTitle(file.name); - setDialogues(dialogues); - setCharacters(characters); + const readFiles = async (fileList: FileList): Promise => + Promise.all( + Array.from(fileList).map(async (file) => ({ + id: nextId.current++, + name: file.name, + content: await readFileAsText(file), + })), + ); + + // 最初の読み込み(全置き換え)。選択・ボイス不要はリセットする + const loadFiles = async (fileList: FileList) => { + try { + const loaded = await readFiles(fileList); + setFiles(loaded); + setTitle(deriveTitle(loaded)); setSelected(new Set()); setNoVoice(new Set()); - setWarnings(warnings); - setFileLoaded(true); setError(null); - }; - reader.onerror = () => { + } catch { setError("ファイルの読み込みに失敗いたしました"); - }; - reader.readAsText(file, "UTF-8"); + } + }; + + // 追加読み込み(末尾に連結)。既存セリフの id は不変なのでボイス不要は維持 + const addFiles = async (fileList: FileList) => { + try { + const loaded = await readFiles(fileList); + setFiles((prev) => [...prev, ...loaded]); + setError(null); + } catch { + setError("ファイルの読み込みに失敗いたしました"); + } + }; + + // 並び替え・削除は id がずれるためボイス不要をリセットする + const handleMove = (id: number, dir: -1 | 1) => { + setFiles((prev) => moveFile(prev, id, dir)); + setNoVoice(new Set()); + }; + + const handleRemove = (id: number) => { + setFiles((prev) => removeFile(prev, id)); + setNoVoice(new Set()); }; const toggleCharacter = (target: string) => { @@ -88,9 +134,12 @@ export function App() { <> void; + onRemove: (id: number) => void; + onMove: (id: number, dir: -1 | 1) => void; +}; + +export function FileList({ files, onAddFiles, onRemove, onMove }: Props) { + return ( +
+
    + {files.map((file, i) => ( +
  • + {file.name} + + + + + +
  • + ))} +
+ +
+ ); +} diff --git a/src/components/ScriptSummary.tsx b/src/components/ScriptSummary.tsx index 9eebada..d7bb09f 100644 --- a/src/components/ScriptSummary.tsx +++ b/src/components/ScriptSummary.tsx @@ -1,14 +1,19 @@ import type { ParseWarning } from "../lib/parseScript"; +import type { LoadedFile } from "../types"; import { CharacterFilter } from "./CharacterFilter"; import { DialogueCount } from "./DialogueCount"; +import { FileList } from "./FileList"; import { FileTitle } from "./FileTitle"; import { ScriptWarnings } from "./ScriptWarnings"; type Props = { title: string; - fileLoaded: boolean; + files: LoadedFile[]; onTitleChange: (value: string) => void; - onFile: (file: File) => void; + onLoadFiles: (files: FileList) => void; + onAddFiles: (files: FileList) => void; + onRemoveFile: (id: number) => void; + onMoveFile: (id: number, dir: -1 | 1) => void; characters: string[]; selected: Set; onToggleCharacter: (character: string) => void; @@ -19,9 +24,12 @@ type Props = { export function ScriptSummary({ title, - fileLoaded, + files, onTitleChange, - onFile, + onLoadFiles, + onAddFiles, + onRemoveFile, + onMoveFile, characters, selected, onToggleCharacter, @@ -29,6 +37,8 @@ export function ScriptSummary({ warnings, error, }: Props) { + const fileLoaded = files.length > 0; + return (

@@ -37,9 +47,9 @@ export function ScriptSummary({ type="file" id="fileInput" accept=".txt" + multiple onChange={(e) => { - const file = e.target.files?.[0]; - if (file) onFile(file); + if (e.target.files?.length) onLoadFiles(e.target.files); }} /> )} @@ -50,6 +60,14 @@ export function ScriptSummary({ {error}

)} + {fileLoaded && ( + + )} ({ + id, + name, + content, +}); + +describe("combineContents", () => { + it("生テキストを改行で連結する", () => { + const files = [f(0, "a.txt", "田中「A」"), f(1, "b.txt", "山田「B」")]; + expect(combineContents(files)).toBe("田中「A」\n山田「B」"); + }); + + it("空配列は空文字", () => { + expect(combineContents([])).toBe(""); + }); +}); + +describe("deriveTitle", () => { + it(".txt を除いたファイル名を + で連結する", () => { + const files = [f(0, "第1幕.txt", ""), f(1, "第2幕.txt", "")]; + expect(deriveTitle(files)).toBe("第1幕 + 第2幕"); + }); + + it("拡張子がなければそのまま使う", () => { + expect(deriveTitle([f(0, "台本", "")])).toBe("台本"); + }); +}); + +describe("moveFile", () => { + const files = [f(0, "a", ""), f(1, "b", ""), f(2, "c", "")]; + + it("上に移動する", () => { + expect(moveFile(files, 1, -1).map((x) => x.id)).toEqual([1, 0, 2]); + }); + + it("下に移動する", () => { + expect(moveFile(files, 1, 1).map((x) => x.id)).toEqual([0, 2, 1]); + }); + + it("先頭をさらに上へは動かさない", () => { + expect(moveFile(files, 0, -1)).toBe(files); + }); + + it("末尾をさらに下へは動かさない", () => { + expect(moveFile(files, 2, 1)).toBe(files); + }); + + it("存在しない id は無変更", () => { + expect(moveFile(files, 99, -1)).toBe(files); + }); +}); + +describe("removeFile", () => { + it("指定 id を取り除く", () => { + const files = [f(0, "a", ""), f(1, "b", ""), f(2, "c", "")]; + expect(removeFile(files, 1).map((x) => x.id)).toEqual([0, 2]); + }); +}); diff --git a/src/lib/loadedFiles.ts b/src/lib/loadedFiles.ts new file mode 100644 index 0000000..00a55dc --- /dev/null +++ b/src/lib/loadedFiles.ts @@ -0,0 +1,30 @@ +import type { LoadedFile } from "../types"; + +/** 複数ファイルを 1 本の台本として扱うため、生テキストを改行で連結する */ +export function combineContents(files: LoadedFile[]): string { + return files.map((f) => f.content).join("\n"); +} + +/** ファイル名(.txt を除く)を連結して既定のタイトルを作る */ +export function deriveTitle(files: LoadedFile[]): string { + return files.map((f) => f.name.replace(/\.txt$/i, "")).join(" + "); +} + +/** id のファイルを dir 方向(-1 上 / +1 下)に 1 つ入れ替えた新しい配列を返す */ +export function moveFile( + files: LoadedFile[], + id: number, + dir: -1 | 1, +): LoadedFile[] { + const i = files.findIndex((f) => f.id === id); + const j = i + dir; + if (i < 0 || j < 0 || j >= files.length) return files; + const next = [...files]; + [next[i], next[j]] = [next[j], next[i]]; + return next; +} + +/** id のファイルを取り除いた新しい配列を返す */ +export function removeFile(files: LoadedFile[], id: number): LoadedFile[] { + return files.filter((f) => f.id !== id); +} diff --git a/src/types.ts b/src/types.ts index faea41b..aac346c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,10 @@ +export type LoadedFile = { + id: number; + name: string; + /** ファイルの生テキスト。複数ファイルはこれを改行で連結して 1 本の台本として扱う */ + content: string; +}; + export type Dialogue = { id: number; character: string; From ea037123cdf18627748b79fe90bd21b8f5978744 Mon Sep 17 00:00:00 2001 From: Daiki Ikeda Date: Sat, 6 Jun 2026 15:12:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E4=B8=A6=E3=81=B3=E6=9B=BF=E3=81=88=E3=82=92=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E3=83=9C=E3=82=BF=E3=83=B3=E3=81=8B=E3=82=89=20HTML5=20?= =?UTF-8?q?=E3=83=89=E3=83=A9=E3=83=83=E3=82=B0=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各ファイル行を draggable にし、上半分・下半分の判定で挿入位置のラインを 表示してドロップで並び替える。純粋関数は隣接交換の moveFile から任意位置 移動の reorderFiles に差し替え。スマホ非対応のため ▲▼ ボタンは撤去し、 削除と追加のみ残した。 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 8 +-- src/components/FileList.tsx | 85 ++++++++++++++++++++------------ src/components/ScriptSummary.tsx | 6 +-- src/index.css | 30 ++++++----- src/lib/loadedFiles.test.ts | 25 +++++----- src/lib/loadedFiles.ts | 19 ++++--- 6 files changed, 105 insertions(+), 68 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1a17454..e4bc283 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,8 +8,8 @@ import { buildDialogueText, exportFileName } from "./lib/exportDialogue"; import { combineContents, deriveTitle, - moveFile, removeFile, + reorderFiles, } from "./lib/loadedFiles"; import { parseScript } from "./lib/parseScript"; import { toggleInSet } from "./lib/setOps"; @@ -101,8 +101,8 @@ export function App() { }; // 並び替え・削除は id がずれるためボイス不要をリセットする - const handleMove = (id: number, dir: -1 | 1) => { - setFiles((prev) => moveFile(prev, id, dir)); + const handleReorder = (id: number, insertBefore: number) => { + setFiles((prev) => reorderFiles(prev, id, insertBefore)); setNoVoice(new Set()); }; @@ -139,7 +139,7 @@ export function App() { onLoadFiles={loadFiles} onAddFiles={addFiles} onRemoveFile={handleRemove} - onMoveFile={handleMove} + onReorderFile={handleReorder} characters={characters} selected={selected} onToggleCharacter={toggleCharacter} diff --git a/src/components/FileList.tsx b/src/components/FileList.tsx index 0323fcc..36df86c 100644 --- a/src/components/FileList.tsx +++ b/src/components/FileList.tsx @@ -1,47 +1,70 @@ +import { useState } from "react"; import type { LoadedFile } from "../types"; type Props = { files: LoadedFile[]; onAddFiles: (files: FileList) => void; onRemove: (id: number) => void; - onMove: (id: number, dir: -1 | 1) => void; + onReorder: (id: number, insertBefore: number) => void; }; -export function FileList({ files, onAddFiles, onRemove, onMove }: Props) { +export function FileList({ files, onAddFiles, onRemove, onReorder }: Props) { + const [draggingId, setDraggingId] = useState(null); + const [insertBefore, setInsertBefore] = useState(null); + + const clearDrag = () => { + setDraggingId(null); + setInsertBefore(null); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + if (draggingId === null) return; + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + setInsertBefore(after ? index + 1 : index); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + if (draggingId !== null && insertBefore !== null) { + onReorder(draggingId, insertBefore); + } + clearDrag(); + }; + return (
    {files.map((file, i) => ( -
  • - {file.name} - - - - +
  • { + setDraggingId(file.id); + e.dataTransfer.effectAllowed = "move"; + }} + onDragOver={(e) => handleDragOver(e, i)} + onDrop={handleDrop} + onDragEnd={clearDrag} + className={ + (draggingId === file.id ? "dragging" : "") + + (insertBefore === i ? " drop-before" : "") + + (insertBefore === i + 1 ? " drop-after" : "") + } + > + + {file.name} +
  • ))}
diff --git a/src/components/ScriptSummary.tsx b/src/components/ScriptSummary.tsx index d7bb09f..c3bcb89 100644 --- a/src/components/ScriptSummary.tsx +++ b/src/components/ScriptSummary.tsx @@ -13,7 +13,7 @@ type Props = { onLoadFiles: (files: FileList) => void; onAddFiles: (files: FileList) => void; onRemoveFile: (id: number) => void; - onMoveFile: (id: number, dir: -1 | 1) => void; + onReorderFile: (id: number, insertBefore: number) => void; characters: string[]; selected: Set; onToggleCharacter: (character: string) => void; @@ -29,7 +29,7 @@ export function ScriptSummary({ onLoadFiles, onAddFiles, onRemoveFile, - onMoveFile, + onReorderFile, characters, selected, onToggleCharacter, @@ -65,7 +65,7 @@ export function ScriptSummary({ files={files} onAddFiles={onAddFiles} onRemove={onRemoveFile} - onMove={onMoveFile} + onReorder={onReorderFile} /> )} { }); }); -describe("moveFile", () => { +describe("reorderFiles", () => { const files = [f(0, "a", ""), f(1, "b", ""), f(2, "c", "")]; - it("上に移動する", () => { - expect(moveFile(files, 1, -1).map((x) => x.id)).toEqual([1, 0, 2]); + it("先頭を中間(c の直前)へ移動する", () => { + expect(reorderFiles(files, 0, 2).map((x) => x.id)).toEqual([1, 0, 2]); }); - it("下に移動する", () => { - expect(moveFile(files, 1, 1).map((x) => x.id)).toEqual([0, 2, 1]); + it("末尾を先頭へ移動する", () => { + expect(reorderFiles(files, 2, 0).map((x) => x.id)).toEqual([2, 0, 1]); }); - it("先頭をさらに上へは動かさない", () => { - expect(moveFile(files, 0, -1)).toBe(files); + it("先頭を末尾(length)へ移動する", () => { + expect(reorderFiles(files, 0, 3).map((x) => x.id)).toEqual([1, 2, 0]); }); - it("末尾をさらに下へは動かさない", () => { - expect(moveFile(files, 2, 1)).toBe(files); + it("自分の直前・直後へのドロップは無変更", () => { + expect(reorderFiles(files, 1, 1).map((x) => x.id)).toEqual([0, 1, 2]); + expect(reorderFiles(files, 1, 2).map((x) => x.id)).toEqual([0, 1, 2]); }); - it("存在しない id は無変更", () => { - expect(moveFile(files, 99, -1)).toBe(files); + it("存在しない id は同一参照を返す", () => { + expect(reorderFiles(files, 99, 0)).toBe(files); }); }); diff --git a/src/lib/loadedFiles.ts b/src/lib/loadedFiles.ts index 00a55dc..c96cbdd 100644 --- a/src/lib/loadedFiles.ts +++ b/src/lib/loadedFiles.ts @@ -10,17 +10,22 @@ export function deriveTitle(files: LoadedFile[]): string { return files.map((f) => f.name.replace(/\.txt$/i, "")).join(" + "); } -/** id のファイルを dir 方向(-1 上 / +1 下)に 1 つ入れ替えた新しい配列を返す */ -export function moveFile( +/** + * id のファイルを「元の配列の insertBefore 番目の直前」へ移動した新しい配列を返す。 + * insertBefore は 0〜files.length(末尾に置く場合は files.length)。 + */ +export function reorderFiles( files: LoadedFile[], id: number, - dir: -1 | 1, + insertBefore: number, ): LoadedFile[] { - const i = files.findIndex((f) => f.id === id); - const j = i + dir; - if (i < 0 || j < 0 || j >= files.length) return files; + const from = files.findIndex((f) => f.id === id); + if (from < 0) return files; const next = [...files]; - [next[i], next[j]] = [next[j], next[i]]; + const [moved] = next.splice(from, 1); + // 自分より前の要素を抜いた分、挿入位置が 1 つ手前にずれる + const dest = from < insertBefore ? insertBefore - 1 : insertBefore; + next.splice(dest, 0, moved); return next; }