| 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 |