返回 JoyAI-Echo
useWorkplaceEditor.ts
根目录 / echo_longvideo / Director_Agent / webui / src / hooks / workplace-editor / useWorkplaceEditor.ts
1 import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
2
3 import type { StoryProfile } from "@/lib/types";
4
5 import {
6 applyServerSnapshot,
7 buildSegmentsFromServer,
8 editorClearOverlays,
9 editorEdit,
10 editorGet,
11 editorHold,
12 editorRelease,
13 editorSetCommitting,
14 hasDirtyBeats,
15 initialEditorState,
16 mergeStoryProfileWithOverlay,
17 } from "./reducer";
18 import {
19 beatPath,
20 type CommitAction,
21 type EditorSegment,
22 type FieldPath,
23 type WorkplaceEditorState,
24 type WorkplaceMutations,
25 } from "./types";
26
27 type EditorAction =
28 | {
29 type: "SERVER_SNAPSHOT";
30 workplace: NonNullable<WorkplaceEditorState["server"]>;
31 }
32 | { type: "RESET" }
33 | { type: "HOLD"; path: FieldPath }
34 | { type: "EDIT"; path: FieldPath; value: string }
35 | { type: "RELEASE"; path: FieldPath }
36 | { type: "CLEAR_OVERLAYS"; paths?: FieldPath[] }
37 | { type: "SET_COMMITTING"; committing: boolean };
38
39 function reducer(
40 state: WorkplaceEditorState,
41 action: EditorAction,
42 ): WorkplaceEditorState {
43 switch (action.type) {
44 case "SERVER_SNAPSHOT":
45 return applyServerSnapshot(state, action.workplace);
46 case "RESET":
47 return initialEditorState;
48 case "HOLD":
49 return editorHold(state, action.path);
50 case "EDIT":
51 return editorEdit(state, action.path, action.value);
52 case "RELEASE":
53 return editorRelease(state, action.path);
54 case "CLEAR_OVERLAYS":
55 return editorClearOverlays(state, action.paths);
56 case "SET_COMMITTING":
57 return editorSetCommitting(state, action.committing);
58 default:
59 return state;
60 }
61 }
62
63 export type UseWorkplaceEditorResult = {
64 get: (path: FieldPath) => string;
65 hold: (path: FieldPath) => void;
66 edit: (path: FieldPath, value: string) => void;
67 release: (path: FieldPath) => void;
68 saveStory: () => Promise<void>;
69 saveBeats: () => Promise<void>;
70 commit: (action: CommitAction) => Promise<void>;
71 segments: EditorSegment[];
72 storyReadOnly: boolean;
73 beatsReadOnly: boolean;
74 storyDirty: boolean;
75 beatsDirty: boolean;
76 committing: boolean;
77 /** True while any story/beat textarea holds a field lease (focus). */
78 isFieldFocused: boolean;
79 };
80
81 export function useWorkplaceEditor(
82 workplace: WorkplaceEditorState["server"],
83 mutations: WorkplaceMutations,
84 ): UseWorkplaceEditorResult {
85 const [state, dispatch] = useReducer(reducer, initialEditorState);
86 const stateRef = useRef(state);
87 stateRef.current = state;
88
89 useEffect(() => {
90 if (!workplace) {
91 dispatch({ type: "RESET" });
92 return;
93 }
94 dispatch({ type: "SERVER_SNAPSHOT", workplace });
95 }, [workplace]);
96
97 const get = useCallback(
98 (path: FieldPath) => editorGet(stateRef.current, path),
99 [],
100 );
101
102 const flushStoryIfDirty = useCallback(async () => {
103 const current = stateRef.current;
104
105 if (!current.server?.story_editable || !current.dirty.story) return;
106 const storyMd = editorGet(current, "story").trim();
107 if (!storyMd) return;
108 await mutations.saveStory(storyMd);
109 dispatch({ type: "CLEAR_OVERLAYS", paths: ["story"] });
110 }, [mutations]);
111
112 const flushBeatsIfDirty = useCallback(async () => {
113 const current = stateRef.current;
114 if (!current.server?.beats_editable || !hasDirtyBeats(current.dirty))
115 return;
116 const profile = mergeStoryProfileWithOverlay(
117 current.server,
118 current.overlay,
119 current.dirty,
120 );
121 if (!profile) return;
122 await mutations.saveStoryProfile(profile as StoryProfile);
123 dispatch({ type: "CLEAR_OVERLAYS" });
124 }, [mutations]);
125
126 const saveStory = useCallback(async () => {
127 dispatch({ type: "SET_COMMITTING", committing: true });
128 try {
129 await flushStoryIfDirty();
130 } finally {
131 dispatch({ type: "SET_COMMITTING", committing: false });
132 }
133 }, [flushStoryIfDirty]);
134
135 const saveBeats = useCallback(async () => {
136 dispatch({ type: "SET_COMMITTING", committing: true });
137 try {
138 await flushBeatsIfDirty();
139 } finally {
140 dispatch({ type: "SET_COMMITTING", committing: false });
141 }
142 }, [flushBeatsIfDirty]);
143
144 const hold = useCallback((path: FieldPath) => {
145 dispatch({ type: "HOLD", path });
146 }, []);
147
148 const edit = useCallback((path: FieldPath, value: string) => {
149 dispatch({ type: "EDIT", path, value });
150 }, []);
151
152 const release = useCallback((path: FieldPath) => {
153 dispatch({ type: "RELEASE", path });
154 }, []);
155
156 const commit = useCallback(
157 async (action: CommitAction) => {
158 dispatch({ type: "SET_COMMITTING", committing: true });
159 try {
160 switch (action.type) {
161 case "confirm_story": {
162 // Workflow 步骤不隐式 save;须先点「保存」再确认,避免重复调度 edit LLM。
163 await mutations.confirmStory();
164 dispatch({ type: "CLEAR_OVERLAYS", paths: ["story"] });
165 break;
166 }
167 case "start_generation": {
168 // 同上:分镜 overlay 须经 saveBeats 持久化后再进入生成流程。
169 await mutations.startGeneration();
170 dispatch({ type: "CLEAR_OVERLAYS" });
171 break;
172 }
173 case "start_merge": {
174 await mutations.startMerge();
175 break;
176 }
177 case "split_shot": {
178 await mutations.splitShot(action.shotId, {
179 before_text: action.beforeText.trim(),
180 after_text: action.afterText.trim(),
181 });
182 dispatch({ type: "CLEAR_OVERLAYS" });
183 break;
184 }
185 case "merge_shot": {
186 await mutations.mergeShotUp(action.shotId, action.mergedText);
187 dispatch({ type: "CLEAR_OVERLAYS" });
188 break;
189 }
190 case "delete_shot": {
191 await mutations.deleteShot(action.shotId);
192 dispatch({ type: "CLEAR_OVERLAYS" });
193 break;
194 }
195 }
196 } finally {
197 dispatch({ type: "SET_COMMITTING", committing: false });
198 }
199 },
200 [mutations],
201 );
202
203 const segments = useMemo(() => {
204 if (!state.server) return [];
205 return buildSegmentsFromServer(
206 state.server,
207 state.overlay,
208 state.lease,
209 state.dirty,
210 );
211 }, [state.dirty, state.lease, state.overlay, state.server]);
212
213 const storyReadOnly = !state.server?.story_editable;
214 const beatsReadOnly = !state.server?.beats_editable;
215 const storyDirty = Boolean(state.dirty.story);
216 const beatsDirty = hasDirtyBeats(state.dirty);
217 const isFieldFocused = useMemo(
218 () => Object.values(state.lease).some((mode) => mode === "held"),
219 [state.lease],
220 );
221
222 return {
223 get,
224 hold,
225 edit,
226 release,
227 saveStory,
228 saveBeats,
229 commit,
230 segments,
231 storyReadOnly,
232 beatsReadOnly,
233 storyDirty,
234 beatsDirty,
235 committing: state.committing,
236 isFieldFocused,
237 };
238 }
239
240 export { beatPath };
241
241 lines TYPESCRIPT