import React, { useCallback, useEffect, useState } from "react";
import { $getRoot, $createParagraphNode, EditorState } from "lexical";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import {
BeautifulMentionsPlugin,
BeautifulMentionNode,
type BeautifulMentionsTheme,
type BeautifulMentionsMenuItemProps,
} from "lexical-beautiful-mentions";
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
import { useLoadApps } from "@/hooks/useLoadApps";
import { forwardRef } from "react";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { parseAppMentions } from "@/shared/parse_mention_apps";
// Define the theme for mentions
const beautifulMentionsTheme: BeautifulMentionsTheme = {
"@": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md",
"@Focused": "outline-none ring-2 ring-ring",
};
// Custom menu item component
const CustomMenuItem = forwardRef<
HTMLLIElement,
BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => (
App
{typeof item === "string" ? item : item.value}
));
// Custom menu component
function CustomMenu({ loading: _loading, ...props }: any) {
return (
);
}
// Plugin to handle Enter key
function EnterKeyPlugin({ onSubmit }: { onSubmit: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event: KeyboardEvent) => {
// Check if mentions menu is open by looking for our custom menu element
const mentionsMenu = document.querySelector(
'[data-mentions-menu="true"]',
);
const hasVisibleItems =
mentionsMenu && mentionsMenu.children.length > 0;
if (hasVisibleItems) {
// If mentions menu is open with items, let the mentions plugin handle the Enter key
return false;
}
if (!event.shiftKey) {
event.preventDefault();
onSubmit();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH, // Use higher priority to catch before mentions plugin
);
}, [editor, onSubmit]);
return null;
}
// Plugin to clear editor content
function ClearEditorPlugin({
shouldClear,
onCleared,
}: {
shouldClear: boolean;
onCleared: () => void;
}) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (shouldClear) {
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
paragraph.select();
});
onCleared();
}
}, [editor, shouldClear, onCleared]);
return null;
}
interface LexicalChatInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: (e: React.ClipboardEvent) => void;
placeholder?: string;
disabled?: boolean;
}
function onError(error: Error) {
console.error(error);
}
export function LexicalChatInput({
value,
onChange,
onSubmit,
onPaste,
placeholder = "Ask Dyad to build...",
disabled = false,
}: LexicalChatInputProps) {
const { apps } = useLoadApps();
const [shouldClear, setShouldClear] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
// Prepare mention items - convert apps to mention format
const mentionItems = React.useMemo(() => {
if (!apps) return { "@": [] };
// Get current app name
const currentApp = apps.find((app) => app.id === selectedAppId);
const currentAppName = currentApp?.name;
// Parse already mentioned apps from current input value
const alreadyMentioned = parseAppMentions(value);
// Filter out current app and already mentioned apps
const filteredApps = apps.filter((app) => {
// Exclude current app
if (app.name === currentAppName) return false;
// Exclude already mentioned apps (case-insensitive comparison)
if (
alreadyMentioned.some(
(mentioned) => mentioned.toLowerCase() === app.name.toLowerCase(),
)
)
return false;
return true;
});
const appMentions = filteredApps.map((app) => app.name);
return {
"@": appMentions,
};
}, [apps, selectedAppId, value]);
const initialConfig = {
namespace: "ChatInput",
theme: {
beautifulMentions: beautifulMentionsTheme,
},
onError,
nodes: [BeautifulMentionNode],
editable: !disabled,
};
const handleEditorChange = useCallback(
(editorState: EditorState) => {
editorState.read(() => {
const root = $getRoot();
let textContent = root.getTextContent();
console.time("handleEditorChange");
// Transform @AppName mentions to @app:AppName format
// This regex matches @AppName where AppName is one of our actual app names
// Short-circuit if there's no "@" symbol in the text
if (textContent.includes("@")) {
const appNames = apps?.map((app) => app.name) || [];
for (const appName of appNames) {
// Escape special regex characters in app name
const escapedAppName = appName.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const mentionRegex = new RegExp(
`@(${escapedAppName})(?![a-zA-Z0-9_-])`,
"g",
);
textContent = textContent.replace(mentionRegex, "@app:$1");
}
}
console.timeEnd("handleEditorChange");
onChange(textContent);
});
},
[onChange, apps],
);
const handleSubmit = useCallback(() => {
onSubmit();
setShouldClear(true);
}, [onSubmit]);
const handleCleared = useCallback(() => {
setShouldClear(false);
}, []);
// Update editor content when value changes externally (like clearing)
useEffect(() => {
if (value === "") {
setShouldClear(true);
}
}, [value]);
return (
}
onPaste={onPaste}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
);
}