2026-04-14 03:04:05 +02:00
|
|
|
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
HOURS_PER_DAY,
|
|
|
|
|
formatTimeOfDayHours
|
|
|
|
|
} from "../document/project-time-settings";
|
|
|
|
|
import { formatControlEffectValue, getControlTargetRefKey } from "../controls/control-surface";
|
|
|
|
|
import {
|
2026-04-14 13:48:11 +02:00
|
|
|
findProjectScheduleRoutineEffect,
|
2026-04-14 03:04:05 +02:00
|
|
|
PROJECT_SCHEDULE_WEEKDAYS,
|
|
|
|
|
formatProjectScheduleDaySelection,
|
|
|
|
|
formatProjectScheduleWeekdayLabel,
|
|
|
|
|
getProjectScheduleTimelineSegments,
|
|
|
|
|
type ProjectScheduler,
|
|
|
|
|
type ProjectScheduleRoutine,
|
|
|
|
|
type ProjectScheduleWeekday
|
|
|
|
|
} from "../scheduler/project-scheduler";
|
|
|
|
|
import {
|
|
|
|
|
getProjectScheduleEffectOptionId,
|
|
|
|
|
getProjectScheduleTargetOptionForRoutine,
|
|
|
|
|
listProjectScheduleEffectOptions,
|
|
|
|
|
type ProjectScheduleEffectOptionId,
|
|
|
|
|
type ProjectScheduleTargetOption
|
|
|
|
|
} from "../scheduler/project-schedule-control-options";
|
|
|
|
|
|
|
|
|
|
interface ProjectSchedulePaneProps {
|
|
|
|
|
targetOptions: ProjectScheduleTargetOption[];
|
|
|
|
|
scheduler: ProjectScheduler;
|
|
|
|
|
selectedRoutineId: string | null;
|
|
|
|
|
onSelectRoutine(routineId: string | null): void;
|
|
|
|
|
onAddRoutine(targetKey: string): void;
|
|
|
|
|
onDeleteRoutine(routineId: string): void;
|
|
|
|
|
onClose(): void;
|
|
|
|
|
onSetRoutineTarget(routineId: string, targetKey: string): void;
|
|
|
|
|
onSetRoutineTitle(routineId: string, title: string): void;
|
|
|
|
|
onSetRoutineEnabled(routineId: string, enabled: boolean): void;
|
|
|
|
|
onSetRoutineStartHour(routineId: string, startHour: number): void;
|
|
|
|
|
onSetRoutineEndHour(routineId: string, endHour: number): void;
|
|
|
|
|
onSetRoutinePriority(routineId: string, priority: number): void;
|
|
|
|
|
onSetRoutineEffectOption(
|
|
|
|
|
routineId: string,
|
|
|
|
|
effectOptionId: ProjectScheduleEffectOptionId
|
|
|
|
|
): void;
|
|
|
|
|
onSetRoutineDays(
|
|
|
|
|
routineId: string,
|
|
|
|
|
mode: "everyDay" | "selectedDays",
|
|
|
|
|
days: ProjectScheduleWeekday[]
|
|
|
|
|
): void;
|
|
|
|
|
onSetRoutineNumericValue(routineId: string, value: number): void;
|
|
|
|
|
onSetRoutineColorValue(routineId: string, colorHex: string): void;
|
|
|
|
|
onSetRoutineAnimationClip(routineId: string, clipName: string): void;
|
|
|
|
|
onSetRoutineAnimationLoop(routineId: string, loop: boolean): void;
|
2026-04-14 13:48:11 +02:00
|
|
|
onSetActorRoutinePresence(routineId: string, active: boolean): void;
|
|
|
|
|
onSetActorRoutineAnimationClip(
|
|
|
|
|
routineId: string,
|
|
|
|
|
clipName: string | null
|
|
|
|
|
): void;
|
|
|
|
|
onSetActorRoutineAnimationLoop(routineId: string, loop: boolean): void;
|
|
|
|
|
onSetActorRoutinePath(routineId: string, pathId: string | null): void;
|
|
|
|
|
onSetActorRoutinePathSpeed(routineId: string, speed: number): void;
|
|
|
|
|
onSetActorRoutinePathLoop(routineId: string, loop: boolean): void;
|
2026-04-14 03:04:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleCommitOnEnter(
|
|
|
|
|
event: ReactKeyboardEvent<HTMLInputElement>,
|
|
|
|
|
commit: () => void
|
|
|
|
|
) {
|
|
|
|
|
if (event.key !== "Enter") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.currentTarget.blur();
|
|
|
|
|
commit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRoutineSummary(routine: ProjectScheduleRoutine): string {
|
2026-04-14 13:48:11 +02:00
|
|
|
const summaryParts = [
|
|
|
|
|
formatProjectScheduleDaySelection(routine.days),
|
|
|
|
|
`${formatTimeOfDayHours(routine.startHour)}-${formatTimeOfDayHours(routine.endHour)}`
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (routine.target.kind === "actor") {
|
|
|
|
|
const presenceEffect = findProjectScheduleRoutineEffect(
|
|
|
|
|
routine,
|
|
|
|
|
"setActorPresence"
|
|
|
|
|
);
|
|
|
|
|
const animationEffect = findProjectScheduleRoutineEffect(
|
|
|
|
|
routine,
|
|
|
|
|
"playActorAnimation"
|
|
|
|
|
);
|
|
|
|
|
const pathEffect = findProjectScheduleRoutineEffect(routine, "followActorPath");
|
|
|
|
|
|
|
|
|
|
summaryParts.push(presenceEffect?.active === false ? "Hidden" : "Present");
|
|
|
|
|
|
|
|
|
|
if (animationEffect !== null) {
|
|
|
|
|
summaryParts.push(formatControlEffectValue(animationEffect));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pathEffect !== null) {
|
|
|
|
|
summaryParts.push(formatControlEffectValue(pathEffect));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
summaryParts.push(formatControlEffectValue(routine.effects[0]!));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summaryParts.push(`P${routine.priority}`);
|
|
|
|
|
return summaryParts.join(" · ");
|
2026-04-14 03:04:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isRoutineEffectInactive(routine: ProjectScheduleRoutine): boolean {
|
2026-04-14 13:48:11 +02:00
|
|
|
if (routine.target.kind === "actor") {
|
|
|
|
|
return (
|
|
|
|
|
findProjectScheduleRoutineEffect(routine, "setActorPresence")?.active ===
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const effect = routine.effects[0];
|
|
|
|
|
|
|
|
|
|
if (effect === undefined) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (effect.type) {
|
2026-04-14 03:04:05 +02:00
|
|
|
case "stopModelAnimation":
|
|
|
|
|
case "stopSound":
|
|
|
|
|
return true;
|
|
|
|
|
case "setModelInstanceVisible":
|
2026-04-14 13:48:11 +02:00
|
|
|
return !effect.visible;
|
2026-04-14 03:04:05 +02:00
|
|
|
case "setInteractionEnabled":
|
|
|
|
|
case "setLightEnabled":
|
2026-04-14 13:48:11 +02:00
|
|
|
return !effect.enabled;
|
2026-04-14 03:04:05 +02:00
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRoutineNumericValue(routine: ProjectScheduleRoutine): number | null {
|
2026-04-14 13:48:11 +02:00
|
|
|
const effect = routine.effects[0];
|
|
|
|
|
|
|
|
|
|
if (effect === undefined) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (effect.type) {
|
2026-04-14 03:04:05 +02:00
|
|
|
case "setSoundVolume":
|
2026-04-14 13:48:11 +02:00
|
|
|
return effect.volume;
|
2026-04-14 03:04:05 +02:00
|
|
|
case "setLightIntensity":
|
|
|
|
|
case "setAmbientLightIntensity":
|
|
|
|
|
case "setSunLightIntensity":
|
2026-04-14 13:48:11 +02:00
|
|
|
return effect.intensity;
|
2026-04-14 03:04:05 +02:00
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRoutineColorValue(routine: ProjectScheduleRoutine): string | null {
|
2026-04-14 13:48:11 +02:00
|
|
|
const effect = routine.effects[0];
|
|
|
|
|
|
|
|
|
|
if (effect === undefined) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (effect.type) {
|
2026-04-14 03:04:05 +02:00
|
|
|
case "setLightColor":
|
|
|
|
|
case "setAmbientLightColor":
|
|
|
|
|
case "setSunLightColor":
|
2026-04-14 13:48:11 +02:00
|
|
|
return effect.colorHex;
|
2026-04-14 03:04:05 +02:00
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function groupTargetOptions(
|
|
|
|
|
targetOptions: ProjectScheduleTargetOption[]
|
|
|
|
|
): Array<{ groupLabel: string; options: ProjectScheduleTargetOption[] }> {
|
|
|
|
|
const grouped = new Map<string, ProjectScheduleTargetOption[]>();
|
|
|
|
|
|
|
|
|
|
for (const option of targetOptions) {
|
|
|
|
|
const entries = grouped.get(option.groupLabel) ?? [];
|
|
|
|
|
entries.push(option);
|
|
|
|
|
grouped.set(option.groupLabel, entries);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...grouped.entries()].map(([groupLabel, options]) => ({
|
|
|
|
|
groupLabel,
|
|
|
|
|
options
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ProjectSchedulePane({
|
|
|
|
|
targetOptions,
|
|
|
|
|
scheduler,
|
|
|
|
|
selectedRoutineId,
|
|
|
|
|
onSelectRoutine,
|
|
|
|
|
onAddRoutine,
|
|
|
|
|
onDeleteRoutine,
|
|
|
|
|
onClose,
|
|
|
|
|
onSetRoutineTarget,
|
|
|
|
|
onSetRoutineTitle,
|
|
|
|
|
onSetRoutineEnabled,
|
|
|
|
|
onSetRoutineStartHour,
|
|
|
|
|
onSetRoutineEndHour,
|
|
|
|
|
onSetRoutinePriority,
|
|
|
|
|
onSetRoutineEffectOption,
|
|
|
|
|
onSetRoutineDays,
|
|
|
|
|
onSetRoutineNumericValue,
|
|
|
|
|
onSetRoutineColorValue,
|
|
|
|
|
onSetRoutineAnimationClip,
|
2026-04-14 13:48:25 +02:00
|
|
|
onSetRoutineAnimationLoop,
|
|
|
|
|
onSetActorRoutinePresence,
|
|
|
|
|
onSetActorRoutineAnimationClip,
|
|
|
|
|
onSetActorRoutineAnimationLoop,
|
|
|
|
|
onSetActorRoutinePath,
|
|
|
|
|
onSetActorRoutinePathSpeed,
|
|
|
|
|
onSetActorRoutinePathLoop
|
2026-04-14 03:04:05 +02:00
|
|
|
}: ProjectSchedulePaneProps) {
|
|
|
|
|
const selectedRoutine =
|
|
|
|
|
selectedRoutineId === null ? null : scheduler.routines[selectedRoutineId] ?? null;
|
|
|
|
|
const selectedTargetOption =
|
|
|
|
|
selectedRoutine === null
|
|
|
|
|
? null
|
|
|
|
|
: getProjectScheduleTargetOptionForRoutine(
|
|
|
|
|
targetOptions,
|
|
|
|
|
selectedRoutine.target
|
|
|
|
|
);
|
|
|
|
|
const selectedEffectOptionId =
|
2026-04-14 13:48:25 +02:00
|
|
|
selectedRoutine === null || selectedRoutine.target.kind === "actor"
|
2026-04-14 03:04:05 +02:00
|
|
|
? null
|
2026-04-14 13:48:25 +02:00
|
|
|
: getProjectScheduleEffectOptionId(selectedRoutine.effects[0]!);
|
2026-04-14 03:04:05 +02:00
|
|
|
const selectedEffectOptions =
|
2026-04-14 13:48:25 +02:00
|
|
|
selectedTargetOption === null || selectedTargetOption.target.kind === "actor"
|
2026-04-14 03:04:05 +02:00
|
|
|
? []
|
|
|
|
|
: listProjectScheduleEffectOptions(selectedTargetOption);
|
2026-04-14 13:48:25 +02:00
|
|
|
const selectedActorPresenceEffect =
|
|
|
|
|
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
|
|
|
|
|
? null
|
|
|
|
|
: findProjectScheduleRoutineEffect(selectedRoutine, "setActorPresence");
|
|
|
|
|
const selectedActorAnimationEffect =
|
|
|
|
|
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
|
|
|
|
|
? null
|
|
|
|
|
: findProjectScheduleRoutineEffect(selectedRoutine, "playActorAnimation");
|
|
|
|
|
const selectedActorPathEffect =
|
|
|
|
|
selectedRoutine === null || selectedRoutine.target.kind !== "actor"
|
|
|
|
|
? null
|
|
|
|
|
: findProjectScheduleRoutineEffect(selectedRoutine, "followActorPath");
|
2026-04-14 03:04:05 +02:00
|
|
|
const selectedRoutineDays =
|
|
|
|
|
selectedRoutine === null
|
|
|
|
|
? PROJECT_SCHEDULE_WEEKDAYS
|
|
|
|
|
: selectedRoutine.days.mode === "everyDay"
|
|
|
|
|
? PROJECT_SCHEDULE_WEEKDAYS
|
|
|
|
|
: selectedRoutine.days.days;
|
|
|
|
|
const hourTicks = Array.from({ length: HOURS_PER_DAY }, (_, hour) => hour);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="schedule-pane" data-testid="project-schedule-pane">
|
|
|
|
|
<div className="schedule-pane__header">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="label">Schedule</div>
|
|
|
|
|
<div className="schedule-pane__summary">
|
|
|
|
|
Scheduler-owned orchestration over typed control targets and global
|
|
|
|
|
project time.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="schedule-pane__actions">
|
|
|
|
|
<button
|
|
|
|
|
className="toolbar__button toolbar__button--compact"
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={targetOptions.length === 0}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
onAddRoutine(
|
|
|
|
|
selectedTargetOption?.key ?? targetOptions[0]?.key ?? ""
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Add Routine
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="toolbar__button toolbar__button--compact"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="schedule-pane__body">
|
|
|
|
|
<div className="schedule-pane__timeline">
|
|
|
|
|
<div className="schedule-ruler">
|
|
|
|
|
<div className="schedule-ruler__label">Targets</div>
|
|
|
|
|
<div className="schedule-ruler__track">
|
|
|
|
|
{hourTicks.map((hour) => (
|
|
|
|
|
<div key={hour} className="schedule-ruler__tick">
|
|
|
|
|
<span>{String(hour).padStart(2, "0")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{targetOptions.length === 0 ? (
|
|
|
|
|
<div className="schedule-pane__empty">
|
|
|
|
|
No scheduler-addressable control targets are authored in this
|
|
|
|
|
project yet.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
targetOptions.map((targetOption) => {
|
|
|
|
|
const routines = Object.values(scheduler.routines)
|
|
|
|
|
.filter(
|
|
|
|
|
(routine) =>
|
|
|
|
|
getControlTargetRefKey(routine.target) === targetOption.key
|
|
|
|
|
)
|
|
|
|
|
.sort((left, right) => left.startHour - right.startHour);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={targetOption.key} className="schedule-row">
|
|
|
|
|
<div className="schedule-row__label">
|
|
|
|
|
<button
|
|
|
|
|
className="schedule-row__add"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onAddRoutine(targetOption.key)}
|
|
|
|
|
>
|
|
|
|
|
+
|
|
|
|
|
</button>
|
|
|
|
|
<div className="schedule-row__meta">
|
|
|
|
|
<div className="schedule-row__title">{targetOption.label}</div>
|
|
|
|
|
<div className="schedule-row__subtitle">
|
|
|
|
|
{targetOption.groupLabel} · {targetOption.subtitle}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="schedule-row__track">
|
|
|
|
|
<div className="schedule-row__grid" />
|
|
|
|
|
{routines.map((routine) =>
|
|
|
|
|
getProjectScheduleTimelineSegments(routine).map(
|
|
|
|
|
(segment) => (
|
|
|
|
|
<button
|
|
|
|
|
key={segment.key}
|
|
|
|
|
className={`schedule-block ${
|
|
|
|
|
selectedRoutineId === routine.id
|
|
|
|
|
? "schedule-block--selected"
|
|
|
|
|
: ""
|
|
|
|
|
} ${
|
|
|
|
|
isRoutineEffectInactive(routine)
|
|
|
|
|
? "schedule-block--inactive"
|
|
|
|
|
: ""
|
|
|
|
|
} ${
|
|
|
|
|
routine.enabled ? "" : "schedule-block--disabled"
|
|
|
|
|
}`.trim()}
|
|
|
|
|
type="button"
|
|
|
|
|
title={`${routine.title} · ${getRoutineSummary(routine)}`}
|
|
|
|
|
style={{
|
|
|
|
|
left: `${(segment.startHour / HOURS_PER_DAY) * 100}%`,
|
|
|
|
|
width: `${((segment.endHour - segment.startHour) / HOURS_PER_DAY) * 100}%`
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => onSelectRoutine(routine.id)}
|
|
|
|
|
>
|
|
|
|
|
<span className="schedule-block__title">
|
|
|
|
|
{routine.title}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<aside className="schedule-pane__editor">
|
|
|
|
|
{selectedRoutine === null || selectedTargetOption === null ? (
|
|
|
|
|
<div className="schedule-pane__empty">
|
|
|
|
|
Select a routine block or create a new schedule routine.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Routine</div>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Title</span>
|
|
|
|
|
<input
|
|
|
|
|
key={`${selectedRoutine.id}-title`}
|
|
|
|
|
className="text-input"
|
|
|
|
|
type="text"
|
|
|
|
|
defaultValue={selectedRoutine.title}
|
|
|
|
|
onBlur={(event) =>
|
|
|
|
|
onSetRoutineTitle(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.value
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onKeyDown={(event) =>
|
|
|
|
|
handleCommitOnEnter(event, () =>
|
|
|
|
|
onSetRoutineTitle(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.value
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Target</span>
|
|
|
|
|
<select
|
|
|
|
|
className="select-input"
|
|
|
|
|
value={selectedTargetOption.key}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineTarget(selectedRoutine.id, event.currentTarget.value)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{groupTargetOptions(targetOptions).map((group) => (
|
|
|
|
|
<optgroup key={group.groupLabel} label={group.groupLabel}>
|
|
|
|
|
{group.options.map((option) => (
|
|
|
|
|
<option key={option.key} value={option.key}>
|
|
|
|
|
{option.label} ({option.subtitle})
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</optgroup>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Effect</span>
|
|
|
|
|
<select
|
|
|
|
|
className="select-input"
|
|
|
|
|
value={selectedEffectOptionId ?? ""}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineEffectOption(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.value as ProjectScheduleEffectOptionId
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{selectedEffectOptions.map((effectOption) => (
|
|
|
|
|
<option key={effectOption.id} value={effectOption.id}>
|
|
|
|
|
{effectOption.label}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="form-field form-field--inline">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selectedRoutine.enabled}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineEnabled(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.checked
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<span className="label">Enabled</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Days</div>
|
|
|
|
|
<div className="schedule-days">
|
|
|
|
|
<button
|
|
|
|
|
className={`schedule-day ${
|
|
|
|
|
selectedRoutine.days.mode === "everyDay"
|
|
|
|
|
? "schedule-day--active"
|
|
|
|
|
: ""
|
|
|
|
|
}`.trim()}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
onSetRoutineDays(selectedRoutine.id, "everyDay", [])
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Every
|
|
|
|
|
</button>
|
|
|
|
|
{PROJECT_SCHEDULE_WEEKDAYS.map((weekday) => {
|
|
|
|
|
const selected = selectedRoutineDays.includes(weekday);
|
|
|
|
|
const nextDays =
|
|
|
|
|
selectedRoutine.days.mode === "everyDay"
|
|
|
|
|
? PROJECT_SCHEDULE_WEEKDAYS.filter(
|
|
|
|
|
(entry) => entry !== weekday
|
|
|
|
|
)
|
|
|
|
|
: selected
|
|
|
|
|
? selectedRoutineDays.filter(
|
|
|
|
|
(entry) => entry !== weekday
|
|
|
|
|
)
|
|
|
|
|
: [...selectedRoutineDays, weekday];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={weekday}
|
|
|
|
|
className={`schedule-day ${
|
|
|
|
|
selected ? "schedule-day--active" : ""
|
|
|
|
|
}`.trim()}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
onSetRoutineDays(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
nextDays.length === PROJECT_SCHEDULE_WEEKDAYS.length
|
|
|
|
|
? "everyDay"
|
|
|
|
|
: "selectedDays",
|
|
|
|
|
nextDays.length === 0 ? [weekday] : nextDays
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{formatProjectScheduleWeekdayLabel(weekday)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Window</div>
|
|
|
|
|
<div className="vector-inputs vector-inputs--two">
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Start</span>
|
|
|
|
|
<input
|
|
|
|
|
key={`${selectedRoutine.id}-start`}
|
|
|
|
|
className="text-input"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="24"
|
|
|
|
|
step="0.25"
|
|
|
|
|
defaultValue={selectedRoutine.startHour}
|
|
|
|
|
onBlur={(event) =>
|
|
|
|
|
onSetRoutineStartHour(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onKeyDown={(event) =>
|
|
|
|
|
handleCommitOnEnter(event, () =>
|
|
|
|
|
onSetRoutineStartHour(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">End</span>
|
|
|
|
|
<input
|
|
|
|
|
key={`${selectedRoutine.id}-end`}
|
|
|
|
|
className="text-input"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="24"
|
|
|
|
|
step="0.25"
|
|
|
|
|
defaultValue={selectedRoutine.endHour}
|
|
|
|
|
onBlur={(event) =>
|
|
|
|
|
onSetRoutineEndHour(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onKeyDown={(event) =>
|
|
|
|
|
handleCommitOnEnter(event, () =>
|
|
|
|
|
onSetRoutineEndHour(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Priority</span>
|
|
|
|
|
<input
|
|
|
|
|
key={`${selectedRoutine.id}-priority`}
|
|
|
|
|
className="text-input"
|
|
|
|
|
type="number"
|
|
|
|
|
step="1"
|
|
|
|
|
defaultValue={selectedRoutine.priority}
|
|
|
|
|
onBlur={(event) =>
|
|
|
|
|
onSetRoutinePriority(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onKeyDown={(event) =>
|
|
|
|
|
handleCommitOnEnter(event, () =>
|
|
|
|
|
onSetRoutinePriority(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{selectedEffectOptions.find(
|
|
|
|
|
(effectOption) => effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.valueKind === "number" ? (
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Value</div>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">
|
|
|
|
|
{selectedEffectOptions.find(
|
|
|
|
|
(effectOption) => effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.valueLabel ?? "Value"}
|
|
|
|
|
</span>
|
|
|
|
|
<input
|
|
|
|
|
key={`${selectedRoutine.id}-numeric`}
|
|
|
|
|
className="text-input"
|
|
|
|
|
type="number"
|
|
|
|
|
min={
|
|
|
|
|
selectedEffectOptions.find(
|
|
|
|
|
(effectOption) =>
|
|
|
|
|
effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.min ?? 0
|
|
|
|
|
}
|
|
|
|
|
step={
|
|
|
|
|
selectedEffectOptions.find(
|
|
|
|
|
(effectOption) =>
|
|
|
|
|
effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.step ?? 0.1
|
|
|
|
|
}
|
|
|
|
|
defaultValue={getRoutineNumericValue(selectedRoutine) ?? 0}
|
|
|
|
|
onBlur={(event) =>
|
|
|
|
|
onSetRoutineNumericValue(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onKeyDown={(event) =>
|
|
|
|
|
handleCommitOnEnter(event, () =>
|
|
|
|
|
onSetRoutineNumericValue(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
Number(event.currentTarget.value)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{selectedEffectOptions.find(
|
|
|
|
|
(effectOption) => effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.valueKind === "color" ? (
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Value</div>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">
|
|
|
|
|
{selectedEffectOptions.find(
|
|
|
|
|
(effectOption) => effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.valueLabel ?? "Color"}
|
|
|
|
|
</span>
|
|
|
|
|
<input
|
|
|
|
|
className="color-input"
|
|
|
|
|
type="color"
|
|
|
|
|
value={getRoutineColorValue(selectedRoutine) ?? "#ffffff"}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineColorValue(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.value
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{selectedEffectOptions.find(
|
|
|
|
|
(effectOption) => effectOption.id === selectedEffectOptionId
|
|
|
|
|
)?.valueKind === "animation" ? (
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Animation</div>
|
|
|
|
|
<label className="form-field">
|
|
|
|
|
<span className="label">Clip</span>
|
|
|
|
|
<select
|
|
|
|
|
className="select-input"
|
|
|
|
|
value={
|
|
|
|
|
selectedRoutine.effect.type === "playModelAnimation"
|
|
|
|
|
? selectedRoutine.effect.clipName
|
|
|
|
|
: selectedTargetOption.defaults.animationClipNames?.[0] ?? ""
|
|
|
|
|
}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineAnimationClip(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.value
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{(selectedTargetOption.defaults.animationClipNames ?? []).map(
|
|
|
|
|
(clipName) => (
|
|
|
|
|
<option key={clipName} value={clipName}>
|
|
|
|
|
{clipName}
|
|
|
|
|
</option>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="form-field form-field--inline">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={
|
|
|
|
|
selectedRoutine.effect.type === "playModelAnimation"
|
|
|
|
|
? selectedRoutine.effect.loop !== false
|
|
|
|
|
: true
|
|
|
|
|
}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
onSetRoutineAnimationLoop(
|
|
|
|
|
selectedRoutine.id,
|
|
|
|
|
event.currentTarget.checked
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<span className="label">Loop</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<div className="label">Details</div>
|
|
|
|
|
<div className="schedule-pane__summary">
|
|
|
|
|
{selectedTargetOption.groupLabel} · {selectedTargetOption.label}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="schedule-pane__summary">
|
|
|
|
|
{getRoutineSummary(selectedRoutine)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="form-section">
|
|
|
|
|
<button
|
|
|
|
|
className="toolbar__button toolbar__button--compact"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onDeleteRoutine(selectedRoutine.id)}
|
|
|
|
|
>
|
|
|
|
|
Delete Routine
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|