diff --git a/src/App.tsx b/src/App.tsx index af3a89c..e4bc283 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, + removeFile, + reorderFiles, +} 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 handleReorder = (id: number, insertBefore: number) => { + setFiles((prev) => reorderFiles(prev, id, insertBefore)); + 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; + onReorder: (id: number, insertBefore: number) => void; +}; + +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) => ( +
  • { + 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 9eebada..c3bcb89 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; + onReorderFile: (id: number, insertBefore: number) => 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, + onReorderFile, 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("reorderFiles", () => { + const files = [f(0, "a", ""), f(1, "b", ""), f(2, "c", "")]; + + it("先頭を中間(c の直前)へ移動する", () => { + expect(reorderFiles(files, 0, 2).map((x) => x.id)).toEqual([1, 0, 2]); + }); + + it("末尾を先頭へ移動する", () => { + expect(reorderFiles(files, 2, 0).map((x) => x.id)).toEqual([2, 0, 1]); + }); + + it("先頭を末尾(length)へ移動する", () => { + expect(reorderFiles(files, 0, 3).map((x) => x.id)).toEqual([1, 2, 0]); + }); + + 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(reorderFiles(files, 99, 0)).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..c96cbdd --- /dev/null +++ b/src/lib/loadedFiles.ts @@ -0,0 +1,35 @@ +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 のファイルを「元の配列の insertBefore 番目の直前」へ移動した新しい配列を返す。 + * insertBefore は 0〜files.length(末尾に置く場合は files.length)。 + */ +export function reorderFiles( + files: LoadedFile[], + id: number, + insertBefore: number, +): LoadedFile[] { + const from = files.findIndex((f) => f.id === id); + if (from < 0) return files; + const next = [...files]; + const [moved] = next.splice(from, 1); + // 自分より前の要素を抜いた分、挿入位置が 1 つ手前にずれる + const dest = from < insertBefore ? insertBefore - 1 : insertBefore; + next.splice(dest, 0, moved); + 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;