diff --git a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx index 0efaaa7159..d16761369e 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/enhanced/editor.tsx @@ -4,6 +4,7 @@ import { type JSONContent, TiptapEditor } from "@hypr/tiptap/editor"; import NoteEditor from "@hypr/tiptap/editor"; import { EMPTY_TIPTAP_DOC, isValidTiptapContent } from "@hypr/tiptap/shared"; +import { useSearchEngine } from "../../../../../../contexts/search/engine"; import { useImageUpload } from "../../../../../../hooks/useImageUpload"; import * as main from "../../../../../../store/tinybase/store/main"; @@ -40,14 +41,21 @@ export const EnhancedEditor = forwardRef< main.STORE_ID, ); + const { search } = useSearchEngine(); + const mentionConfig = useMemo( () => ({ trigger: "@", - handleSearch: async () => { - return []; + handleSearch: async (query: string) => { + const results = await search(query); + return results.slice(0, 5).map((hit) => ({ + id: hit.document.id, + type: hit.document.type, + label: hit.document.title, + })); }, }), - [], + [search], ); const fileHandlerConfig = useMemo(() => ({ onImageUpload }), [onImageUpload]); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/raw.tsx b/apps/desktop/src/components/main/body/sessions/note-input/raw.tsx index e2d28634b0..c46458a089 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/raw.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/raw.tsx @@ -11,6 +11,7 @@ import { type PlaceholderFunction, } from "@hypr/tiptap/shared"; +import { useSearchEngine } from "../../../../../contexts/search/engine"; import { useImageUpload } from "../../../../../hooks/useImageUpload"; import * as main from "../../../../../store/tinybase/store/main"; @@ -73,14 +74,21 @@ export const RawEditor = forwardRef< [persistChange, hasNonEmptyText], ); + const { search } = useSearchEngine(); + const mentionConfig = useMemo( () => ({ trigger: "@", - handleSearch: async () => { - return []; + handleSearch: async (query: string) => { + const results = await search(query); + return results.slice(0, 5).map((hit) => ({ + id: hit.document.id, + type: hit.document.type, + label: hit.document.title, + })); }, }), - [], + [search], ); const fileHandlerConfig = useMemo(() => ({ onImageUpload }), [onImageUpload]); diff --git a/apps/desktop/src/contexts/search/engine/index.tsx b/apps/desktop/src/contexts/search/engine/index.tsx index cbf0a78367..cdf1b91b1d 100644 --- a/apps/desktop/src/contexts/search/engine/index.tsx +++ b/apps/desktop/src/contexts/search/engine/index.tsx @@ -106,19 +106,28 @@ export function SearchEngineProvider({ query: string, filters: SearchFilters | null = null, ): Promise => { - const normalizedQuery = normalizeQuery(query); - - if (normalizedQuery.length < 1) { - return []; - } - if (!oramaInstance.current) { return []; } + const normalizedQuery = normalizeQuery(query); + try { const whereClause = buildOramaFilters(filters); + if (normalizedQuery.length < 1) { + const searchResults = await oramaSearch(oramaInstance.current, { + term: "", + sortBy: { + property: "created_at", + order: "DESC", + }, + limit: 10, + ...(whereClause && { where: whereClause }), + }); + return searchResults.hits as SearchHit[]; + } + const searchResults = await oramaSearch(oramaInstance.current, { term: normalizedQuery, boost: { diff --git a/apps/desktop/src/store/tinybase/store/main.ts b/apps/desktop/src/store/tinybase/store/main.ts index cf35cf8953..182ed9a121 100644 --- a/apps/desktop/src/store/tinybase/store/main.ts +++ b/apps/desktop/src/store/tinybase/store/main.ts @@ -294,6 +294,16 @@ export const StoreComponent = () => { "enhanced_notes", "template_id", "position", + ) + .setIndexDefinition( + INDEXES.mentionsBySource, + "mapping_mention", + "source_id", + ) + .setIndexDefinition( + INDEXES.mentionsByTarget, + "mapping_mention", + "target_id", ), ); @@ -356,6 +366,8 @@ export const INDEXES = { sessionsByHuman: "sessionsByHuman", enhancedNotesBySession: "enhancedNotesBySession", enhancedNotesByTemplate: "enhancedNotesByTemplate", + mentionsBySource: "mentionsBySource", + mentionsByTarget: "mentionsByTarget", }; export const RELATIONSHIPS = { diff --git a/apps/desktop/src/utils/mentions.ts b/apps/desktop/src/utils/mentions.ts new file mode 100644 index 0000000000..1b8396e2fc --- /dev/null +++ b/apps/desktop/src/utils/mentions.ts @@ -0,0 +1,70 @@ +import type { MentionSourceType, MentionTargetType } from "@hypr/store"; +import type { JSONContent } from "@hypr/tiptap/editor"; + +export interface ExtractedMention { + target_id: string; + target_type: MentionTargetType; +} + +export function extractMentionsFromTiptap( + content: JSONContent, +): ExtractedMention[] { + const mentions: ExtractedMention[] = []; + const seen = new Set(); + + const traverse = (node: JSONContent) => { + if (node.type === "mention-@" && node.attrs) { + const key = `${node.attrs.type}:${node.attrs.id}`; + if (!seen.has(key)) { + seen.add(key); + mentions.push({ + target_id: node.attrs.id as string, + target_type: node.attrs.type as MentionTargetType, + }); + } + } + + if (node.content) { + for (const child of node.content) { + traverse(child); + } + } + }; + + traverse(content); + return mentions; +} + +export function syncMentions( + store: { + getSliceRowIds: (indexId: string, sliceId: string) => string[]; + delRow: (tableId: string, rowId: string) => void; + setRow: ( + tableId: string, + rowId: string, + row: Record, + ) => void; + }, + userId: string, + sourceId: string, + sourceType: MentionSourceType, + mentions: ExtractedMention[], + indexId: string, +) { + const existingRowIds = store.getSliceRowIds(indexId, sourceId); + + for (const rowId of existingRowIds) { + store.delRow("mapping_mention", rowId); + } + + for (const mention of mentions) { + const rowId = `${sourceId}:${mention.target_type}:${mention.target_id}`; + store.setRow("mapping_mention", rowId, { + user_id: userId, + source_id: sourceId, + source_type: sourceType, + target_id: mention.target_id, + target_type: mention.target_type, + }); + } +} diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts index a4ca7a7f92..f299dc844e 100644 --- a/packages/store/src/tinybase.ts +++ b/packages/store/src/tinybase.ts @@ -10,6 +10,7 @@ import { eventSchema, generalSchema, humanSchema, + mappingMentionSchema, mappingSessionParticipantSchema, mappingTagSessionSchema, organizationSchema, @@ -96,6 +97,13 @@ export const tableSchemaForTinybase = { tag_id: { type: "string" }, session_id: { type: "string" }, } as const satisfies InferTinyBaseSchema, + mapping_mention: { + user_id: { type: "string" }, + source_id: { type: "string" }, + source_type: { type: "string" }, + target_id: { type: "string" }, + target_type: { type: "string" }, + } as const satisfies InferTinyBaseSchema, templates: { user_id: { type: "string" }, title: { type: "string" }, diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts index d23ffcd65c..e332d31dfe 100644 --- a/packages/store/src/zod.ts +++ b/packages/store/src/zod.ts @@ -102,6 +102,24 @@ export const mappingTagSessionSchema = z.object({ session_id: z.string(), }); +export const mentionTargetTypeSchema = z.enum([ + "session", + "human", + "organization", +]); +export type MentionTargetType = z.infer; + +export const mentionSourceTypeSchema = z.enum(["session", "enhanced_note"]); +export type MentionSourceType = z.infer; + +export const mappingMentionSchema = z.object({ + user_id: z.string(), + source_id: z.string(), + source_type: mentionSourceTypeSchema, + target_id: z.string(), + target_type: mentionTargetTypeSchema, +}); + export const templateSectionSchema = z.object({ title: z.string(), description: z.string(), @@ -232,6 +250,7 @@ export type MappingSessionParticipant = z.infer< >; export type Tag = z.infer; export type MappingTagSession = z.infer; +export type MappingMention = z.infer; export type Template = z.infer; export type TemplateSection = z.infer; export type ChatGroup = z.infer; @@ -257,5 +276,6 @@ export type EventStorage = ToStorageType; export type MappingSessionParticipantStorage = ToStorageType< typeof mappingSessionParticipantSchema >; +export type MappingMentionStorage = ToStorageType; export type AIProviderStorage = ToStorageType; export type GeneralStorage = ToStorageType; diff --git a/packages/tiptap/src/editor/index.tsx b/packages/tiptap/src/editor/index.tsx index 7f92a6ac4e..10ff6bb921 100644 --- a/packages/tiptap/src/editor/index.tsx +++ b/packages/tiptap/src/editor/index.tsx @@ -12,7 +12,7 @@ import "../../styles.css"; import * as shared from "../shared"; import type { FileHandlerConfig } from "../shared/extensions"; import type { PlaceholderFunction } from "../shared/extensions/placeholder"; -import { mention, type MentionConfig } from "./mention"; +import { isMentionActive, mention, type MentionConfig } from "./mention"; const safeRequestIdleCallback = typeof requestIdleCallback !== "undefined" @@ -106,7 +106,12 @@ const Editor = forwardRef<{ editor: TiptapEditor | null }, EditorProps>( } } - if (event.key === "ArrowUp" && isInFirstBlock && onNavigateToTitle) { + if ( + event.key === "ArrowUp" && + isInFirstBlock && + onNavigateToTitle && + !isMentionActive(state) + ) { event.preventDefault(); const firstBlock = state.doc.firstChild; diff --git a/packages/tiptap/src/editor/mention.tsx b/packages/tiptap/src/editor/mention.tsx index 482c50b9b1..09c01611e4 100644 --- a/packages/tiptap/src/editor/mention.tsx +++ b/packages/tiptap/src/editor/mention.tsx @@ -8,13 +8,23 @@ import { type VirtualElement, } from "@floating-ui/dom"; import Mention from "@tiptap/extension-mention"; -import { PluginKey } from "@tiptap/pm/state"; +import { type EditorState, PluginKey } from "@tiptap/pm/state"; import { ReactRenderer } from "@tiptap/react"; import { type SuggestionOptions } from "@tiptap/suggestion"; +import { Building2Icon, StickyNoteIcon, UserIcon } from "lucide-react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; const GLOBAL_NAVIGATE_FUNCTION = "__HYPR_NAVIGATE__"; +const mentionPluginKeys: PluginKey[] = []; + +export function isMentionActive(state: EditorState): boolean { + return mentionPluginKeys.some((key) => { + const pluginState = key.getState(state); + return pluginState?.active === true; + }); +} + export interface MentionItem { id: string; type: string; @@ -109,6 +119,13 @@ const Component = forwardRef< key={item.id} onClick={() => selectItem(index)} > + {item.type === "session" ? ( + + ) : item.type === "human" ? ( + + ) : item.type === "organization" ? ( + + ) : null} {item.label} ); @@ -146,9 +163,12 @@ const suggestion = ( } }; + const pluginKey = new PluginKey(`mention-${config.trigger}`); + mentionPluginKeys.push(pluginKey); + return { char: config.trigger, - pluginKey: new PluginKey(`mention-${config.trigger}`), + pluginKey, command: ({ editor, range, props }) => { const item = props as MentionItem; if (item.content) { @@ -177,16 +197,13 @@ const suggestion = ( } }, items: async ({ query }) => { - if (!query || query.length < 1) { - loading = false; - return []; - } + const normalizedQuery = query ?? ""; - if (query === currentQuery && cachedItems.length > 0) { + if (normalizedQuery === currentQuery && cachedItems.length > 0) { return cachedItems; } - currentQuery = query; + currentQuery = normalizedQuery; if (abortController) { abortController.abort(); @@ -196,7 +213,7 @@ const suggestion = ( loading = true; setTimeout(() => { - Promise.resolve(config.handleSearch(query)) + Promise.resolve(config.handleSearch(normalizedQuery)) .then((items: MentionItem[]) => { cachedItems = items.slice(0, 5); loading = false; @@ -221,7 +238,7 @@ const suggestion = ( const update = () => { void computePosition(referenceEl, floatingEl, { placement: "bottom-start", - middleware: [offset(0), flip(), shift({ limiter: limitShift() })], + middleware: [offset(4), flip(), shift({ limiter: limitShift() })], }).then(({ x, y }) => { Object.assign(floatingEl.style, { left: `${x}px`, diff --git a/packages/tiptap/src/styles/mention.css b/packages/tiptap/src/styles/mention.css index 304de48f75..0015a088c1 100644 --- a/packages/tiptap/src/styles/mention.css +++ b/packages/tiptap/src/styles/mention.css @@ -25,9 +25,12 @@ font-size: 0.9rem; } -.mention-item:hover, .mention-item.is-selected { - background-color: rgba(59, 130, 246, 0.06); + background-color: #f5f5f5; +} + +.mention-item:hover { + background-color: #fafafa; } .mention-label { @@ -38,12 +41,43 @@ font-size: 0.85rem; } -.mention { +.mention-type-icon { + width: 0.9rem; + height: 0.9rem; + flex-shrink: 0; +} + +.mention-type-session { color: #3b82f6; +} + +.mention-type-human { + color: #8b5cf6; +} + +.mention-type-organization { + color: #10b981; +} + +.mention { font-weight: 500; text-decoration: none; border-radius: 0.25rem; - background-color: rgba(59, 130, 246, 0.08); padding: 0.1rem 0.25rem; font-size: 0.9rem; } + +.mention[data-type="session"] { + color: #3b82f6; + background-color: rgba(59, 130, 246, 0.08); +} + +.mention[data-type="human"] { + color: #8b5cf6; + background-color: rgba(139, 92, 246, 0.08); +} + +.mention[data-type="organization"] { + color: #10b981; + background-color: rgba(16, 185, 129, 0.08); +}