Add ProjectSequencesPanel component
This commit is contained in:
501
src/app/ProjectSequencesPanel.tsx
Normal file
501
src/app/ProjectSequencesPanel.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
|
||||
import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface";
|
||||
import { type ProjectDialogueLibrary, getProjectDialogues } from "../dialogues/project-dialogues";
|
||||
import {
|
||||
getProjectScheduleEffectOptionId,
|
||||
getProjectScheduleTargetOptionByKey,
|
||||
listProjectScheduleEffectOptions,
|
||||
type ProjectScheduleEffectOptionId,
|
||||
type ProjectScheduleTargetOption
|
||||
} from "../scheduler/project-schedule-control-options";
|
||||
import {
|
||||
getSequenceStepLabel,
|
||||
type SequenceStep
|
||||
} from "../sequencer/project-sequence-steps";
|
||||
import {
|
||||
getProjectSequences,
|
||||
type ProjectSequenceLibrary
|
||||
} from "../sequencer/project-sequences";
|
||||
|
||||
interface ProjectSequencesPanelProps {
|
||||
sequences: ProjectSequenceLibrary;
|
||||
dialogues: ProjectDialogueLibrary;
|
||||
targetOptions: ProjectScheduleTargetOption[];
|
||||
selectedSequenceId: string | null;
|
||||
onSelectSequence(sequenceId: string | null): void;
|
||||
onAddSequence(): void;
|
||||
onDeleteSequence(sequenceId: string): void;
|
||||
onSetSequenceTitle(sequenceId: string, title: string): void;
|
||||
onAddHeldControlStep(sequenceId: string, targetKey: string): void;
|
||||
onAddImpulseControlStep(sequenceId: string, targetKey: string): void;
|
||||
onAddDialogueStep(sequenceId: string, dialogueId: string): void;
|
||||
onDeleteStep(sequenceId: string, stepIndex: number): void;
|
||||
onSetControlStepTarget(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
targetKey: string
|
||||
): void;
|
||||
onSetControlStepEffectOption(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
effectOptionId: ProjectScheduleEffectOptionId
|
||||
): void;
|
||||
onSetControlStepNumericValue(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
value: number
|
||||
): void;
|
||||
onSetControlStepColorValue(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
colorHex: string
|
||||
): void;
|
||||
onSetControlStepAnimationClip(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
clipName: string
|
||||
): void;
|
||||
onSetControlStepAnimationLoop(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
loop: boolean
|
||||
): void;
|
||||
onSetDialogueStepDialogueId(
|
||||
sequenceId: string,
|
||||
stepIndex: number,
|
||||
dialogueId: string
|
||||
): void;
|
||||
}
|
||||
|
||||
function commitOnEnter(
|
||||
event: ReactKeyboardEvent<HTMLInputElement>,
|
||||
commit: () => void
|
||||
) {
|
||||
if (event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.blur();
|
||||
commit();
|
||||
}
|
||||
|
||||
function getControlStepNumericValue(step: Extract<SequenceStep, { type: "controlEffect" }>): number | null {
|
||||
switch (step.effect.type) {
|
||||
case "setSoundVolume":
|
||||
return step.effect.volume;
|
||||
case "setLightIntensity":
|
||||
case "setAmbientLightIntensity":
|
||||
case "setSunLightIntensity":
|
||||
return step.effect.intensity;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getControlStepColorValue(step: Extract<SequenceStep, { type: "controlEffect" }>): string | null {
|
||||
switch (step.effect.type) {
|
||||
case "setLightColor":
|
||||
case "setAmbientLightColor":
|
||||
case "setSunLightColor":
|
||||
return step.effect.colorHex;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectSequencesPanel({
|
||||
sequences,
|
||||
dialogues,
|
||||
targetOptions,
|
||||
selectedSequenceId,
|
||||
onSelectSequence,
|
||||
onAddSequence,
|
||||
onDeleteSequence,
|
||||
onSetSequenceTitle,
|
||||
onAddHeldControlStep,
|
||||
onAddImpulseControlStep,
|
||||
onAddDialogueStep,
|
||||
onDeleteStep,
|
||||
onSetControlStepTarget,
|
||||
onSetControlStepEffectOption,
|
||||
onSetControlStepNumericValue,
|
||||
onSetControlStepColorValue,
|
||||
onSetControlStepAnimationClip,
|
||||
onSetControlStepAnimationLoop,
|
||||
onSetDialogueStepDialogueId
|
||||
}: ProjectSequencesPanelProps) {
|
||||
const sequenceList = getProjectSequences(sequences);
|
||||
const dialogueList = getProjectDialogues(dialogues);
|
||||
const selectedSequence =
|
||||
selectedSequenceId === null
|
||||
? null
|
||||
: sequences.sequences[selectedSequenceId] ?? null;
|
||||
|
||||
return (
|
||||
<div className="form-section">
|
||||
<div className="label">Sequences</div>
|
||||
{sequenceList.length === 0 ? (
|
||||
<div className="outliner-empty">No project sequences authored yet.</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{sequenceList.map((sequence) => (
|
||||
<div
|
||||
key={sequence.id}
|
||||
className={`outliner-item outliner-item--compact ${
|
||||
selectedSequence?.id === sequence.id
|
||||
? "outliner-item--selected"
|
||||
: ""
|
||||
}`.trim()}
|
||||
>
|
||||
<div className="outliner-item__row">
|
||||
<button
|
||||
className="outliner-item__select"
|
||||
type="button"
|
||||
onClick={() => onSelectSequence(sequence.id)}
|
||||
>
|
||||
<span className="outliner-item__title">{sequence.title}</span>
|
||||
<span className="outliner-item__meta">
|
||||
{sequence.steps.length} step
|
||||
{sequence.steps.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="outliner-item__delete"
|
||||
type="button"
|
||||
aria-label={`Delete ${sequence.title}`}
|
||||
onClick={() => onDeleteSequence(sequence.id)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-actions">
|
||||
<button className="toolbar__button" type="button" onClick={onAddSequence}>
|
||||
Add Sequence
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedSequence === null ? (
|
||||
<div className="outliner-empty">
|
||||
Select a sequence to edit its title and steps.
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-section">
|
||||
<label className="form-field">
|
||||
<span className="label">Title</span>
|
||||
<input
|
||||
className="text-input"
|
||||
type="text"
|
||||
defaultValue={selectedSequence.title}
|
||||
onBlur={(event) =>
|
||||
onSetSequenceTitle(selectedSequence.id, event.currentTarget.value)
|
||||
}
|
||||
onKeyDown={(event) =>
|
||||
commitOnEnter(event, () =>
|
||||
onSetSequenceTitle(selectedSequence.id, event.currentTarget.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="label">Steps</div>
|
||||
{selectedSequence.steps.length === 0 ? (
|
||||
<div className="outliner-empty">
|
||||
Add held control, impulse control, or dialogue steps.
|
||||
</div>
|
||||
) : (
|
||||
<div className="outliner-list">
|
||||
{selectedSequence.steps.map((step, stepIndex) => {
|
||||
if (step.type === "controlEffect") {
|
||||
const targetKey = getControlTargetRefKey(step.effect.target);
|
||||
const targetOption =
|
||||
getProjectScheduleTargetOptionByKey(targetOptions, targetKey);
|
||||
const effectOptions =
|
||||
targetOption === null
|
||||
? []
|
||||
: listProjectScheduleEffectOptions(targetOption);
|
||||
const effectOptionId =
|
||||
targetOption === null
|
||||
? null
|
||||
: (() => {
|
||||
try {
|
||||
return getProjectScheduleEffectOptionId(step.effect);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const selectedEffectOption =
|
||||
effectOptionId === null
|
||||
? null
|
||||
: effectOptions.find((option) => option.id === effectOptionId) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div key={`${selectedSequence.id}-${stepIndex}`} className="outliner-item">
|
||||
<div className="outliner-item__row">
|
||||
<div className="outliner-item__meta">
|
||||
{getSequenceStepLabel(step)}
|
||||
</div>
|
||||
<button
|
||||
className="outliner-item__delete"
|
||||
type="button"
|
||||
onClick={() => onDeleteStep(selectedSequence.id, stepIndex)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{targetOption === null || effectOptionId === null ? (
|
||||
<div className="material-summary">
|
||||
{formatControlEffectValue(step.effect)}. This control step
|
||||
is preserved, but the current sequence editor can only edit
|
||||
control targets/effects that are exposed through the
|
||||
existing sequencer target catalog.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="vector-inputs vector-inputs--two">
|
||||
<label className="form-field">
|
||||
<span className="label">Class</span>
|
||||
<input
|
||||
className="text-input"
|
||||
type="text"
|
||||
value={step.stepClass === "held" ? "Held" : "Impulse"}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
<span className="label">Target</span>
|
||||
<select
|
||||
className="select-input"
|
||||
value={targetOption.key}
|
||||
onChange={(event) =>
|
||||
onSetControlStepTarget(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{targetOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.groupLabel} · {option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
<span className="label">Effect</span>
|
||||
<select
|
||||
className="select-input"
|
||||
value={effectOptionId}
|
||||
onChange={(event) =>
|
||||
onSetControlStepEffectOption(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget
|
||||
.value as ProjectScheduleEffectOptionId
|
||||
)
|
||||
}
|
||||
>
|
||||
{effectOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedEffectOption?.valueKind === "number" ? (
|
||||
<label className="form-field">
|
||||
<span className="label">
|
||||
{selectedEffectOption.valueLabel ?? "Value"}
|
||||
</span>
|
||||
<input
|
||||
className="text-input"
|
||||
type="number"
|
||||
min={selectedEffectOption.min ?? 0}
|
||||
step={selectedEffectOption.step ?? 0.1}
|
||||
defaultValue={getControlStepNumericValue(step) ?? 0}
|
||||
onBlur={(event) =>
|
||||
onSetControlStepNumericValue(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
Number(event.currentTarget.value)
|
||||
)
|
||||
}
|
||||
onKeyDown={(event) =>
|
||||
commitOnEnter(event, () =>
|
||||
onSetControlStepNumericValue(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
Number(event.currentTarget.value)
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{selectedEffectOption?.valueKind === "color" ? (
|
||||
<label className="form-field">
|
||||
<span className="label">
|
||||
{selectedEffectOption.valueLabel ?? "Color"}
|
||||
</span>
|
||||
<input
|
||||
className="color-input"
|
||||
type="color"
|
||||
value={getControlStepColorValue(step) ?? "#ffffff"}
|
||||
onChange={(event) =>
|
||||
onSetControlStepColorValue(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{selectedEffectOption?.valueKind === "animation" ? (
|
||||
<>
|
||||
<label className="form-field">
|
||||
<span className="label">Clip</span>
|
||||
<select
|
||||
className="select-input"
|
||||
value={
|
||||
step.effect.type === "playModelAnimation"
|
||||
? step.effect.clipName
|
||||
: targetOption.defaults.animationClipNames?.[0] ??
|
||||
""
|
||||
}
|
||||
onChange={(event) =>
|
||||
onSetControlStepAnimationClip(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{(targetOption.defaults.animationClipNames ?? []).map(
|
||||
(clipName) => (
|
||||
<option key={clipName} value={clipName}>
|
||||
{clipName}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field form-field--inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
step.effect.type === "playModelAnimation"
|
||||
? step.effect.loop !== false
|
||||
: true
|
||||
}
|
||||
onChange={(event) =>
|
||||
onSetControlStepAnimationLoop(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="label">Loop</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${selectedSequence.id}-${stepIndex}`} className="outliner-item">
|
||||
<div className="outliner-item__row">
|
||||
<div className="outliner-item__meta">
|
||||
{getSequenceStepLabel(step)}
|
||||
</div>
|
||||
<button
|
||||
className="outliner-item__delete"
|
||||
type="button"
|
||||
onClick={() => onDeleteStep(selectedSequence.id, stepIndex)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
<span className="label">Dialogue</span>
|
||||
<select
|
||||
className="select-input"
|
||||
value={step.dialogueId}
|
||||
onChange={(event) =>
|
||||
onSetDialogueStepDialogueId(
|
||||
selectedSequence.id,
|
||||
stepIndex,
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{dialogueList.map((dialogue) => (
|
||||
<option key={dialogue.id} value={dialogue.id}>
|
||||
{dialogue.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="toolbar__button"
|
||||
type="button"
|
||||
disabled={targetOptions.length === 0}
|
||||
onClick={() =>
|
||||
onAddHeldControlStep(selectedSequence.id, targetOptions[0]?.key ?? "")
|
||||
}
|
||||
>
|
||||
Add Held Control Step
|
||||
</button>
|
||||
<button
|
||||
className="toolbar__button"
|
||||
type="button"
|
||||
disabled={targetOptions.length === 0}
|
||||
onClick={() =>
|
||||
onAddImpulseControlStep(
|
||||
selectedSequence.id,
|
||||
targetOptions[0]?.key ?? ""
|
||||
)
|
||||
}
|
||||
>
|
||||
Add Impulse Control Step
|
||||
</button>
|
||||
<button
|
||||
className="toolbar__button"
|
||||
type="button"
|
||||
disabled={dialogueList.length === 0}
|
||||
onClick={() =>
|
||||
onAddDialogueStep(selectedSequence.id, dialogueList[0]?.id ?? "")
|
||||
}
|
||||
>
|
||||
Add Dialogue Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user