Update React Editor integration and clean up template
This commit is contained in:
@@ -74,15 +74,10 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
// FileReader uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
@@ -118,7 +113,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
|
||||
video.srcObject = stream;
|
||||
|
||||
// Video element uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
|
||||
@@ -144,7 +138,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
|
||||
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, "image/png");
|
||||
@@ -174,10 +167,6 @@ const captureScreenshot = async (): Promise<File | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Provider Context & Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AttachmentsContext {
|
||||
files: (FileUIPart & { id: string })[];
|
||||
add: (files: File[] | FileList) => void;
|
||||
@@ -196,7 +185,6 @@ export interface TextInputContext {
|
||||
export interface PromptInputControllerProps {
|
||||
textInput: TextInputContext;
|
||||
attachments: AttachmentsContext;
|
||||
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
|
||||
__registerFileInput: (
|
||||
ref: RefObject<HTMLInputElement | null>,
|
||||
open: () => void
|
||||
@@ -220,7 +208,6 @@ export const usePromptInputController = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// Optional variants (do NOT throw). Useful for dual-mode components.
|
||||
const useOptionalPromptInputController = () =>
|
||||
useContext(PromptInputController);
|
||||
|
||||
@@ -241,19 +228,14 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional global provider that lifts PromptInput state outside of PromptInput.
|
||||
* If you don't use it, PromptInput stays fully self-managed.
|
||||
*/
|
||||
/** Lifts prompt state when shared control is needed. */
|
||||
export const PromptInputProvider = ({
|
||||
initialInput: initialTextInput = "",
|
||||
children,
|
||||
}: PromptInputProviderProps) => {
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
|
||||
// ----- attachments state (global when wrapped)
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(FileUIPart & { id: string })[]
|
||||
>([]);
|
||||
@@ -300,14 +282,13 @@ export const PromptInputProvider = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
// Keep the latest files available to the unmount cleanup.
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
|
||||
useEffect(() => {
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
}, [attachmentFiles]);
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const f of attachmentsRef.current) {
|
||||
@@ -365,14 +346,9 @@ export const PromptInputProvider = ({
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component Context & Hooks
|
||||
// ============================================================================
|
||||
|
||||
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
|
||||
|
||||
export const usePromptInputAttachments = () => {
|
||||
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
|
||||
const provider = useOptionalProviderAttachments();
|
||||
const local = useContext(LocalAttachmentsContext);
|
||||
const context = local ?? provider;
|
||||
@@ -384,10 +360,6 @@ export const usePromptInputAttachments = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Referenced Sources (Local to PromptInput)
|
||||
// ============================================================================
|
||||
|
||||
export interface ReferencedSourcesContext {
|
||||
sources: (SourceDocumentUIPart & { id: string })[];
|
||||
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
|
||||
@@ -490,16 +462,11 @@ export type PromptInputProps = Omit<
|
||||
HTMLAttributes<HTMLFormElement>,
|
||||
"onSubmit" | "onError"
|
||||
> & {
|
||||
// e.g., "image/*" or leave undefined for any
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
// When true, accepts drops anywhere on document. Default false (opt-in).
|
||||
globalDrop?: boolean;
|
||||
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
||||
syncHiddenInput?: boolean;
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
// bytes
|
||||
maxFileSize?: number;
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept";
|
||||
@@ -524,24 +491,19 @@ export const PromptInput = ({
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) => {
|
||||
// Try to use a provider controller if present
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
|
||||
// Refs
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
// ----- Local attachments (only used when no provider)
|
||||
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
||||
const files = usingProvider ? controller.attachments.files : items;
|
||||
|
||||
// ----- Local referenced sources (always local to PromptInput)
|
||||
const [referencedSources, setReferencedSources] = useState<
|
||||
(SourceDocumentUIPart & { id: string })[]
|
||||
>([]);
|
||||
|
||||
// Keep a ref to files for cleanup on unmount (avoids stale closure)
|
||||
const filesRef = useRef(files);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -565,7 +527,6 @@ export const PromptInput = ({
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.endsWith("/*")) {
|
||||
// e.g: image/* -> image/
|
||||
const prefix = pattern.slice(0, -1);
|
||||
return f.type.startsWith(prefix);
|
||||
}
|
||||
@@ -638,7 +599,6 @@ export const PromptInput = ({
|
||||
[]
|
||||
);
|
||||
|
||||
// Wrapper that validates files before calling provider's add
|
||||
const addWithProviderValidation = useCallback(
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = [...fileList];
|
||||
@@ -713,7 +673,6 @@ export const PromptInput = ({
|
||||
clearReferencedSources();
|
||||
}, [clearAttachments, clearReferencedSources]);
|
||||
|
||||
// Let provider know about our hidden file input so external menus can call openFileDialog()
|
||||
useEffect(() => {
|
||||
if (!usingProvider) {
|
||||
return;
|
||||
@@ -721,22 +680,19 @@ export const PromptInput = ({
|
||||
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
|
||||
}, [usingProvider, controller]);
|
||||
|
||||
// Note: File input cannot be programmatically set for security reasons
|
||||
// The syncHiddenInput prop is no longer functional
|
||||
// Browsers forbid programmatically populating file inputs.
|
||||
useEffect(() => {
|
||||
if (syncHiddenInput && inputRef.current && files.length === 0) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}, [files, syncHiddenInput]);
|
||||
|
||||
// Attach drop handlers on nearest form and document (opt-in)
|
||||
useEffect(() => {
|
||||
const form = formRef.current;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
if (globalDrop) {
|
||||
// when global drop is on, let the document-level handler own drops
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -805,7 +761,6 @@ export const PromptInput = ({
|
||||
if (event.currentTarget.files) {
|
||||
add(event.currentTarget.files);
|
||||
}
|
||||
// Reset input value to allow selecting files that were previously removed
|
||||
event.currentTarget.value = "";
|
||||
},
|
||||
[add]
|
||||
@@ -853,19 +808,16 @@ export const PromptInput = ({
|
||||
return (formData.get("message") as string) || "";
|
||||
})();
|
||||
|
||||
// Reset form immediately after capturing text to avoid race condition
|
||||
// where user input during async blob conversion would be lost
|
||||
// Reset before async conversion so new input is not cleared afterward.
|
||||
if (!usingProvider) {
|
||||
form.reset();
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert blob URLs to data URLs asynchronously
|
||||
const convertedFiles: FileUIPart[] = await Promise.all(
|
||||
files.map(async ({ id: _id, ...item }) => {
|
||||
if (item.url?.startsWith("blob:")) {
|
||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||
// If conversion failed, keep the original blob URL
|
||||
return {
|
||||
...item,
|
||||
url: dataUrl ?? item.url,
|
||||
@@ -877,7 +829,6 @@ export const PromptInput = ({
|
||||
|
||||
const result = onSubmit({ files: convertedFiles, text }, event);
|
||||
|
||||
// Handle both sync and async onSubmit
|
||||
if (result instanceof Promise) {
|
||||
try {
|
||||
await result;
|
||||
@@ -886,23 +837,21 @@ export const PromptInput = ({
|
||||
controller.textInput.clear();
|
||||
}
|
||||
} catch {
|
||||
// Don't clear on error - user may want to retry
|
||||
// Preserve failed submissions for retry.
|
||||
}
|
||||
} else {
|
||||
// Sync function completed without throwing, clear inputs
|
||||
clear();
|
||||
if (usingProvider) {
|
||||
controller.textInput.clear();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Don't clear on error - user may want to retry
|
||||
// Preserve failed submissions for retry.
|
||||
}
|
||||
},
|
||||
[usingProvider, controller, files, onSubmit, clear]
|
||||
);
|
||||
|
||||
// Render with or without local provider
|
||||
const inner = (
|
||||
<>
|
||||
<input
|
||||
@@ -932,7 +881,6 @@ export const PromptInput = ({
|
||||
</LocalReferencedSourcesContext.Provider>
|
||||
);
|
||||
|
||||
// Always provide LocalAttachmentsContext so children get validated add function
|
||||
return (
|
||||
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
|
||||
{withReferencedSources}
|
||||
@@ -966,10 +914,8 @@ export const PromptInputTextarea = ({
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
// Call the external onKeyDown handler first
|
||||
onKeyDown?.(e);
|
||||
|
||||
// If the external handler prevented default, don't run internal logic
|
||||
if (e.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
@@ -983,7 +929,6 @@ export const PromptInputTextarea = ({
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
// Check if the submit button is disabled before submitting
|
||||
const { form } = e.currentTarget;
|
||||
const submitButton = form?.querySelector(
|
||||
'button[type="submit"]'
|
||||
@@ -995,7 +940,6 @@ export const PromptInputTextarea = ({
|
||||
form?.requestSubmit();
|
||||
}
|
||||
|
||||
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
e.currentTarget.value === "" &&
|
||||
@@ -1205,9 +1149,6 @@ export const PromptInputActionMenuItem = ({
|
||||
<DropdownMenuItem className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
// Note: Actions that perform side-effects (like opening a file dialog)
|
||||
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
||||
status?: ChatStatus;
|
||||
onStop?: () => void;
|
||||
@@ -1364,7 +1305,6 @@ export const PromptInputTabLabel = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputTabLabelProps) => (
|
||||
// Content provided via children in props
|
||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
|
||||
<h3
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user