feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,13 +25,25 @@ import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
|
||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
||||
import { BlockActionMenu } from './block-action-menu'
|
||||
import { SmartPasteMenu } from './smart-paste-menu'
|
||||
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
||||
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
@@ -42,7 +54,7 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -64,6 +76,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
noteTitle?: string
|
||||
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||
sourceUrl?: string | null
|
||||
}
|
||||
@@ -86,7 +99,7 @@ type SlashItem = {
|
||||
|
||||
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
|
||||
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
|
||||
|
||||
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
|
||||
|
||||
@@ -176,6 +189,10 @@ const slashCommands: SlashItem[] = [
|
||||
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertDatabaseBlockAtSelection(e) },
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -233,11 +250,28 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
const [blockMenuState, setBlockMenuState] = useState<{
|
||||
anchor: DOMRect
|
||||
pos: number
|
||||
node: PMNode | null
|
||||
} | null>(null)
|
||||
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
|
||||
const smartPastePendingRef = useRef<{
|
||||
reference: ParsedBlockReference
|
||||
blockPos: number
|
||||
blockNode: PMNode
|
||||
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
|
||||
} | null>(null)
|
||||
const [smartPasteMenu, setSmartPasteMenu] = useState<{
|
||||
anchor: { top: number; left: number }
|
||||
reference: ParsedBlockReference
|
||||
sourceNoteTitle?: string
|
||||
} | null>(null)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
@@ -355,16 +389,38 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Typography,
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
LiveBlockExtension,
|
||||
DatabaseBlockExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
attributes: { class: 'notion-editor tiptap' },
|
||||
handleDOMEvents: {
|
||||
keydown: (view, event) => {
|
||||
if (event.defaultPrevented) return false
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return false
|
||||
const { from, empty } = view.state.selection
|
||||
if (!empty) return false
|
||||
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
|
||||
if (!/\/(database|db)$/i.test(textBefore)) return false
|
||||
event.preventDefault()
|
||||
const slashIdx = textBefore.lastIndexOf('/')
|
||||
const deleteFrom = from - (textBefore.length - slashIdx)
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) return false
|
||||
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
|
||||
if (!insertDatabaseBlockAtSelection(ed)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
return true
|
||||
},
|
||||
click: (_view, event) => {
|
||||
const link = (event.target as HTMLElement).closest('a[href]')
|
||||
if (!link) return false
|
||||
@@ -373,11 +429,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (noteIdMatch) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(
|
||||
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
const targetId = decodeURIComponent(noteIdMatch[1])
|
||||
const blockMatch = href.match(/#block-([^&#]+)/)
|
||||
openNotePeek({
|
||||
noteId: targetId,
|
||||
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
@@ -405,11 +462,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
try {
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const editorInstance = editorInstanceRef.current
|
||||
if (!editorInstance) continue
|
||||
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) continue
|
||||
const inserted = ed.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(editorInstance.getHTML())
|
||||
emitContentChange(ed.getHTML())
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
@@ -447,6 +504,80 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
editorInstanceRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.storage.liveBlock.hostNoteId = noteId ?? null
|
||||
}, [editor, noteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
editor.storage.smartPaste.onPaste = (view, event) => {
|
||||
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
|
||||
let blockRef = parseBlockReferenceFromText(clipboardText)
|
||||
if (!blockRef) {
|
||||
blockRef = recallLastBlockReference()
|
||||
}
|
||||
if (!blockRef) return false
|
||||
|
||||
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
||||
if (!emptyParagraph) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const coords = view.coordsAtPos(view.state.selection.from)
|
||||
smartPastePendingRef.current = {
|
||||
reference: blockRef,
|
||||
blockPos: emptyParagraph.pos,
|
||||
blockNode: emptyParagraph.node,
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
setSmartPasteMenu({
|
||||
anchor: { top: coords.bottom, left: coords.left },
|
||||
reference: blockRef,
|
||||
})
|
||||
})
|
||||
|
||||
void fetch(
|
||||
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
|
||||
)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
|
||||
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === blockRef.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
const exists = Boolean(data?.exists) || sessionFallback.content.length > 0
|
||||
const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '')
|
||||
const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || ''
|
||||
smartPastePendingRef.current!.blockStatus = {
|
||||
exists,
|
||||
content,
|
||||
sourceNoteTitle,
|
||||
}
|
||||
setSmartPasteMenu((prev) =>
|
||||
prev?.reference.raw === blockRef.raw
|
||||
? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle }
|
||||
: prev,
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.storage.smartPaste.onPaste = null
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
|
||||
@@ -620,6 +751,159 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
handleSelectBlockRef.current = handleSelectBlock
|
||||
|
||||
const openBlockActionMenu = useCallback((anchorRect: DOMRect) => {
|
||||
if (!editor) return
|
||||
const block = resolveBlockAtDragHandle(editor)
|
||||
if (!block) return
|
||||
dragBlockRef.current = block
|
||||
setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node })
|
||||
}, [editor])
|
||||
|
||||
const closeBlockActionMenu = useCallback(() => {
|
||||
setBlockMenuState(null)
|
||||
}, [])
|
||||
|
||||
const closeSmartPasteMenu = useCallback(() => {
|
||||
smartPastePendingRef.current = null
|
||||
setSmartPasteMenu(null)
|
||||
}, [])
|
||||
|
||||
const handleBlockReferenceCopied = useCallback((html: string) => {
|
||||
emitContentChange(html)
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'block-reference-copy' },
|
||||
}))
|
||||
}
|
||||
}, [emitContentChange, noteId])
|
||||
|
||||
const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => {
|
||||
const cached = smartPastePendingRef.current?.blockStatus
|
||||
if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === reference.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
|
||||
const res = await fetch(
|
||||
`/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content,
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle,
|
||||
}
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
return {
|
||||
exists: true,
|
||||
content: data.content ?? '',
|
||||
sourceNoteTitle: data.sourceNoteTitle ?? '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content || data.content || '',
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '',
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSmartPasteLive = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
targetNoteId: noteId,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlockType = ed.schema.nodes.liveBlock
|
||||
if (liveBlockType) {
|
||||
ed.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
const node = liveBlockType.create({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
snapshotContent: status.content,
|
||||
sourceNoteTitle: status.sourceNoteTitle,
|
||||
})
|
||||
tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node)
|
||||
if (dispatch) dispatch(tr)
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
emitContentChange(ed.getHTML())
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'smart-paste-live-block' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId])
|
||||
|
||||
const handleSmartPastePlain = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
const linkHref = pending.reference.raw.match(/^https?:\/\//)
|
||||
? pending.reference.raw
|
||||
: `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}`
|
||||
const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw
|
||||
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: linkText,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: linkHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}],
|
||||
})
|
||||
.run()
|
||||
|
||||
emitContentChange(ed.getHTML())
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -645,8 +929,34 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && blockMenuState && (
|
||||
<BlockActionMenu
|
||||
editor={editor}
|
||||
anchorRect={blockMenuState.anchor}
|
||||
blockPos={blockMenuState.pos}
|
||||
blockNode={blockMenuState.node}
|
||||
noteId={noteId}
|
||||
sourceNoteTitle={noteTitle}
|
||||
onBlockReferenceCopied={handleBlockReferenceCopied}
|
||||
onClose={closeBlockActionMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{smartPasteMenu && (
|
||||
<SmartPasteMenu
|
||||
anchor={smartPasteMenu.anchor}
|
||||
reference={smartPasteMenu.reference}
|
||||
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
|
||||
onLive={() => { void handleSmartPasteLive() }}
|
||||
onPlain={() => { void handleSmartPastePlain() }}
|
||||
onClose={closeSmartPasteMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
@@ -973,6 +1283,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
|
||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1021,6 +1332,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
@@ -1029,7 +1345,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||
const q = query.toLowerCase()
|
||||
const textFiltered = localCommands.filter(c =>
|
||||
c.title.toLowerCase().includes(q)
|
||||
|| c.description.toLowerCase().includes(q)
|
||||
|| (c.shortcut?.toLowerCase().includes(q) ?? false)
|
||||
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false)
|
||||
)
|
||||
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
|
||||
|
||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||
@@ -1070,7 +1392,15 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = filtered[selectedIndex]
|
||||
if (item) handleSelect(item)
|
||||
if (item) {
|
||||
handleSelect(item)
|
||||
} else if (/^(database|db)$/i.test(query)) {
|
||||
deleteSlashText()
|
||||
closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
|
||||
|
||||
Reference in New Issue
Block a user