diff --git a/.changeset/great-seals-thank.md b/.changeset/great-seals-thank.md new file mode 100644 index 000000000..34e7fea25 --- /dev/null +++ b/.changeset/great-seals-thank.md @@ -0,0 +1,5 @@ +--- +'mexit-webapp': patch +--- + +AI-Powered Selection Actions diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 9b70c8a71..3daafde8e 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -15,8 +15,8 @@ "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", - "@floating-ui/react": "^0.18.0", - "@floating-ui/react-dom-interactions": "^0.9.3", + "@floating-ui/react": "^0.22.2", + "@floating-ui/react-dom-interactions": "^0.13.3", "@iconify-icons/ri": "^1.1.1", "@iconify/icons-bx": "^1.1.0", "@iconify/icons-codicon": "^1.1.15", diff --git a/apps/webapp/src/Components/AIPop/AIHistory.tsx b/apps/webapp/src/Components/AIPop/AIHistory.tsx new file mode 100644 index 000000000..0b152c973 --- /dev/null +++ b/apps/webapp/src/Components/AIPop/AIHistory.tsx @@ -0,0 +1,25 @@ +import { SupportedAIEventTypes, useHistoryStore } from '@mexit/core' + +import { StyledAIHistory, StyledAIHistoryContainer } from './styled' + +const DEFAULT_HISTORY_LENGTH = 20 + +const AIHistory = ({ onClick }) => { + const aiHistory = useHistoryStore((store) => store.ai) + + return ( + + {aiHistory?.slice(-DEFAULT_HISTORY_LENGTH)?.map((event, i) => { + const type = !event?.at(-1) ? undefined : event?.at(-1)?.type ?? SupportedAIEventTypes.PROMPT + + return ( + onClick(i)} type={type}> + + + ) + })} + + ) +} + +export default AIHistory diff --git a/apps/webapp/src/Components/AIPop/AIResults.tsx b/apps/webapp/src/Components/AIPop/AIResults.tsx new file mode 100644 index 000000000..a332ac4d6 --- /dev/null +++ b/apps/webapp/src/Components/AIPop/AIResults.tsx @@ -0,0 +1,17 @@ +import { AIResult, StyledAIResults } from './styled' + +type AIResultsProps = { + results: Array +} + +const AIResults: React.FC = ({ results }) => { + return ( + + {results?.map((result) => { + return {result} + })} + + ) +} + +export default AIResults diff --git a/apps/webapp/src/Components/AIPop/Floater.tsx b/apps/webapp/src/Components/AIPop/Floater.tsx new file mode 100644 index 000000000..e161b1b4d --- /dev/null +++ b/apps/webapp/src/Components/AIPop/Floater.tsx @@ -0,0 +1,116 @@ +import { useEffect, useRef } from 'react' +import { ErrorBoundary } from 'react-error-boundary' + +import { + arrow, + autoUpdate, + flip, + FloatingArrow, + FloatingFocusManager, + FloatingPortal, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole +} from '@floating-ui/react' +import { getSelectionBoundingClientRect } from '@udecode/plate' +import { useTheme } from 'styled-components' + +import { FloatingElementType, mog, useFloatingStore, useHistoryStore } from '@mexit/core' + +import { FloaterContainer } from './styled' +import AIBlockPopover from '.' + +const DefaultFloater = ({ onClose }) => { + const isOpen = useFloatingStore((store) => store.floatingElement === FloatingElementType.AI_POPOVER) + const setIsOpen = useFloatingStore((store) => store.setFloatingElement) + + const theme = useTheme() + const arrowRef = useRef(null) + + const { x, y, strategy, refs, context } = useFloating({ + open: isOpen, + onOpenChange: (isOpen) => { + const state = isOpen ? FloatingElementType.AI_POPOVER : null + + if (!state && onClose) { + mog('called') + onClose() + } + setIsOpen(state) + }, + middleware: [ + shift({ + crossAxis: true, + padding: 10 + }), + flip(), + arrow({ + element: arrowRef, + padding: 10 + }) + ], + whileElementsMounted: autoUpdate + }) + + const click = useClick(context) + const dismiss = useDismiss(context) + const role = useRole(context) + + const { getFloatingProps } = useInteractions([click, dismiss, role]) + + useEffect(() => { + const coords = getSelectionBoundingClientRect() + + refs.setPositionReference({ + getBoundingClientRect() { + return coords + } + }) + }, [isOpen]) + + return ( + + {isOpen && ( + + + + + + + )} + + ) +} + +const Floater = () => { + const clearAIEventsHistory = useHistoryStore((s) => s.clearAIHistory) + + return ( + <>}> + + + ) +} + +export default Floater diff --git a/apps/webapp/src/Components/AIPop/index.tsx b/apps/webapp/src/Components/AIPop/index.tsx new file mode 100644 index 000000000..75b90a82d --- /dev/null +++ b/apps/webapp/src/Components/AIPop/index.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useMemo } from 'react' + +import { + deserializeMd, + focusEditor, + getEndPoint, + getPlateEditorRef, + insertNodes, + usePlateEditorRef +} from '@udecode/plate' +import Highlighter from 'web-highlighter' + +import { IconButton } from '@workduck-io/mex-components' + +import { camelCase, generateTempId, SupportedAIEventTypes, useFloatingStore, useHistoryStore } from '@mexit/core' +import { AutoComplete, DefaultMIcons, Group } from '@mexit/shared' + +import { useAIOptions } from '../../Hooks/useAIOptions' +import { useCreateNewMenu } from '../../Hooks/useCreateNewMenu' +import Plateless from '../Editor/Plateless' + +import AIHistory from './AIHistory' +import { + AIContainerFooter, + AIContainerHeader, + AIContainerSection, + AIResponseContainer, + StyledAIContainer +} from './styled' + +const AIResponse = ({ aiResponse, index }) => { + const editor = usePlateEditorRef() + const selected = aiResponse?.at(index)?.at(0) + + if (selected) { + const deserialize = deserializeMd(editor, selected?.content) + + return ( + + + + ) + } + + return <> +} + +interface AIPreviewProps { + onInsert?: (content: string) => void +} + +const AIBlockPopover: React.FC = (props) => { + const aiEventsHistory = useHistoryStore((s) => s.ai) + const activeEventIndex = useHistoryStore((s) => s.activeEventIndex) + const setActiveEventIndex = useHistoryStore((s) => s.setActiveEventIndex) + const clearAIResponses = useHistoryStore((s) => s.clearAIResponses) + const setFloatingElement = useFloatingStore((s) => s.setFloatingElement) + + const { performAIAction } = useAIOptions() + const { getAIMenuItems } = useCreateNewMenu() + + const defaultItems = useMemo(() => { + return getAIMenuItems() + }, []) + + const insertContent = (content: string, replace = true) => { + if (!content) return + + const editor = getPlateEditorRef() + const deserialize = deserializeMd(editor, content)?.map((node) => ({ + ...node, + id: generateTempId() + })) + + if (Array.isArray(deserialize) && deserialize.length > 0) { + const at = replace ? editor.selection : getEndPoint(editor, editor.selection) + + insertNodes(editor, deserialize, { + at, + select: true + }) + + try { + focusEditor(editor) + } catch (err) { + console.error('Unable to focus editor', err) + } + + setFloatingElement(undefined) + } + } + + useEffect(() => { + return () => { + const state = useFloatingStore.getState().state?.AI_POPOVER + if (state?.range) { + const highlight = new Highlighter() + highlight.removeAll() + } + } + }, []) + + const handleOnEnter = async (value: string) => { + try { + await performAIAction(SupportedAIEventTypes.PROMPT, value) + } catch (err) { + console.error('Unable generate prompt result', err) + } + } + + const userQuery = aiEventsHistory?.at(activeEventIndex)?.at(-1) + const defaultValue = + !userQuery?.type || userQuery?.type === SupportedAIEventTypes.PROMPT + ? userQuery?.content + : camelCase(userQuery?.type) + + const disableMenu = useFloatingStore.getState().state?.AI_POPOVER?.disableMenu + + return ( + + + + + + + + + + setActiveEventIndex(index)} /> + + { + const content = aiEventsHistory?.at(activeEventIndex)?.at(0)?.content + insertContent(content) + }} + size={12} + icon={DefaultMIcons.INSERT.value} + /> + { + const content = aiEventsHistory?.at(activeEventIndex)?.at(0)?.content + insertContent(content, false) + }} + /> + + + + ) +} + +export default AIBlockPopover diff --git a/apps/webapp/src/Components/AIPop/styled.tsx b/apps/webapp/src/Components/AIPop/styled.tsx new file mode 100644 index 000000000..edee5e1dd --- /dev/null +++ b/apps/webapp/src/Components/AIPop/styled.tsx @@ -0,0 +1,119 @@ +import styled, { keyframes } from 'styled-components' + +import { SupportedAIEventTypes } from '@mexit/core' +import { BodyFont } from '@mexit/shared' + +const getEventColor = (type: SupportedAIEventTypes | undefined, saturation = 100, lightness = 75) => { + if (!type) return `hsl(-210, 100%, 75%)` + + let hash = 0 + for (let i = 0; i < type.length; i++) { + hash = type.charCodeAt(i) + ((hash << 7) - hash) + hash = hash & hash + } + + return `hsl(${hash % 360}, ${saturation}%, ${lightness}%)` +} + +const float = keyframes` + 0% { + opacity: 0.4; + transform: scale(0.9); + transform: translateY(-0.25rem); + } + 70% { + transform: scale(1.005) + + } + + 100% { + opacity: 1; + transform: translateY(0rem); + transform: scale(1); + } +` + +export const AIResult = styled.div` + font-weight: bold; + line-height: 1.25; + color: ${({ theme }) => theme.tokens.text.default}; +` + +export const StyledAIResults = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.small}; +` + +export const GenerateResultContainer = styled.div`` + +export const AIContainerHeader = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing.small}; + border-radius: ${({ theme }) => theme.borderRadius.small}; +` + +export const AIResponseContainer = styled.div` + ${BodyFont} + max-height: 16rem; +` + +export const AIContainerSection = styled.section` + flex: 1; + overflow: hidden auto; +` +export const AIContainerFooter = styled.footer` + padding: ${({ theme }) => theme.spacing.small}; + display: flex; + gap: ${({ theme }) => theme.spacing.small}; + align-items: center; +` + +export const StyledAIHistoryContainer = styled.div` + display: flex; + align-items: center; + flex: 1; +` + +export const StyledAIHistory = styled.span<{ type: SupportedAIEventTypes }>` + :hover { + cursor: pointer; + background: ${({ theme }) => theme.tokens.surfaces.s[4]}; + box-shadow: ${({ theme }) => theme.tokens.shadow.medium}; + } + + padding: ${({ theme }) => theme.spacing.tiny}; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: ${({ theme }) => theme.borderRadius.small}; + + span { + padding: ${({ theme }) => theme.spacing.tiny}; + border-radius: ${({ theme }) => theme.borderRadius.small}; + background-color: ${({ type }) => getEventColor(type)}; + } +` + +export const FloaterContainer = styled.div` + border-radius: ${({ theme }) => theme.borderRadius.large}; + background-color: rgba(${({ theme }) => theme.rgbTokens.surfaces.modal}, 0.5); + box-shadow: inset ${({ theme }) => theme.tokens.shadow.medium}; + backdrop-filter: blur(2rem); + transform-origin: top; + z-index: 11; + border: 1px solid ${({ theme }) => theme.tokens.surfaces.s[3]}; + animation: ${float} 150ms ease-out; +` + +export const StyledAIContainer = styled.div` + width: 28rem; + height: 24rem; + max-width: 28rem; + max-height: 24rem; + display: flex; + flex-direction: column; + overflow: hidden; +` diff --git a/apps/webapp/src/Components/Editor/BalloonToolbar/BallonOptionsUnwrapper.tsx b/apps/webapp/src/Components/Editor/BalloonToolbar/BallonOptionsUnwrapper.tsx new file mode 100644 index 000000000..ed4aadda9 --- /dev/null +++ b/apps/webapp/src/Components/Editor/BalloonToolbar/BallonOptionsUnwrapper.tsx @@ -0,0 +1,117 @@ +import React, { useRef } from 'react' + +import { getPreventDefaultHandler } from '@udecode/plate' +import styled, { css } from 'styled-components' + +import { MIcon } from '@mexit/core' +import { fadeIn, Tooltip } from '@mexit/shared' + +type BallonOptionsUnwrapperProps = { + id: string + icon: MIcon + active: string + color?: string + onClick: (id: string) => void + children: React.ReactNode[] +} + +const BallonOptionContainer = styled.div<{ length?: number; active?: boolean }>` + display: flex; + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.spacing.tiny}; + border-radius: ${({ theme }) => theme.borderRadius.small}; + transition: width 0.25s ease, background 0.25s ease, transform 0.25s ease; + + ${({ active, length, theme }) => + active + ? css` + width: calc(${length * 2}rem + 2rem); + background: ${theme.tokens.surfaces.s[3]}; + transform: scale(1.1) translateY(-0.5rem); + transform-origin: 100% 0; + box-shadow: ${({ theme }) => theme.tokens.shadow.medium}; + margin: 0 ${theme.spacing.small}; + ` + : css` + width: 2rem; + transform: scale(1) translateY(0); + box-shadow: none; + background: none; + + :hover { + background: ${theme.tokens.surfaces.s[3]}; + } + `} + + cursor: pointer; + gap: ${({ theme }) => theme.spacing.small}; +` + +const StyledOption = styled.span<{ index?: number; active?: boolean; isMiddleActive?: boolean }>` + ${({ isMiddleActive }) => + isMiddleActive && + css` + pointer-events: none; + `} + + ${({ active }) => + active + ? css` + display: inline-block; + ` + : css` + display: none; + `} /* animation: ${fadeIn} 0.5s ease-in; */ + /* animation-fill-mode: backwards; */ + /* animation-delay: ${(props) => props.index * 0.05}s; */ +` + +const BallonOptionsUnwrapper: React.FC = ({ + id, + icon, + children, + active, + color, + onClick +}) => { + const isActive = active === id + const ref = useRef(null) + + const handleOnClick = (e) => { + e.preventDefault() + e.stopPropagation() + + onClick(id) + } + + return ( + + + {React.Children.map(children, (child, i) => { + const isMiddleElement = i === Math.floor(children.length / 2) + + return ( + + {child} + + ) + })} + + + ) +} + +export default BallonOptionsUnwrapper diff --git a/apps/webapp/src/Components/Editor/BalloonToolbar/BalloonToolbar.tsx b/apps/webapp/src/Components/Editor/BalloonToolbar/BalloonToolbar.tsx index b1765b139..2c70e65aa 100644 --- a/apps/webapp/src/Components/Editor/BalloonToolbar/BalloonToolbar.tsx +++ b/apps/webapp/src/Components/Editor/BalloonToolbar/BalloonToolbar.tsx @@ -3,33 +3,20 @@ import React from 'react' import { withPlateEventProvider } from '@udecode/plate-core' import { PortalBody } from '@udecode/plate-styled-components' -import { BalloonToolbarBase, BalloonToolbarProps, getBalloonToolbarStyles, useFloatingToolbar } from '@mexit/shared' +import { BalloonToolbarBase, BalloonToolbarProps, useFloatingToolbar } from '@mexit/shared' export const BalloonToolbar = withPlateEventProvider((props: BalloonToolbarProps) => { - const { children, theme = 'dark', arrow = false, portalElement, floatingOptions } = props + const { children, arrow = true, portalElement, floatingOptions } = props - const { floating, style, placement, open } = useFloatingToolbar({ + const { floating, style, open } = useFloatingToolbar({ floatingOptions }) - const styles = getBalloonToolbarStyles({ - placement, - theme, - arrow, - ...props - }) - if (!open) return null return ( - + {children} diff --git a/apps/webapp/src/Components/Editor/BalloonToolbar/EditorBalloonToolbar.tsx b/apps/webapp/src/Components/Editor/BalloonToolbar/EditorBalloonToolbar.tsx index bd556060a..880a45161 100644 --- a/apps/webapp/src/Components/Editor/BalloonToolbar/EditorBalloonToolbar.tsx +++ b/apps/webapp/src/Components/Editor/BalloonToolbar/EditorBalloonToolbar.tsx @@ -3,19 +3,17 @@ import React from 'react' import AlignLeftIcon from '@iconify/icons-bx/bx-align-left' import AlignCenterIcon from '@iconify/icons-bx/bx-align-middle' import AlignRightIcon from '@iconify/icons-bx/bx-align-right' -import addLine from '@iconify/icons-ri/add-line' import boldIcon from '@iconify/icons-ri/bold' import codeLine from '@iconify/icons-ri/code-line' import doubleQuotesL from '@iconify/icons-ri/double-quotes-l' -import fileAddLine from '@iconify/icons-ri/file-add-line' import h1 from '@iconify/icons-ri/h-1' import h2 from '@iconify/icons-ri/h-2' import h3 from '@iconify/icons-ri/h-3' import italicIcon from '@iconify/icons-ri/italic' import listOrdered from '@iconify/icons-ri/list-ordered' import listUnordered from '@iconify/icons-ri/list-unordered' -import strikeThrough from '@iconify/icons-ri/strikethrough' -import taskLine from '@iconify/icons-ri/task-line' +import strikeThrough from '@iconify/icons-ri/strikethrough-2' +import underlineIcon from '@iconify/icons-ri/underline' import { Icon } from '@iconify/react' import { AlignToolbarButton, @@ -32,26 +30,65 @@ import { MARK_CODE, MARK_ITALIC, MARK_STRIKETHROUGH, + MARK_UNDERLINE, MarkToolbarButton, + ToolbarButton, ToolbarButtonProps, usePlateEditorRef } from '@udecode/plate' +import { useTheme } from 'styled-components' +import Highlighter from 'web-highlighter' -import { ButtonSeparator, useBalloonToolbarStore } from '@mexit/shared' +import { FloatingElementType, useFloatingStore, useHistoryStore } from '@mexit/core' +import { ButtonSeparator, DefaultMIcons, getMIcon, IconDisplay, useBalloonToolbarStore } from '@mexit/shared' + +import useUpdateBlock from '../../../Editor/Hooks/useUpdateBlock' import { SelectionToNode, SelectionToNodeInput } from './components/SelectionToNode' import { SelectionToSnippet, SelectionToSnippetInput } from './components/SelectionToSnippet' import { SelectionToTask } from './components/SelectionToTask' +import BallonOptionsUnwrapper from './BallonOptionsUnwrapper' import { BalloonToolbar } from './BalloonToolbar' const BallonMarkToolbarButtons = () => { - const toolbarState = useBalloonToolbarStore((s) => s.toolbarState) - // const setToolbarState = useBalloonToolbarStore((s) => s.setToolbarState) + const [isOptionOpen, setIsOptionOpen] = React.useState(null) + const theme = useTheme() const editor = usePlateEditorRef() + const { getSelectionInMarkdown } = useUpdateBlock() + const addAIEvent = useHistoryStore((store) => store.addInitialEvent) + const toolbarState = useBalloonToolbarStore((s) => s.toolbarState) + const setFloatingElement = useFloatingStore((s) => s.setFloatingElement) + + const handleOpenOption = (id: string) => { + setIsOptionOpen(id) + } + + const handleOpenAIPreview = (event) => { + event.preventDefault() + + const content = getSelectionInMarkdown() + const selection = window.getSelection() + + if (content) { + addAIEvent({ role: 'assistant', content }) + } + + const highlight = new Highlighter({ + style: { + className: 'highlight' + } + }) + + const range = selection.getRangeAt(0) + highlight.fromRange(range) + + setFloatingElement(FloatingElementType.AI_POPOVER, { + range + }) + } const arrow = false - const theme = 'dark' const top = 'top' as const const floatingOptions = { @@ -60,109 +97,159 @@ const BallonMarkToolbarButtons = () => { const tooltip = { arrow: true, - delay: 0, duration: [200, 0], + delay: 500, theme: 'mex', hideOnClick: false, - offset: [0, 17], + offset: [0, 10], placement: top } as any return ( - + { { normal: ( <> - } - tooltip={{ content: 'Heading 1', ...tooltip }} + } + onMouseDown={handleOpenAIPreview} /> + + + } + tooltip={{ content: 'Heading 1', ...tooltip }} + /> - } - tooltip={{ content: 'Heading 2', ...tooltip }} - /> + } + tooltip={{ content: 'Heading 2', ...tooltip }} + /> - } - tooltip={{ content: 'Heading 3', ...tooltip }} - /> + } + tooltip={{ content: 'Heading 3', ...tooltip }} + /> + - } - /> - } - /> - } - /> + + } + /> + } + /> + } + /> + - } - tooltip={{ content: 'Quote', ...tooltip }} - /> + + } + tooltip={{ content: 'Quote', ...tooltip }} + /> - } - tooltip={{ content: 'Bullet List', ...tooltip }} - /> + } + tooltip={{ content: 'Bullet List', ...tooltip }} + /> - } - tooltip={{ content: 'Ordered List', ...tooltip }} - /> + } + tooltip={{ content: 'Ordered List', ...tooltip }} + /> + - } - tooltip={{ content: 'Bold (⌘B)', ...tooltip }} - /> - } - /> - } - tooltip={{ content: 'Italic (⌘I)', ...tooltip }} - /> - } /> + + } + tooltip={{ content: 'Bold (⌘B)', ...tooltip }} + /> + } + tooltip={{ content: 'Strike through ⌘⇧X', ...tooltip }} + /> + } + tooltip={{ content: 'Italic (⌘I)', ...tooltip }} + /> + } + tooltip={{ content: 'Underline (⌘U)', ...tooltip }} + /> + } + /> + - } - tooltip={{ content: 'Convert Blocks to Task', ...tooltip }} - /> + + } + tooltip={{ content: 'Convert to Task', ...tooltip }} + /> - } - tooltip={{ content: 'Convert Blocks to New Node', ...tooltip }} - /> + } + tooltip={{ content: 'Convert to Note', ...tooltip }} + /> - } - tooltip={{ content: 'Convert Blocks to New Snippet', ...tooltip }} - /> + } + tooltip={{ content: 'Convert to Snippet', ...tooltip }} + /> + ), 'new-note': , diff --git a/apps/webapp/src/Components/Editor/BalloonToolbar/components/EventBasedToolbarButton/index.tsx b/apps/webapp/src/Components/Editor/BalloonToolbar/components/EventBasedToolbarButton/index.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/webapp/src/Components/Editor/BalloonToolbar/components/EventBasedToolbarButton/styled.tsx b/apps/webapp/src/Components/Editor/BalloonToolbar/components/EventBasedToolbarButton/styled.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/apps/webapp/src/Components/Editor/ContentEditor.tsx b/apps/webapp/src/Components/Editor/ContentEditor.tsx index 23e0d7749..d1daea970 100644 --- a/apps/webapp/src/Components/Editor/ContentEditor.tsx +++ b/apps/webapp/src/Components/Editor/ContentEditor.tsx @@ -6,7 +6,17 @@ import { focusEditor, getPlateEditorRef, selectEditor } from '@udecode/plate' import { tinykeys } from '@workduck-io/tinykeys' -import { getContent, useBlockStore, useContentStore, useDataStore, useEditorStore, useHelpStore,useLayoutStore, useModalStore } from '@mexit/core' +import { + getContent, + useBlockStore, + useContentStore, + useDataStore, + useEditorStore, + useFloatingStore, + useHelpStore, + useLayoutStore, + useModalStore +} from '@mexit/core' import { EditorWrapper, isOnEditableElement } from '@mexit/shared' import { useComboboxOpen } from '../../Editor/Hooks/useComboboxOpen' @@ -107,7 +117,12 @@ const ContentEditor = () => { }) }, Enter: (event) => { - if (!isOnEditableElement(event) && !useModalStore.getState().open && !useLayoutStore.getState().contextMenu) { + if ( + !isOnEditableElement(event) && + !useModalStore.getState().open && + !useLayoutStore.getState().contextMenu && + !useFloatingStore.getState().floatingElement + ) { event.preventDefault() const editorRef = getPlateEditorRef(nodeid) ?? getPlateEditorRef() focusEditor(editorRef) diff --git a/apps/webapp/src/Components/Editor/Plateless.tsx b/apps/webapp/src/Components/Editor/Plateless.tsx index 44e614701..1ba8b390e 100644 --- a/apps/webapp/src/Components/Editor/Plateless.tsx +++ b/apps/webapp/src/Components/Editor/Plateless.tsx @@ -51,8 +51,8 @@ const MultiLineElementsArray = [ ELEMENT_LIC ] as const -export type InlineElements = typeof InlineElementsArray[number] -export type MultiLineElements = typeof MultiLineElementsArray[number] +export type InlineElements = (typeof InlineElementsArray)[number] +export type MultiLineElements = (typeof MultiLineElementsArray)[number] interface ItemRenderProps { children: React.ReactNode @@ -262,7 +262,6 @@ interface RenderPlatelessProps { const RenderPlateless = React.memo( ({ content, typeMap, multiline = false }: RenderPlatelessProps) => { - // mog('Plateless', { content }) const childrenRender = content && content.map((node) => { @@ -284,6 +283,7 @@ const RenderPlateless = React.memo( return <>{childrenRender} } ) + RenderPlateless.displayName = 'RenderPlateless' /** diff --git a/apps/webapp/src/Components/Mentions/PermissionModal.tsx b/apps/webapp/src/Components/Mentions/PermissionModal.tsx index 83080fd57..05409ce2d 100644 --- a/apps/webapp/src/Components/Mentions/PermissionModal.tsx +++ b/apps/webapp/src/Components/Mentions/PermissionModal.tsx @@ -11,8 +11,10 @@ import { permissionOptions, useAuthStore, useEditorStore, -useMentionStore , userPreferenceStore as useUserPreferenceStore, useShareModalStore - } from '@mexit/core' + useMentionStore, + userPreferenceStore as useUserPreferenceStore, + useShareModalStore +} from '@mexit/core' import { Center, copyTextToClipboard, diff --git a/apps/webapp/src/Components/Mentions/styles.tsx b/apps/webapp/src/Components/Mentions/styles.tsx index 9c79c45de..c0a01d158 100644 --- a/apps/webapp/src/Components/Mentions/styles.tsx +++ b/apps/webapp/src/Components/Mentions/styles.tsx @@ -82,7 +82,7 @@ export const TableContainer = styled.section` display: block; max-height: 16rem; height: 16rem; - width: 40vw; + width: 100%; max-width: 660px; overflow: hidden auto; backdrop-filter: blur(8px); diff --git a/apps/webapp/src/Editor/Components/SlashCommands/useSlashCommandOnChange.ts b/apps/webapp/src/Editor/Components/SlashCommands/useSlashCommandOnChange.ts index 175104f3f..df5e12346 100644 --- a/apps/webapp/src/Editor/Components/SlashCommands/useSlashCommandOnChange.ts +++ b/apps/webapp/src/Editor/Components/SlashCommands/useSlashCommandOnChange.ts @@ -9,7 +9,7 @@ import { TElement } from '@udecode/plate' -import { isElder, useComboboxStore } from '@mexit/core' +import { camelCase, FloatingElementType, isElder, useComboboxStore, useFloatingStore } from '@mexit/core' import { useSnippets } from '../../../Hooks/useSnippets' import { IComboboxItem, SlashCommandConfig } from '../../Types/Combobox' @@ -17,12 +17,13 @@ import { IComboboxItem, SlashCommandConfig } from '../../Types/Combobox' export const useSlashCommandOnChange = (keys: { [type: string]: SlashCommandConfig }) => { const closeMenu = useComboboxStore((state) => state.closeMenu) const { getSnippetContent } = useSnippets() + const setFloatingElement = useFloatingStore((s) => s.setFloatingElement) return (editor: PlateEditor, item: IComboboxItem) => { const targetRange = useComboboxStore.getState().targetRange const commandKey = Object.keys(keys).filter((k) => keys[k].command === item.key)[0] - const commandConfig = keys[commandKey] + if (targetRange) { try { if (isElder(commandKey, 'snip')) { @@ -31,6 +32,18 @@ export const useSlashCommandOnChange = (keys: { [type: string]: SlashCommandConf select(editor, targetRange) insertNodes(editor, content) } + } else if (item.key === 'ai') { + const aiFloatingElement = FloatingElementType.AI_POPOVER + select(editor, targetRange) + deleteText(editor) + + setTimeout(() => { + setFloatingElement(aiFloatingElement, { + label: camelCase(aiFloatingElement), + type: aiFloatingElement, + disableMenu: true + }) + }, 1) } else if (item.key === 'table') { select(editor, targetRange) insertTable(editor, { rowCount: 3 }) @@ -60,6 +73,7 @@ export const useSlashCommandOnChange = (keys: { [type: string]: SlashCommandConf } catch (e) { console.error(e) } + return closeMenu() } diff --git a/apps/webapp/src/Editor/Hooks/useEditorConfig.ts b/apps/webapp/src/Editor/Hooks/useEditorConfig.ts index dd0745dda..9f408195b 100644 --- a/apps/webapp/src/Editor/Hooks/useEditorConfig.ts +++ b/apps/webapp/src/Editor/Hooks/useEditorConfig.ts @@ -36,7 +36,7 @@ import { useViewStore } from '../../Stores/useViewStore' import { QuickLinkComboboxItem } from '../Components/QuickLink/QuickLinkComboboxItem' import { SlashComboboxItem } from '../Components/SlashCommands/SlashComboboxItem' import { TagComboboxItem } from '../Components/Tags/TagComboboxItem' -import { CategoryType, QuickLinkType } from '../constants' +import { AI_RENDER_TYPE, CategoryType, QuickLinkType } from '../constants' import { PluginOptionType } from '../Plugins' import { ComboboxKey } from '../Types/Combobox' import { ComboboxConfig, ComboConfigData } from '../Types/MultiCombobox' @@ -253,6 +253,10 @@ export const useEditorPluginConfig = (editorId: string, options?: PluginOptionTy url: 'https://example.com/' } }, + ai: { + slateElementType: AI_RENDER_TYPE, + command: 'ai' + }, task: { slateElementType: ELEMENT_TODO_LI, command: 'task' diff --git a/apps/webapp/src/Editor/Hooks/useUpdateBlock.ts b/apps/webapp/src/Editor/Hooks/useUpdateBlock.ts index 46d1a4b16..d883c8f30 100644 --- a/apps/webapp/src/Editor/Hooks/useUpdateBlock.ts +++ b/apps/webapp/src/Editor/Hooks/useUpdateBlock.ts @@ -1,9 +1,10 @@ import { findNodePath, getPlateEditorRef, setNodes } from '@udecode/plate' -import { useContentStore } from '@mexit/core' +import { ELEMENT_PARAGRAPH, useContentStore } from '@mexit/core' import { useBufferStore } from '../../Hooks/useEditorBuffer' import { useUpdater } from '../../Hooks/useUpdater' +import parseToMarkdown from '../utils' type BlockDataType = Record @@ -68,6 +69,16 @@ const useUpdateBlock = () => { } } + const getSelectionInMarkdown = () => { + const editor = getPlateEditorRef() + if (!editor.selection) return + + const nodeFragments = editor.getFragment() + const selectedText = parseToMarkdown({ children: nodeFragments, type: ELEMENT_PARAGRAPH })?.trim() + + return selectedText + } + const addBlockInContent = (noteId: string, block: BlockDataType) => { const bufferContent = useBufferStore.getState().getBuffer(noteId) const existingContent = useContentStore.getState().getContent(noteId)?.content @@ -83,7 +94,8 @@ const useUpdateBlock = () => { return { insertInEditor, setInfoOfBlockInContent, - addBlockInContent + addBlockInContent, + getSelectionInMarkdown } } diff --git a/apps/webapp/src/Editor/MexEditor.tsx b/apps/webapp/src/Editor/MexEditor.tsx index c24d39b35..0baebb8f0 100644 --- a/apps/webapp/src/Editor/MexEditor.tsx +++ b/apps/webapp/src/Editor/MexEditor.tsx @@ -7,6 +7,7 @@ import { EditableProps } from 'slate-react/dist/components/editable' import { useBlockHighlightStore, useMultipleEditors } from '@mexit/core' +import Floater from '../Components/AIPop/Floater' import { useGlobalListener } from '../Hooks/useGlobalListener' import { useFocusBlock } from '../Stores/useFocusBlock' @@ -51,7 +52,6 @@ export const MexEditorBase = (props: MexEditorProps) => { const [content, setContent] = useState([]) const setInternalMetadata = useMexEditorStore((store) => store.setInternalMetadata) const isEmpty = useMultipleEditors((store) => store.isEmpty) - const { selectBlock } = useFocusBlock() const clearHighlights = useBlockHighlightStore((store) => store.clearAllHighlightedBlockIds) const highlightedBlockIds = useBlockHighlightStore((store) => store.highlighted.editor) @@ -112,6 +112,7 @@ export const MexEditorBase = (props: MexEditorProps) => { > {props.options?.withBalloonToolbar && props.BalloonMarkToolbarButtons} {isEmpty && } + {props.options?.withGlobalListener !== false && } {props.debug &&
{JSON.stringify(content, null, 2)}
} diff --git a/apps/webapp/src/Editor/Plugins/parseTwitterUrl.ts b/apps/webapp/src/Editor/Plugins/parseTwitterUrl.ts index 2ece3f6e9..065422504 100644 --- a/apps/webapp/src/Editor/Plugins/parseTwitterUrl.ts +++ b/apps/webapp/src/Editor/Plugins/parseTwitterUrl.ts @@ -1,12 +1,9 @@ import { MediaPlugin } from '@udecode/plate' import { getPluginOptions, PlateEditor, RenderFunction, Value } from '@udecode/plate-core' -import { mog } from '@mexit/core' - const twitterRegex = /^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(?\d+)/ export const parseTwitterUrl = (url: string): EmbedUrlData | undefined => { - mog('URL IS', { url }) if (url?.match(twitterRegex)) { return { provider: 'twitter', diff --git a/apps/webapp/src/Editor/constants.ts b/apps/webapp/src/Editor/constants.ts index d4af8c9eb..3e79dc694 100644 --- a/apps/webapp/src/Editor/constants.ts +++ b/apps/webapp/src/Editor/constants.ts @@ -12,6 +12,8 @@ export enum CategoryType { tag = 'Tags' } +export const AI_RENDER_TYPE = 'ai-render-type' + export enum QuickLinkType { backlink = 'Backlinks', publicNotes = 'Public Notes', diff --git a/apps/webapp/src/Hooks/useAIOptions.ts b/apps/webapp/src/Hooks/useAIOptions.ts new file mode 100644 index 000000000..558cee7a8 --- /dev/null +++ b/apps/webapp/src/Hooks/useAIOptions.ts @@ -0,0 +1,39 @@ +import { toast } from 'react-hot-toast' + +import { AIEvent, API, SupportedAIEventTypes, useHistoryStore } from '@mexit/core' + +export const useAIOptions = () => { + const addInAIEventsHistory = useHistoryStore((store) => store.addInAIHistory) + + const performAIAction = async (type: SupportedAIEventTypes, content?: string): Promise => { + const aiEventsHistory = useHistoryStore.getState().ai + const userQuery: AIEvent = { + role: 'user', + type + } + + if (content) { + userQuery.content = content + } + + const reqData = { + context: [...aiEventsHistory.flat().filter((item) => item), userQuery] + } + + try { + const assistantResponse = await API.ai.perform(reqData) + + if (assistantResponse?.content) { + addInAIEventsHistory(userQuery, assistantResponse) + } + } catch (err) { + // * Write cute error message + toast('Something went wrong!') + console.error('Unable to perform AI action', err) + } + } + + return { + performAIAction + } +} diff --git a/apps/webapp/src/Hooks/useCreateNewMenu.tsx b/apps/webapp/src/Hooks/useCreateNewMenu.tsx index 06f7d4bf7..23caf95eb 100644 --- a/apps/webapp/src/Hooks/useCreateNewMenu.tsx +++ b/apps/webapp/src/Hooks/useCreateNewMenu.tsx @@ -11,6 +11,7 @@ import { isReservedNamespace, MIcon, ModalsType, + SupportedAIEventTypes, useDataStore, useLayoutStore, useMetadataStore, @@ -25,8 +26,10 @@ import { useDeleteStore } from '../Components/Refactor/DeleteModal' import { doesLinkRemain } from '../Components/Refactor/doesLinkRemain' import { useTaskViewModalStore } from '../Components/TaskViewModal' import { useBlockMenu } from '../Editor/Components/useBlockMenu' +import useUpdateBlock from '../Editor/Hooks/useUpdateBlock' import { useViewStore } from '../Stores/useViewStore' +import { useAIOptions } from './useAIOptions' import { useCreateNewNote } from './useCreateNewNote' import { useNamespaces } from './useNamespaces' import { useNavigation } from './useNavigation' @@ -73,9 +76,11 @@ export const useCreateNewMenu = () => { const deleteNamespace = useDataStore((store) => store.deleteNamespace) const { goTo } = useRouting() + const { getSelectionInMarkdown } = useUpdateBlock() const { push } = useNavigation() const { deleteView } = useViews() const { addSnippet } = useSnippets() + const { performAIAction } = useAIOptions() const { execRefactorAsync } = useRefactor() const { createNewNote } = useCreateNewNote() const blockMenuItems = useBlockMenu() @@ -325,6 +330,37 @@ export const useCreateNewMenu = () => { ] } + // * AI functions + const handleAIQuery = async (type: SupportedAIEventTypes, callback: any) => { + performAIAction(type).then((res) => { + callback(res) + }) + } + + const getAIMenuItems = () => { + return [ + getMenuItem( + 'Continue', + (c) => handleAIQuery(SupportedAIEventTypes.EXPAND, c), + false, + getMIcon('ICON', 'system-uicons:write') + ), + getMenuItem( + 'Explain', + (c) => handleAIQuery(SupportedAIEventTypes.EXPLAIN, c), + false, + getMIcon('ICON', 'ri:question-line') + ), + getMenuItem('Summarize', (c) => handleAIQuery(SupportedAIEventTypes.SUMMARIZE, c), false, DefaultMIcons.AI), + getMenuItem( + 'Actionable', + (c) => handleAIQuery(SupportedAIEventTypes.ACTIONABLE, c), + false, + getMIcon('ICON', 'ic:round-view-list') + ) + ] + } + return { getCreateNewMenuItems, getSnippetsMenuItems, @@ -332,6 +368,7 @@ export const useCreateNewMenu = () => { getBlockMenuItems, getTreeMenuItems, getViewMenuItems, + getAIMenuItems, // * Handlers handleCreateSnippet, diff --git a/apps/webapp/src/Hooks/useUpdater.ts b/apps/webapp/src/Hooks/useUpdater.ts index d61421efc..0114a96c8 100644 --- a/apps/webapp/src/Hooks/useUpdater.ts +++ b/apps/webapp/src/Hooks/useUpdater.ts @@ -47,7 +47,6 @@ export const useUpdater = () => { const todos = getTodosFromContent(content) updateNodeTodos(noteId, todos) - // console.log('updateFromContent', noteId, todos, content) await updateDocument({ id: noteId, contents: content, diff --git a/apps/webapp/src/Workers/controller.ts b/apps/webapp/src/Workers/controller.ts index 8338a9fa8..9da1789b5 100644 --- a/apps/webapp/src/Workers/controller.ts +++ b/apps/webapp/src/Workers/controller.ts @@ -62,7 +62,6 @@ export const runBatchWorker = async ( args: any[] | Record ) => { const res = await requestsWorker.runBatchWorker(requestType, batchSize, args) - mog('RUN_BATCH_WORKER', { res }) return res } diff --git a/libs/core/src/API/AI.ts b/libs/core/src/API/AI.ts new file mode 100644 index 000000000..325a738bf --- /dev/null +++ b/libs/core/src/API/AI.ts @@ -0,0 +1,17 @@ +import { type Options } from 'ky' + +import { type KYClient } from '@workduck-io/dwindle' + +import { AIEvent } from '../Types' +import { apiURLs } from '../Utils/routes' + +export class AiAPI { + private client: KYClient + constructor(client: KYClient) { + this.client = client + } + + async perform(data: any, options?: Options): Promise { + return await this.client.post(apiURLs.openAi.perform, data, options) + } +} diff --git a/libs/core/src/API/Base.ts b/libs/core/src/API/Base.ts index 6ab75f8a4..d729efed6 100644 --- a/libs/core/src/API/Base.ts +++ b/libs/core/src/API/Base.ts @@ -2,6 +2,7 @@ import { type KyInstance } from 'ky/distribution/types/ky' import { KYClient } from '@workduck-io/dwindle' +import { AiAPI } from './AI' import { BookmarkAPI } from './Bookmarks' import { CommentAPI } from './Comment' import { HighlightAPI } from './Highlight' @@ -30,6 +31,7 @@ class APIClass { public namespace: NamespaceAPI public view: ViewAPI public loch: LochAPI + public ai: AiAPI public link: LinkAPI public prompt: PromptAPI public reminder: ReminderAPI @@ -56,6 +58,7 @@ class APIClass { this.prompt = new PromptAPI(this.client) this.view = new ViewAPI(this.client) this.link = new LinkAPI(this.client) + this.ai = new AiAPI(this.client) this.reminder = new ReminderAPI(this.client) this.user = new UserAPI(this.client) this.highlight = new HighlightAPI(this.client) diff --git a/libs/core/src/Data/defaultCommands.ts b/libs/core/src/Data/defaultCommands.ts index 905240225..5ebc52830 100644 --- a/libs/core/src/Data/defaultCommands.ts +++ b/libs/core/src/Data/defaultCommands.ts @@ -2,6 +2,12 @@ import { CategoryType, SlashCommand } from '../Types/Editor' import { getMIcon } from '../Types/Store' export const defaultCommands: SlashCommand[] = [ + { + command: 'ai', + text: 'Start with AI', + icon: getMIcon('ICON', 'fluent:text-grammar-wand-24-filled'), + type: CategoryType.action + }, { command: 'task', text: 'Create a Task', icon: getMIcon('ICON', 'ri:task-line'), type: CategoryType.action }, { command: 'table', text: 'Insert Table', icon: getMIcon('ICON', 'ri:table-line'), type: CategoryType.action }, // { command: 'canvas', text: 'Insert Drawing canvas', icon: getMIcon('ICON', 'ri:markup-line'), type: CategoryType.action }, diff --git a/libs/core/src/Stores/floating.store.ts b/libs/core/src/Stores/floating.store.ts new file mode 100644 index 000000000..5e8c9b302 --- /dev/null +++ b/libs/core/src/Stores/floating.store.ts @@ -0,0 +1,27 @@ +import { StoreIdentifier } from '../Types/Store' +import { createStore } from '../Utils/storeCreator' + +export enum FloatingElementType { + BALLON_TOOLBAR = 'BALLON_TOOLBAR', + AI_POPOVER = 'AI_POPOVER' +} + +const floatingStoreConfig = (set, get) => ({ + floatingElement: undefined as FloatingElementType | undefined, + state: {} as Record, + updateFloatingElementState: (floatingElementId: string, state: Record) => { + const existingState = get().state + set({ state: { ...existingState, [floatingElementId]: { ...existingState[floatingElementId], ...state } } }) + }, + getFloatingElementState: (element: FloatingElementType) => get().state[element], + setFloatingElement: (element: FloatingElementType, state?: any) => { + if (state) { + set({ floatingElement: element, state: { ...get().state, [element]: state } }) + } else { + const { [element]: _, ...rest } = get().state + set({ floatingElement: element, state: rest }) + } + } +}) + +export const useFloatingStore = createStore(floatingStoreConfig, StoreIdentifier.FLOATING, false) diff --git a/libs/core/src/Stores/history.store.ts b/libs/core/src/Stores/history.store.ts index 0ca3c5433..c3e084999 100644 --- a/libs/core/src/Stores/history.store.ts +++ b/libs/core/src/Stores/history.store.ts @@ -1,3 +1,4 @@ +import { AIEvent, AIEventsHistory } from '../Types/History' import { StoreIdentifier } from '../Types/Store' import { createStore } from '../Utils/storeCreator' @@ -6,6 +7,35 @@ const MAX_HISTORY_SIZE = 25 export const historyStoreConfig = (set, get) => ({ stack: [] as Array, currentNodeIndex: -1, + ai: [] as AIEventsHistory, + activeEventIndex: -1, + setActiveEventIndex: (index: number) => set({ activeEventIndex: index }), + addInitialEvent: (event: AIEvent) => { + set({ ai: [[event, undefined]] }) + }, + addInAIHistory: (userQuery: AIEvent, assistantResponse: AIEvent) => { + const aiEventsHistory = get().ai as AIEventsHistory + const activeEventIndex = get().activeEventIndex + const lastEvent = aiEventsHistory.at(activeEventIndex)?.[0] + set({ + ai: [...aiEventsHistory.slice(0, activeEventIndex), [lastEvent, userQuery], [assistantResponse, undefined]], + activeEventIndex: -1 + }) + }, + clearAIResponses: () => { + const aiEventsHistory = get().ai as AIEventsHistory + if (aiEventsHistory?.length) { + const initialEvent = aiEventsHistory[0] + set({ ai: [[initialEvent[0], undefined]], activeEventIndex: -1 }) + } + }, + clearAIHistory: () => set({ ai: [], activeEventIndex: -1 }), + getLastEvent: (): string | undefined => { + const aiHistory = get().ai as AIEventsHistory + const lastEvent = aiHistory.at(-1)?.at(-1) + + return lastEvent?.content + }, /** * Push will remove all elements above the currentNodeIndex diff --git a/libs/core/src/Stores/index.ts b/libs/core/src/Stores/index.ts index d589977bf..fd367b60c 100644 --- a/libs/core/src/Stores/index.ts +++ b/libs/core/src/Stores/index.ts @@ -14,6 +14,7 @@ export * from './data.store' export * from './description.store' export * from './editor.store' export * from './editors.store' +export * from './floating.store' export * from './help.store' export * from './highlight.store' export * from './history.store' diff --git a/libs/core/src/Types/History.ts b/libs/core/src/Types/History.ts new file mode 100644 index 000000000..3ebb47976 --- /dev/null +++ b/libs/core/src/Types/History.ts @@ -0,0 +1,17 @@ +export enum SupportedAIEventTypes { + SUMMARIZE = 'SUMMARIZE', + EXPLAIN = 'EXPLAIN', + EXPAND = 'EXPAND', + ACTIONABLE = 'ACTIONABLE', + PROMPT = 'PROMPT' +} + +export interface AIEvent { + role: 'user' | 'assistant' + content?: string + type?: SupportedAIEventTypes +} + +export type AIEventHistory = [AIEvent, AIEvent] + +export type AIEventsHistory = Array diff --git a/libs/core/src/Types/List.ts b/libs/core/src/Types/List.ts index 514ab1b75..4180fd49e 100644 --- a/libs/core/src/Types/List.ts +++ b/libs/core/src/Types/List.ts @@ -14,6 +14,15 @@ export interface ListItemType { extras?: Partial } +export interface MenuListItemType { + id: string + label: string + disabled?: boolean + icon?: MIcon + onSelect: any + options?: Array +} + export interface ItemExtraType { nodeid: string blockid: string diff --git a/libs/core/src/Types/Store.ts b/libs/core/src/Types/Store.ts index 4a9e153cc..ccb5284dd 100644 --- a/libs/core/src/Types/Store.ts +++ b/libs/core/src/Types/Store.ts @@ -48,6 +48,7 @@ export enum StoreIdentifier { API = 'api', AUTH = 'auth', BLOCK = 'block', + FLOATING = 'floating', COMMENTS = 'comments', HELP = 'help', HISTORY = 'history', diff --git a/libs/core/src/Types/index.ts b/libs/core/src/Types/index.ts new file mode 100644 index 000000000..40b267152 --- /dev/null +++ b/libs/core/src/Types/index.ts @@ -0,0 +1,26 @@ +export * from './Actions' +export * from './Auth' +export * from './Comment' +export * from './Editor' +export * from './FeatureFlags' +export * from './Filters' +export * from './Help' +export * from './Highlight' +export * from './History' +export * from './Kanban' +export * from './List' +export * from './Mentions' +export * from './Metadata' +export * from './MultiCombobox' +export * from './Prompt' +export * from './Reaction' +export * from './Reminders' +export * from './Search' +export * from './Shortcut' +export * from './Shortener' +export * from './SmartCapture' +export * from './Store' +export * from './Sync' +export * from './Todo' +export * from './UserPreference' +export * from './View' diff --git a/libs/core/src/Utils/helpers.ts b/libs/core/src/Utils/helpers.ts index 9191645da..efbf7eb78 100644 --- a/libs/core/src/Utils/helpers.ts +++ b/libs/core/src/Utils/helpers.ts @@ -14,7 +14,7 @@ export function wrapErr(f: (result: T) => void) { export const defaultContent: NodeContent = { type: 'init', - content: [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }], + content: [{ type: ELEMENT_PARAGRAPH, children: [{ text: 'hello world' }] }], version: -1 } diff --git a/libs/core/src/Utils/index.ts b/libs/core/src/Utils/index.ts new file mode 100644 index 000000000..e77b5df56 --- /dev/null +++ b/libs/core/src/Utils/index.ts @@ -0,0 +1,30 @@ +export * from './actionUtils' +export * from './batchPromise' +export * from './config' +export * from './content' +export * from './dataTransform' +export * from './defaults' +export * from './editorElements' +export * from './events' +export * from './fuzzysearch' +export * from './getNewBlockData' +export * from './heirarchy' +export * from './helpers' +export * from './heuristichelper' +export * from './idbStorageAdapter' +export * from './idGenerator' +export * from './keyMap' +export * from './links' +export * from './linkUtils' +export * from './lodashUtils' +export * from './mog' +export * from './niceTry' +export * from './parseData' +export * from './parsers' +export * from './path' +export * from './reminders' +export * from './routes' +export * from './serializer' +export * from './strings' +export * from './time' +export * from './treeUtils' diff --git a/libs/core/src/Utils/routes.ts b/libs/core/src/Utils/routes.ts index 1e911f461..85d952cbf 100644 --- a/libs/core/src/Utils/routes.ts +++ b/libs/core/src/Utils/routes.ts @@ -48,6 +48,10 @@ export const apiURLs = { archiveInNamespace: (namespaceID: string) => `${API_BASE_URLS.archive}?namespaceID=${namespaceID}` }, + openAi: { + perform: `${API_BASE_URLS.prompt}/chat` + }, + // Namespaces namespaces: { getHierarchy: `${API_BASE_URLS.namespace}/all/hierarchy?getMetadata=true`, diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index e16036e70..4e4ca2237 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -9,58 +9,5 @@ export * from './Data/outline' export * from './Data/search' export * from './Data/views' export * from './Stores' -export * from './Types/Actions' -export * from './Types/Auth' -export * from './Types/Comment' -export * from './Types/Editor' -export * from './Types/FeatureFlags' -export * from './Types/Filters' -export * from './Types/Help' -export * from './Types/Highlight' -export * from './Types/Kanban' -export * from './Types/List' -export * from './Types/Mentions' -export * from './Types/Metadata' -export * from './Types/MultiCombobox' -export * from './Types/Prompt' -export * from './Types/Reaction' -export * from './Types/Reminders' -export * from './Types/Search' -export * from './Types/Shortcut' -export * from './Types/Shortener' -export * from './Types/SmartCapture' -export * from './Types/Store' -export * from './Types/Sync' -export * from './Types/Todo' -export * from './Types/UserPreference' -export * from './Types/View' -export * from './Utils/actionUtils' -export * from './Utils/batchPromise' -export * from './Utils/config' -export * from './Utils/content' -export * from './Utils/dataTransform' -export * from './Utils/defaults' -export * from './Utils/editorElements' -export * from './Utils/events' -export * from './Utils/fuzzysearch' -export * from './Utils/getNewBlockData' -export * from './Utils/heirarchy' -export * from './Utils/helpers' -export * from './Utils/heuristichelper' -export * from './Utils/idbStorageAdapter' -export * from './Utils/idGenerator' -export * from './Utils/keyMap' -export * from './Utils/links' -export * from './Utils/linkUtils' -export * from './Utils/lodashUtils' -export * from './Utils/mog' -export * from './Utils/niceTry' -export * from './Utils/parseData' -export * from './Utils/parsers' -export * from './Utils/path' -export * from './Utils/reminders' -export * from './Utils/routes' -export * from './Utils/serializer' -export * from './Utils/strings' -export * from './Utils/time' -export * from './Utils/treeUtils' +export * from './Types' +export * from './Utils' diff --git a/libs/shared/src/Components/FloatingElements/Autocomplete.style.tsx b/libs/shared/src/Components/FloatingElements/Autocomplete.style.tsx new file mode 100644 index 000000000..54ea68966 --- /dev/null +++ b/libs/shared/src/Components/FloatingElements/Autocomplete.style.tsx @@ -0,0 +1,60 @@ +import styled from 'styled-components' + +import { Group } from '../../Style/Layouts' +import { BodyFont } from '../../Style/Search' + +import { MenuWrapper } from './Dropdown.style' + +export const AutoCompleteSelector = styled.div<{ focusOnActive?: boolean }>` + display: flex; + align-items: center; + flex: 1; + :hover { + cursor: pointer; + background: ${({ theme }) => theme.tokens.surfaces.s[3]}; + box-shadow: ${({ theme }) => theme.tokens.shadow.small}; + } + + transition: all 0.2s ease-in-out; + gap: ${({ theme }) => theme.spacing.small}; + padding: ${({ theme }) => theme.spacing.small}; + border-radius: ${({ theme }) => theme.borderRadius.small}; +` + +export const StyledLoading = styled.div` + display: flex; + align-items: center; + width: 100%; +` + +export const AutoCompleteActions = styled(Group)` + color: ${({ theme }) => theme.tokens.text.fade}; + + ${BodyFont}; + + * { + color: ${({ theme }) => theme.tokens.text.fade}; + opacity: 0.8; + white-space: nowrap; + } +` + +export const AutoCompleteInput = styled.input` + border: none; + ${BodyFont} + outline: none; + background: none; + width: 100%; + color: ${({ theme }) => theme.tokens.text.default}; + + &:focus-visible, + :hover, + :focus { + border: none; + outline: none; + } +` + +export const AutoCompleteSuggestions = styled(MenuWrapper)` + max-width: fit-content; +` diff --git a/libs/shared/src/Components/FloatingElements/Autocomplete.tsx b/libs/shared/src/Components/FloatingElements/Autocomplete.tsx new file mode 100644 index 000000000..1e26e602c --- /dev/null +++ b/libs/shared/src/Components/FloatingElements/Autocomplete.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' + +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset, + size, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole +} from '@floating-ui/react' + +import { fuzzySearch, MenuListItemType } from '@mexit/core' + +import { Loading } from '../../Style/Loading' +import { DisplayShortcut } from '../../Style/Tooltip' +import { IconDisplay } from '../IconDisplay' +import { DefaultMIcons } from '../Icons' + +import { + AutoCompleteActions, + AutoCompleteInput, + AutoCompleteSelector, + AutoCompleteSuggestions, + StyledLoading +} from './Autocomplete.style' +import { MenuItem } from './Dropdown' +import { MenuClassName, MenuItemClassName } from './Dropdown.classes' + +export const AutoComplete: React.FC<{ + onEnter: any + clearOnEnter?: boolean + disableMenu?: boolean + defaultItems: Array + defaultValue?: string +}> = ({ defaultItems = [], disableMenu, defaultValue, onEnter, clearOnEnter }) => { + const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [inputValue, setInputValue] = useState(defaultValue ?? '') + const [activeIndex, setActiveIndex] = useState(null) + + const listRef = useRef>([]) + + const { x, y, strategy, refs, context } = useFloating({ + whileElementsMounted: autoUpdate, + open, + onOpenChange: setOpen, + placement: 'bottom-start', + middleware: [ + offset({ mainAxis: 15 }), + flip({ padding: 10 }), + size({ + apply({ rects, availableHeight, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + maxHeight: `${availableHeight}px` + }) + }, + padding: 10 + }) + ] + }) + + const role = useRole(context, { role: 'listbox' }) + const dismiss = useDismiss(context) + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true + }) + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([role, dismiss, listNav]) + + function onChange(event: React.ChangeEvent) { + const value = event.target.value + setInputValue(value) + + if (value) { + setOpen(true) + setActiveIndex(0) + } + } + + useEffect(() => { + setInputValue(defaultValue ?? '') + }, [defaultValue]) + + const items = useMemo(() => { + if (!inputValue) return defaultItems + + const res = fuzzySearch(defaultItems, inputValue, (item) => item.label) + return res + }, [inputValue]) + + const handleOnSelect = (item: MenuListItemType) => { + setInputValue(item.label) + setIsLoading(true) + item.onSelect(() => { + setIsLoading(false) + setInputValue('') + }) + setActiveIndex(null) + setOpen(false) + } + + return ( + <> + + + { + if (clearOnEnter) { + setInputValue('') + setActiveIndex(null) + setIsLoading(false) + setOpen(false) + } + }) + } + } + } + } + })} + /> + + {isLoading ? ( + + + + ) : ( + <> + + to send + + )} + + + + {open && items.length > 0 && !disableMenu && ( + + + {items.map((menuItem, index) => { + return ( + + ) + })} + + + )} + + + ) +} diff --git a/libs/shared/src/Components/FloatingElements/Dropdown.style.tsx b/libs/shared/src/Components/FloatingElements/Dropdown.style.tsx index 5f1242574..e4af14417 100644 --- a/libs/shared/src/Components/FloatingElements/Dropdown.style.tsx +++ b/libs/shared/src/Components/FloatingElements/Dropdown.style.tsx @@ -5,6 +5,7 @@ import { generateStyle } from '@workduck-io/mex-themes' import { GenericFlex } from '../../Style/Filter.style' import { ScrollStyles } from '../../Style/Helpers' import { Ellipsis } from '../../Style/NodeSelect.style' +import { BodyFont, MainFont } from '../../Style/Search' import { MenuItemClassName } from './Dropdown.classes' @@ -58,9 +59,23 @@ const MenuItemStyles = css` } ` -export const ItemLabel = styled.div` +export const ItemLabel = styled.div<{ fontSize?: 'small' | 'regular' }>` ${Ellipsis} max-width: 12rem; + + ${({ fontSize }) => { + switch (fontSize) { + case 'small': + return css` + ${BodyFont} + ` + case 'regular': + default: + return css` + ${MainFont} + ` + } + }} ` export const RootMenuWrapper = styled.button<{ border: boolean; noHover?: boolean; noBackground?: boolean }>` @@ -121,6 +136,12 @@ export const MenuWrapper = styled.div` ${({ theme }) => ScrollStyles(theme.tokens.surfaces.s[0])} ` -export const MenuItemWrapper = styled.button` +export const MenuItemWrapper = styled.button<{ isActive?: boolean }>` ${MenuItemStyles} + + ${({ isActive, theme }) => + isActive && + css` + background: ${theme.tokens.surfaces.s[3]}; + `} ` diff --git a/libs/shared/src/Components/FloatingElements/Dropdown.tsx b/libs/shared/src/Components/FloatingElements/Dropdown.tsx index 7375f353f..159aee2a2 100644 --- a/libs/shared/src/Components/FloatingElements/Dropdown.tsx +++ b/libs/shared/src/Components/FloatingElements/Dropdown.tsx @@ -58,14 +58,19 @@ export const MenuItem = forwardRef< { label: string icon: MIcon + tabIndex?: number + role?: string + className?: string count?: number + fontSize?: 'small' | 'regular' multiSelect?: boolean + isActive?: boolean selected?: boolean disabled?: boolean color?: string onClick?: (e: React.MouseEvent) => void } ->(({ label, disabled, count, color, icon, multiSelect, selected, ...props }, ref) => { +>(({ label, disabled, count, color, fontSize, icon, multiSelect, selected, ...props }, ref) => { return ( @@ -75,7 +80,7 @@ export const MenuItem = forwardRef< )} - {label} + {label} {count && {count}} @@ -199,7 +204,7 @@ export const MenuComponent = forwardRef void - // isHidden: boolean - // setIsHidden: (isHidden: boolean) => void - // isFocused: boolean - // setIsFocused: (isFocused: boolean) => void toolbarState: ToolbarState setToolbarState: (state: ToolbarState) => void } @@ -35,10 +31,6 @@ interface BalloonToolbarStore { export const useBalloonToolbarStore = create((set, get) => ({ open: false, setOpen: (open) => set({ open }), - // isHidden: true, - // setIsHidden: (isHidden) => set({ isHidden }), - // isFocused: false, - // setIsFocused: (isFocused) => set({ isFocused }), toolbarState: 'normal', setToolbarState: (state) => set({ toolbarState: state }) })) diff --git a/libs/shared/src/Style/BalloonToolbar.styles.ts b/libs/shared/src/Style/BalloonToolbar.styles.ts index 2db662a41..9d8963ac0 100644 --- a/libs/shared/src/Style/BalloonToolbar.styles.ts +++ b/libs/shared/src/Style/BalloonToolbar.styles.ts @@ -119,13 +119,13 @@ export const BalloonToolbarBase = styled(ToolbarBase)` position: absolute; white-space: nowrap; - opacity: 100; - transition: opacity 0.2s ease-in-out; + opacity: 1; + transition: width 0.5s ease, opacity 0.2s ease-in-out; color: ${({ theme }) => theme.tokens.text.default}; ${({ theme }) => generateStyle(theme.editor.toolbar.balloonToolbar.wrapper)} z-index: 500; border: 1px solid transparent; - border-radius: ${({ theme }) => theme.borderRadius.tiny}; + border-radius: ${({ theme }) => theme.borderRadius.small}; padding: ${({ theme }) => theme.spacing.tiny}; box-shadow: ${({ theme }) => theme.tokens.shadow.medium}; @@ -142,8 +142,10 @@ export const BalloonToolbarBase = styled(ToolbarBase)` } .slate-ToolbarButton-active { - color: ${({ theme }) => theme.tokens.colors.primary.default}; - background: rgba(${({ theme }) => theme.rgbTokens.colors.primary.default}, 0.1); + svg { + color: ${({ theme }) => theme.tokens.colors.primary.default}; + } + background: rgba(${({ theme }) => theme.rgbTokens.colors.primary.default}, 0.2); } ` @@ -151,10 +153,9 @@ export const BalloonToolbarInputWrapper = styled.div` display: flex; align-items: center; gap: ${({ theme }) => theme.spacing.small}; - padding: ${({ theme }) => theme.spacing.tiny}; svg { - width: 1.2rem; - height: 1.2rem; + width: 1rem; + height: 1rem; } ` diff --git a/libs/shared/src/Style/Editor.tsx b/libs/shared/src/Style/Editor.tsx index 5a48468bf..7aae7cfff 100644 --- a/libs/shared/src/Style/Editor.tsx +++ b/libs/shared/src/Style/Editor.tsx @@ -420,6 +420,11 @@ export const EditorStyles = styled.div<{ readOnly?: boolean; withShadow?: boolea /* Slate Code Block */ + .highlight { + color: ${({ theme }) => theme.tokens.text.heading}; + background: ${({ theme }) => `rgba(${theme.rgbTokens.colors.primary.default}, 0.4)`}; + } + .slate-code_block { select { font-size: 0.8rem; diff --git a/libs/shared/src/Style/Mentions.tsx b/libs/shared/src/Style/Mentions.tsx index 63f2f510b..8b99b6b4f 100644 --- a/libs/shared/src/Style/Mentions.tsx +++ b/libs/shared/src/Style/Mentions.tsx @@ -3,8 +3,7 @@ import styled, { css } from 'styled-components' import { CardShadow } from './Helpers' import { BodyFont } from './Search' -export const SMentionRoot = styled.div<{ type?: 'mentionable' | 'invite' | 'self' }>` - display: inline-block; +export const SMentionRoot = styled.span<{ type?: 'mentionable' | 'invite' | 'self' }>` line-height: 1.2; background-color: ${({ theme }) => theme.tokens.surfaces.s[2]}; border-radius: ${({ theme }) => theme.borderRadius.tiny}; diff --git a/libs/shared/src/Style/QuickLinkElement.styles.ts b/libs/shared/src/Style/QuickLinkElement.styles.ts index 51182affa..cd335e179 100644 --- a/libs/shared/src/Style/QuickLinkElement.styles.ts +++ b/libs/shared/src/Style/QuickLinkElement.styles.ts @@ -1,8 +1,7 @@ import { Icon } from '@iconify/react' import styled, { css } from 'styled-components' -export const SILinkRoot = styled.div` - display: inline-block; +export const SILinkRoot = styled.span` line-height: 1.2; vertical-align: middle; ` @@ -16,7 +15,7 @@ export const StyledIcon = styled(Icon)` margin-right: 4px; ` -export const SILink = styled.div` +export const SILink = styled.span` display: inline-flex; justify-content: center; align-items: center; diff --git a/libs/shared/src/Style/TagElement.styles.tsx b/libs/shared/src/Style/TagElement.styles.tsx index d60af301c..0ad99f937 100644 --- a/libs/shared/src/Style/TagElement.styles.tsx +++ b/libs/shared/src/Style/TagElement.styles.tsx @@ -1,14 +1,13 @@ import { transparentize } from 'polished' import styled, { css } from 'styled-components' -export const STagRoot = styled.div` - display: inline-block; +export const STagRoot = styled.span` line-height: 1.2; /* outline: selectedFocused ? rgb(0, 120, 212) auto 1px : undefined, */ ` -export const STag = styled.div<{ selected: boolean }>` +export const STag = styled.span<{ selected: boolean }>` color: ${({ theme }) => theme.colors.secondary}; ${({ selected, theme }) => selected && diff --git a/yarn.lock b/yarn.lock index ad12b8272..31bcff51e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1962,6 +1962,15 @@ dependencies: "@floating-ui/core" "^1.2.4" +"@floating-ui/react-dom-interactions@^0.13.3": + version "0.13.3" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.13.3.tgz#6c49dda9e16fff64d188603c1efc139588ce925d" + integrity sha512-AnCW06eIZxzD/Hl1Qbi2JkQRU5KpY7Dn81k3xRfbvs+HylhB+t3x88/GNKLK39mMTlJ/ylxm5prUpiLrTWvifQ== + dependencies: + "@floating-ui/react-dom" "^1.0.1" + aria-hidden "^1.1.3" + tabbable "^6.0.1" + "@floating-ui/react-dom-interactions@^0.6.6": version "0.6.6" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.6.6.tgz#8542e8c4bcbee2cd0d512de676c6a493e0a2d168" @@ -1971,14 +1980,6 @@ aria-hidden "^1.1.3" use-isomorphic-layout-effect "^1.1.1" -"@floating-ui/react-dom-interactions@^0.9.3": - version "0.9.3" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.9.3.tgz#4d4d81664066ac36980e50691aa90b1d40667949" - integrity sha512-oHwFLxySRtmhgwg7ZdWswvDDi+ld4mEtxu6ngOd7mRC5L1Rk6adjSfOBOHDxea+ItAWmds8m6A725sn1HQtUyQ== - dependencies: - "@floating-ui/react-dom" "^1.0.0" - aria-hidden "^1.1.3" - "@floating-ui/react-dom@^0.7.2": version "0.7.2" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.7.2.tgz#0bf4ceccb777a140fc535c87eb5d6241c8e89864" @@ -1987,19 +1988,19 @@ "@floating-ui/dom" "^0.5.3" use-isomorphic-layout-effect "^1.1.1" -"@floating-ui/react-dom@^1.0.0", "@floating-ui/react-dom@^1.2.1": +"@floating-ui/react-dom@^1.0.1", "@floating-ui/react-dom@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.3.0.tgz#4d35d416eb19811c2b0e9271100a6aa18c1579b3" integrity sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g== dependencies: "@floating-ui/dom" "^1.2.1" -"@floating-ui/react@^0.18.0": - version "0.18.1" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.18.1.tgz#3a5ea19d22239f6c8d0250121488969680435473" - integrity sha512-Uqntjem19/3ghAwKSaMU/719P/riiox13rkAzMhCthmAAhTzvvmNka52L5s9Gi/cjAfNNR5/RjkD0YKqTecWzQ== +"@floating-ui/react@^0.22.2": + version "0.22.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.22.2.tgz#57d3e32dd82790be5dfcf1a42a44b4a3d0366ca1" + integrity sha512-7u5JqNfcbUCY9WNGJvcbaoChTx5fbFlW2Mpo/6B5DzB+pPWRBbFknALRUTcXj599Sm7vCZ2HdJS9hID22QKriQ== dependencies: - "@floating-ui/react-dom" "^1.2.1" + "@floating-ui/react-dom" "^1.3.0" aria-hidden "^1.1.3" tabbable "^6.0.1"