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
103 changes: 76 additions & 27 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Dialogue[]>([]);
const [characters, setCharacters] = useState<string[]>([]);
const [files, setFiles] = useState<LoadedFile[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [noVoice, setNoVoice] = useState<Set<number>>(new Set());
const [warnings, setWarnings] = useState<ParseWarning[]>([]);
const [error, setError] = useState<string | null>(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;
Expand All @@ -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<LoadedFile[]> =>
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) => {
Expand All @@ -88,9 +134,12 @@ export function App() {
<>
<ScriptSummary
title={title}
fileLoaded={fileLoaded}
files={files}
onTitleChange={setTitle}
onFile={onFile}
onLoadFiles={loadFiles}
onAddFiles={addFiles}
onRemoveFile={handleRemove}
onReorderFile={handleReorder}
characters={characters}
selected={selected}
onToggleCharacter={toggleCharacter}
Expand Down
85 changes: 85 additions & 0 deletions src/components/FileList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from "react";
import type { LoadedFile } from "../types";

type Props = {
files: LoadedFile[];
onAddFiles: (files: FileList) => void;
onRemove: (id: number) => void;
onReorder: (id: number, insertBefore: number) => void;
};

export function FileList({ files, onAddFiles, onRemove, onReorder }: Props) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const [insertBefore, setInsertBefore] = useState<number | null>(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 (
<div id="fileList">
<ul>
{files.map((file, i) => (
<li
key={file.id}
draggable
onDragStart={(e) => {
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" : "")
}
>
<span className="file-handle" aria-hidden="true">
</span>
<span className="file-name">{file.name}</span>
<button
type="button"
onClick={() => onRemove(file.id)}
title="削除"
aria-label={`${file.name} を削除`}
>
</button>
</li>
))}
</ul>
<label className="file-add">
+ ファイルを追加
<input
type="file"
accept=".txt"
multiple
onChange={(e) => {
if (e.target.files?.length) onAddFiles(e.target.files);
e.target.value = "";
}}
/>
</label>
</div>
);
}
30 changes: 24 additions & 6 deletions src/components/ScriptSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
onToggleCharacter: (character: string) => void;
Expand All @@ -19,16 +24,21 @@ type Props = {

export function ScriptSummary({
title,
fileLoaded,
files,
onTitleChange,
onFile,
onLoadFiles,
onAddFiles,
onRemoveFile,
onReorderFile,
characters,
selected,
onToggleCharacter,
dialogueCount,
warnings,
error,
}: Props) {
const fileLoaded = files.length > 0;

return (
<div id="scriptSummary">
<h1>
Expand All @@ -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);
}}
/>
)}
Expand All @@ -50,6 +60,14 @@ export function ScriptSummary({
{error}
</p>
)}
{fileLoaded && (
<FileList
files={files}
onAddFiles={onAddFiles}
onRemove={onRemoveFile}
onReorder={onReorderFile}
/>
)}
<CharacterFilter
characters={characters}
selected={selected}
Expand Down
74 changes: 74 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,77 @@ button.character-dialogue:disabled {
color: #333;
}
}
#fileList {
writing-mode: horizontal-tb;
font-family: var(--font-sans);
font-size: 10pt;
color: var(--highlight-color);
margin-top: 8px;
max-width: 280px;

ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
cursor: grab;
}
li.dragging {
opacity: 0.4;
}
li.drop-before {
box-shadow: 0 -2px 0 0 var(--highlight-color);
}
li.drop-after {
box-shadow: 0 2px 0 0 var(--highlight-color);
}
.file-handle {
flex-shrink: 0;
color: var(--highlight-color);
opacity: 0.6;
line-height: 1;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
li button {
flex-shrink: 0;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid var(--highlight-color);
border-radius: 4px;
background: white;
color: var(--highlight-color);
font-size: 10px;
line-height: 1;
cursor: pointer;
}
li button:hover {
background: var(--highlight-color);
color: white;
}
.file-add {
display: inline-block;
margin-top: 6px;
padding: 2px 8px;
border: 1px dashed var(--highlight-color);
border-radius: 4px;
color: var(--highlight-color);
cursor: pointer;
}
.file-add input {
display: none;
}
}
label {
padding: var(--filter-label-padding);
margin-block: var(--filter-label-margin);
Expand Down Expand Up @@ -235,6 +306,9 @@ button.character-dialogue:disabled {
#scriptWarnings {
display: none;
}
#fileList {
display: none;
}
#floatingActions {
display: none;
}
Expand Down
Loading