import { ReactNode, RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { createContext, useContextSelector } from 'use-context-selector'; import { v4 as uuidv4 } from 'uuid'; import { isSupportedImageFile, uploadImageAttachment } from '../services/attachments'; import { findFirstPlaceholder } from '../utils/workflowTemplate'; export interface ComposerAttachment { id: string; name: string; mime: string; kind: 'image'; status: 'uploading' | 'ready' | 'error'; /** Object URL used only for the local thumbnail while the composer owns it. */ previewUrl: string; attachmentId?: number; url?: string; width?: number; height?: number; dataUrl?: string; } const isAcceptedFile = (file: File) => isSupportedImageFile(file); interface ComposerContextProps { draft: string; setDraft: React.Dispatch>; textareaRef: RefObject; focusComposer: () => void; populateDraft: (value: string) => void; applyTemplate: (text: string) => void; attachments: ComposerAttachment[]; addFiles: (files: FileList | File[]) => void; removeAttachment: (id: string) => void; clearAttachments: () => void; } const ComposerContext = createContext({ draft: '', setDraft: () => {}, textareaRef: { current: null }, focusComposer: () => {}, populateDraft: () => {}, applyTemplate: () => {}, attachments: [], addFiles: () => {}, removeAttachment: () => {}, clearAttachments: () => {}, }); export function ComposerProvider({ children }: { children: ReactNode }) { const [draft, setDraft] = useState(''); const [attachments, setAttachments] = useState([]); const textareaRef = useRef(null); const focusComposer = useCallback(() => { window.setTimeout(() => textareaRef.current?.focus(), 0); }, []); const populateDraft = useCallback((value: string) => { setDraft(value); focusComposer(); }, [focusComposer]); // Load a workflow's expanded template, then select the first [placeholder] so the // user can type over it (and Tab to the next — handled in the composer). const applyTemplate = useCallback((text: string) => { setDraft(text); window.setTimeout(() => { const el = textareaRef.current; if (!el) return; el.focus(); const range = findFirstPlaceholder(text); if (range) el.setSelectionRange(range[0], range[1]); else el.setSelectionRange(text.length, text.length); el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }, 0); }, []); const updateAttachment = useCallback((id: string, patch: Partial) => { setAttachments((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item))); }, []); const addFiles = useCallback((files: FileList | File[]) => { const list = Array.from(files).filter(isAcceptedFile); list.forEach((file) => { const id = uuidv4(); const previewUrl = URL.createObjectURL(file); setAttachments((prev) => [ ...prev, { id, name: file.name, mime: file.type, kind: 'image', status: 'uploading', previewUrl }, ]); uploadImageAttachment(file) .then((uploaded) => updateAttachment(id, { status: 'ready', attachmentId: uploaded.attachmentId, name: uploaded.name, mime: uploaded.mime, url: uploaded.url, width: uploaded.width, height: uploaded.height, dataUrl: uploaded.dataUrl, })) .catch(() => updateAttachment(id, { status: 'error' })); }); }, [updateAttachment]); const removeAttachment = useCallback((id: string) => { setAttachments((prev) => { const target = prev.find((item) => item.id === id); if (target) URL.revokeObjectURL(target.previewUrl); return prev.filter((item) => item.id !== id); }); }, []); const clearAttachments = useCallback(() => { setAttachments((prev) => { prev.forEach((item) => URL.revokeObjectURL(item.previewUrl)); return []; }); }, []); const value = useMemo(() => ({ draft, setDraft, textareaRef, focusComposer, populateDraft, applyTemplate, attachments, addFiles, removeAttachment, clearAttachments, }), [draft, focusComposer, populateDraft, applyTemplate, attachments, addFiles, removeAttachment, clearAttachments]); return ( {children} ); } const useComposerSelector = (selector: (state: ComposerContextProps) => T): T => ( useContextSelector(ComposerContext, selector) ); export const useComposerDraft = () => useComposerSelector(state => state.draft); export const useSetComposerDraft = () => useComposerSelector(state => state.setDraft); export const useComposerTextareaRef = () => useComposerSelector(state => state.textareaRef); export const useFocusComposer = () => useComposerSelector(state => state.focusComposer); export const usePopulateComposerDraft = () => useComposerSelector(state => state.populateDraft); export const useApplyComposerTemplate = () => useComposerSelector(state => state.applyTemplate); export const useComposerAttachments = () => useComposerSelector(state => state.attachments); export const useAddComposerFiles = () => useComposerSelector(state => state.addFiles); export const useRemoveComposerAttachment = () => useComposerSelector(state => state.removeAttachment); export const useClearComposerAttachments = () => useComposerSelector(state => state.clearAttachments);