diff --git a/src/ChatView.tsx b/src/ChatView.tsx index 2ebf439..9c5a047 100644 --- a/src/ChatView.tsx +++ b/src/ChatView.tsx @@ -7,6 +7,7 @@ import Chat, { ChatProps, ChatRef } from './components/chat-view/Chat' import { CHAT_VIEW_TYPE } from './constants' import { AppProvider } from './contexts/app-context' import { DarkModeProvider } from './contexts/dark-mode-context' +import { DatabaseProvider } from './contexts/database-context' import { DialogContainerProvider } from './contexts/dialog-container-context' import { LLMProvider } from './contexts/llm-context' import { RAGProvider } from './contexts/rag-context' @@ -68,6 +69,7 @@ export class ChatView extends ItemView { }, }, }) + const dbManager = await this.plugin.getDbManager() const ragEngine = await this.plugin.getRAGEngine() this.root.render( @@ -81,17 +83,19 @@ export class ChatView extends ItemView { > - - - - - - - - - + + + + + + + + + + + diff --git a/src/components/chat-view/Chat.tsx b/src/components/chat-view/Chat.tsx index 3d30625..74381f3 100644 --- a/src/components/chat-view/Chat.tsx +++ b/src/components/chat-view/Chat.tsx @@ -479,12 +479,6 @@ const Chat = forwardRef((props, ref) => { > - - - Create Template - - - diff --git a/src/components/chat-view/CreateTemplateDialog.tsx b/src/components/chat-view/CreateTemplateDialog.tsx index c43ae55..ea2f512 100644 --- a/src/components/chat-view/CreateTemplateDialog.tsx +++ b/src/components/chat-view/CreateTemplateDialog.tsx @@ -2,11 +2,14 @@ import { $generateNodesFromSerializedNodes } from '@lexical/clipboard' import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { InitialEditorStateType } from '@lexical/react/LexicalComposer' import * as Dialog from '@radix-ui/react-dialog' -import { $insertNodes, $parseSerializedNode, LexicalEditor } from 'lexical' +import { $insertNodes, LexicalEditor } from 'lexical' import { X } from 'lucide-react' +import { Notice } from 'obsidian' import { useRef, useState } from 'react' +import { useDatabase } from '../../contexts/database-context' import { useDialogContainer } from '../../contexts/dialog-container-context' +import { DuplicateTemplateException } from '../../database/exception' import LexicalContentEditable from './chat-input/LexicalContentEditable' @@ -17,10 +20,13 @@ import LexicalContentEditable from './chat-input/LexicalContentEditable' */ export default function CreateTemplateDialogContent({ selectedSerializedNodes, + onClose, }: { selectedSerializedNodes?: BaseSerializedNode[] | null + onClose: () => void }) { const container = useDialogContainer() + const { templateManager } = useDatabase() const [templateName, setTemplateName] = useState('') const editorRef = useRef(null) @@ -38,72 +44,57 @@ export default function CreateTemplateDialogContent({ }) } - const onSubmit = () => { - if (!editorRef.current) return - const serializedEditorState = editorRef.current.toJSON() - const nodes = serializedEditorState.editorState.root.children - // Testing inserting nodes - editorRef.current.update(() => { - const parsedNodes = nodes.map((node) => $parseSerializedNode(node)) - $insertNodes(parsedNodes) - }) + const onSubmit = async () => { + try { + if (!editorRef.current) return + const serializedEditorState = editorRef.current.toJSON() + const nodes = serializedEditorState.editorState.root.children + if (nodes.length === 0) return + if (templateName.trim().length === 0) { + new Notice('Please enter a name for your template') + return + } + + await templateManager.createTemplate({ + name: templateName, + data: { nodes }, + }) + new Notice(`Template created: ${templateName}`) + onClose() + } catch (error) { + if (error instanceof DuplicateTemplateException) { + new Notice('A template with this name already exists') + } else { + console.error(error) + new Notice('Failed to create template') + } + } } return ( - - + + Create Template - + Create a new template from the selected nodes - + Name setTemplateName(e.target.value)} - style={{ - flex: 1, + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + e.preventDefault() + onSubmit() + } }} + className="smtcmp-tailwind flex-1" /> @@ -116,26 +107,11 @@ export default function CreateTemplateDialogContent({ /> - + Create Template - { - e.currentTarget.style.opacity = '1' - }} - onMouseLeave={(e) => { - e.currentTarget.style.opacity = '0.7' - }} - asChild - > + diff --git a/src/components/chat-view/chat-input/LexicalContentEditable.tsx b/src/components/chat-view/chat-input/LexicalContentEditable.tsx index 5fb42b0..b19400f 100644 --- a/src/components/chat-view/chat-input/LexicalContentEditable.tsx +++ b/src/components/chat-view/chat-input/LexicalContentEditable.tsx @@ -25,7 +25,8 @@ import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin' import OnMutationPlugin, { NodeMutations, } from './plugins/on-mutation/OnMutationPlugin' -import TemplatePopoverPlugin from './plugins/template-popover/TemplatePopoverPlugin' +import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin' +import TemplatePlugin from './plugins/template/TemplatePlugin' export type LexicalContentEditableProps = { editorRef: RefObject @@ -136,8 +137,9 @@ export default function LexicalContentEditable({ + {plugins?.templatePopover && ( - diff --git a/src/components/chat-view/chat-input/plugins/template-popover/TemplatePopoverPlugin.tsx b/src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx similarity index 65% rename from src/components/chat-view/chat-input/plugins/template-popover/TemplatePopoverPlugin.tsx rename to src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx index 29bf1b2..389d0dd 100644 --- a/src/components/chat-view/chat-input/plugins/template-popover/TemplatePopoverPlugin.tsx +++ b/src/components/chat-view/chat-input/plugins/template/CreateTemplatePopoverPlugin.tsx @@ -3,11 +3,11 @@ import { BaseSerializedNode } from '@lexical/clipboard/clipboard' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import * as Dialog from '@radix-ui/react-dialog' import { $getSelection } from 'lexical' -import { useCallback, useEffect, useState } from 'react' +import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react' import CreateTemplateDialogContent from '../../../CreateTemplateDialog' -export default function TemplatePopoverPlugin({ +export default function CreateTemplatePopoverPlugin({ anchorElement, contentEditableElement, }: { @@ -15,11 +15,13 @@ export default function TemplatePopoverPlugin({ contentEditableElement: HTMLElement | null }): JSX.Element | null { const [editor] = useLexicalComposerContext() - const [position, setPosition] = useState<{ - top: number - left: number - } | null>(null) - const [isOpen, setIsOpen] = useState(false) + + const [popoverStyle, setPopoverStyle] = useState(null) + const [popoverWidth, setPopoverWidth] = useState(0) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const [isDialogOpen, setIsDialogOpen] = useState(false) + + const popoverRef = useRef(null) const getSelectedSerializedNodes = useCallback((): | BaseSerializedNode[] @@ -36,33 +38,50 @@ export default function TemplatePopoverPlugin({ }, [editor]) const updatePopoverPosition = useCallback(() => { + if (popoverRef.current) { + setPopoverWidth(popoverRef.current.offsetWidth) + } + if (!anchorElement || !contentEditableElement) return const nativeSelection = document.getSelection() const range = nativeSelection?.getRangeAt(0) if (!range || range.collapsed) { - setIsOpen(false) + setIsPopoverOpen(false) return } if (!contentEditableElement.contains(range.commonAncestorContainer)) { - setIsOpen(false) + setIsPopoverOpen(false) return } const rects = Array.from(range.getClientRects()) - // FIXME: Implement better positioning logic - // set position relative to anchorElement + if (rects.length === 0) { + setIsPopoverOpen(false) + return + } const anchorRect = anchorElement.getBoundingClientRect() - setPosition({ - top: rects[rects.length - 1].bottom - anchorRect.top, - left: rects[rects.length - 1].right - anchorRect.left, + const idealLeft = rects[rects.length - 1].right - anchorRect.left + const paddingX = 8 + const paddingY = 4 + const minLeft = popoverWidth + paddingX + const finalLeft = Math.max(minLeft, idealLeft) + setPopoverStyle({ + top: rects[rects.length - 1].bottom - anchorRect.top + paddingY, + left: finalLeft, + transform: 'translate(-100%, 0)', }) const selectedNodes = getSelectedSerializedNodes() if (!selectedNodes) { - setIsOpen(false) + setIsPopoverOpen(false) return } - setIsOpen(true) - }, [anchorElement, contentEditableElement, getSelectedSerializedNodes]) + setIsPopoverOpen(true) + }, [ + anchorElement, + contentEditableElement, + getSelectedSerializedNodes, + popoverWidth, + ]) useEffect(() => { const handleSelectionChange = () => { @@ -95,14 +114,18 @@ export default function TemplatePopoverPlugin({ }, [editor, updatePopoverPosition]) return ( - + - {isOpen ? ( + {isPopoverOpen ? ( Create template @@ -111,6 +134,7 @@ export default function TemplatePopoverPlugin({ setIsDialogOpen(false)} /> ) diff --git a/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx b/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx new file mode 100644 index 0000000..14664d8 --- /dev/null +++ b/src/components/chat-view/chat-input/plugins/template/TemplatePlugin.tsx @@ -0,0 +1,179 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import clsx from 'clsx' +import { + $parseSerializedNode, + COMMAND_PRIORITY_NORMAL, + TextNode, +} from 'lexical' +import { Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' + +import { useDatabase } from '../../../../../contexts/database-context' +import { SelectTemplate } from '../../../../../database/schema' +import { MenuOption } from '../shared/LexicalMenu' +import { + LexicalTypeaheadMenuPlugin, + useBasicTypeaheadTriggerMatch, +} from '../typeahead-menu/LexicalTypeaheadMenuPlugin' + +class TemplateTypeaheadOption extends MenuOption { + name: string + template: SelectTemplate + + constructor(name: string, template: SelectTemplate) { + super(name) + this.name = name + this.template = template + } +} + +function TemplateMenuItem({ + index, + isSelected, + onClick, + onDelete, + onMouseEnter, + option, +}: { + index: number + isSelected: boolean + onClick: () => void + onDelete: () => void + onMouseEnter: () => void + option: TemplateTypeaheadOption +}) { + return ( + option.setRefElement(el)} + role="option" + aria-selected={isSelected} + id={`typeahead-item-${index}`} + onMouseEnter={onMouseEnter} + onClick={onClick} + > + + {option.name} + { + evt.stopPropagation() + evt.preventDefault() + onDelete() + }} + className="smtcmp-template-menu-item-delete" + > + + + + + ) +} + +export default function TemplatePlugin() { + const [editor] = useLexicalComposerContext() + const { templateManager } = useDatabase() + + const [queryString, setQueryString] = useState(null) + const [searchResults, setSearchResults] = useState([]) + + useEffect(() => { + if (queryString == null) return + templateManager.searchTemplates(queryString).then(setSearchResults) + }, [queryString, templateManager]) + + const options = useMemo( + () => + searchResults.map( + (result) => new TemplateTypeaheadOption(result.name, result), + ), + [searchResults], + ) + + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + }) + + const onSelectOption = useCallback( + ( + selectedOption: TemplateTypeaheadOption, + nodeToRemove: TextNode | null, + closeMenu: () => void, + ) => { + editor.update(() => { + const parsedNodes = selectedOption.template.data.nodes.map((node) => + $parseSerializedNode(node), + ) + if (nodeToRemove) { + const parent = nodeToRemove.getParentOrThrow() + parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes) + const lastNode = parsedNodes[parsedNodes.length - 1] + lastNode.selectEnd() + } + closeMenu() + }) + }, + [editor], + ) + + const handleDelete = useCallback( + async (option: TemplateTypeaheadOption) => { + await templateManager.deleteTemplate(option.template.id) + if (queryString !== null) { + const updatedResults = + await templateManager.searchTemplates(queryString) + setSearchResults(updatedResults) + } + }, + [templateManager, queryString], + ) + + return ( + + onQueryChange={setQueryString} + onSelectOption={onSelectOption} + triggerFn={checkForTriggerMatch} + options={options} + commandPriority={COMMAND_PRIORITY_NORMAL} + menuRenderFn={( + anchorElementRef, + { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, + ) => + anchorElementRef.current && searchResults.length + ? createPortal( + + + {options.map((option, i: number) => ( + { + setHighlightedIndex(i) + selectOptionAndCleanUp(option) + }} + onDelete={() => { + handleDelete(option) + }} + onMouseEnter={() => { + setHighlightedIndex(i) + }} + key={option.key} + option={option} + /> + ))} + + , + anchorElementRef.current, + ) + : null + } + /> + ) +} diff --git a/styles.css b/styles.css index 8191811..5fd54a9 100644 --- a/styles.css +++ b/styles.css @@ -1,3 +1,162 @@ +.smtcmp-tailwind { + /* Flexbox & Grid */ + &.flex { + display: flex; + } + &.flex-col { + flex-direction: column; + } + &.flex-row { + flex-direction: row; + } + &.flex-wrap { + flex-wrap: wrap; + } + &.flex-1 { + flex: 1 1 0; + } + &.items-center { + align-items: center; + } + &.items-start { + align-items: flex-start; + } + &.items-end { + align-items: flex-end; + } + &.justify-center { + justify-content: center; + } + &.justify-between { + justify-content: space-between; + } + &.justify-start { + justify-content: flex-start; + } + &.justify-end { + justify-content: flex-end; + } + &.gap-1 { + gap: var(--size-4-1); + } + &.gap-2 { + gap: var(--size-4-2); + } + &.gap-4 { + gap: var(--size-4-4); + } + + /* Spacing */ + &.p-1 { + padding: var(--size-4-1); + } + &.p-2 { + padding: var(--size-4-2); + } + &.p-4 { + padding: var(--size-4-4); + } + &.m-1 { + margin: var(--size-4-1); + } + &.m-2 { + margin: var(--size-4-2); + } + &.m-4 { + margin: var(--size-4-4); + } + + /* Width & Height */ + &.w-full { + width: 100%; + } + &.h-full { + height: 100%; + } + + /* Display */ + &.hidden { + display: none; + } + &.block { + display: block; + } + &.inline-block { + display: inline-block; + } + &.grid { + display: grid; + } + + /* Text */ + &.text-center { + text-align: center; + } + &.text-left { + text-align: left; + } + &.text-right { + text-align: right; + } + &.font-thin { + font-weight: var(--font-thin); + } + &.font-extralight { + font-weight: var(--font-extralight); + } + &.font-light { + font-weight: var(--font-light); + } + &.font-normal { + font-weight: var(--font-normal); + } + &.font-medium { + font-weight: var(--font-medium); + } + &.font-semibold { + font-weight: var(--font-semibold); + } + &.font-bold { + font-weight: var(--font-bold); + } + &.font-extrabold { + font-weight: var(--font-extrabold); + } + + /* Colors */ + &.text-muted { + color: var(--text-muted); + } + &.text-normal { + color: var(--text-normal); + } + &.text-faint { + color: var(--text-faint); + } + + /* Position */ + &.relative { + position: relative; + } + &.absolute { + position: absolute; + } + &.fixed { + position: fixed; + } + + /* Overflow */ + &.overflow-hidden { + overflow: hidden; + } + &.overflow-auto { + overflow: auto; + } + &.overflow-scroll { + overflow: scroll; + } +} + .smtcmp-chat-header { display: flex; justify-content: space-between; @@ -758,3 +917,66 @@ button.smtcmp-chat-input-model-select { resize: none; } } + +.smtcmp-dialog-content { + position: fixed; + left: 50%; + top: 50%; + z-index: 50; + display: grid; + width: 100%; + max-width: 32rem; + transform: translate(-50%, -50%); + gap: var(--size-4-4); + border: var(--border-width) solid var(--background-modifier-border); + background-color: var(--background-secondary); + padding: var(--size-4-6); + transition-duration: 200ms; + border-radius: var(--radius-m); + + .smtcmp-dialog-title { + font-size: var(--font-ui-larger); + font-weight: var(--font-semibold); + line-height: var(--line-height-tight); + margin: 0; + } + + .smtcmp-dialog-description { + font-size: var(--font-ui-small); + color: var(--text-muted); + margin: 0; + } + + .smtcmp-dialog-close { + position: absolute; + right: var(--size-4-4); + top: var(--size-4-4); + cursor: var(--cursor); + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + } +} + +.smtcmp-template-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--size-4-1); + width: 100%; + + .smtcmp-template-menu-item-delete { + display: flex; + align-items: center; + padding: var(--size-4-1); + opacity: 0.7; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + } +}