feat: bloc Toggle/Section repliable + infrastructure embeddings par fragments
- Nouveau bloc Toggle : sections dépliables dans l'éditeur (slash menu + drag handle) - Transformer un bloc existant en section repliable via le menu d'action - Boutons désactiver (unwrap) et supprimer dans l'en-tête - i18n FR/EN complet - Infrastructure embeddings par fragments (inspiré AppFlowy) - Table NoteEmbeddingChunk + index HNSW - Chunking sémantique (~1000 chars, overlap 200, dedup par hash) - Indexation incrémentale au save (createNote + updateNote + clip) - Queue concurrence 4, retry backoff exponentiel
This commit is contained in:
@@ -8,6 +8,7 @@ import { getAIProvider } from '@/lib/ai/factory'
|
|||||||
import { parseNote, getHashColor } from '@/lib/utils'
|
import { parseNote, getHashColor } from '@/lib/utils'
|
||||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||||
|
import { chunkIndexingService } from '@/lib/ai/services/chunk-indexing.service'
|
||||||
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links'
|
||||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||||
@@ -458,6 +459,12 @@ export async function createNote(data: {
|
|||||||
console.error('[BG] Embedding generation failed:', e)
|
console.error('[BG] Embedding generation failed:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chunkIndexingService.indexNote(noteId, data.title, content)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BG] Chunk indexing failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||||||
if (!hasUserLabels && notebookId) {
|
if (!hasUserLabels && notebookId) {
|
||||||
try {
|
try {
|
||||||
@@ -596,6 +603,12 @@ export async function updateNote(id: string, data: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[BG] Embedding regeneration failed:', e);
|
console.error('[BG] Embedding regeneration failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chunkIndexingService.indexNote(noteId, title, content);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BG] Chunk indexing failed:', e);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createNotification } from '@/app/actions/notifications'
|
|||||||
import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article'
|
import { buildClipSourceFooter, clipFooterLocaleTag } from '@/lib/clip/extract-article'
|
||||||
import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content'
|
import { resolveClipLocale, wrapClipArticleHtml, applyRtlToHtmlBlocks } from '@/lib/clip/rtl-content'
|
||||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||||
|
import { chunkIndexingService } from '@/lib/ai/services/chunk-indexing.service'
|
||||||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||||||
import DOMPurify from 'isomorphic-dompurify'
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
|
||||||
@@ -92,6 +93,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (embedding?.length) {
|
if (embedding?.length) {
|
||||||
await upsertNoteEmbedding(note.id, embedding)
|
await upsertNoteEmbedding(note.id, embedding)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await chunkIndexingService.indexNote(note.id, title || domain, fullContent)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[clip/save] embedding generation failed:', error)
|
console.error('[clip/save] embedding generation failed:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
Heading1, Heading2, Heading3, List, ListOrdered,
|
Heading1, Heading2, Heading3, List, ListOrdered,
|
||||||
CheckSquare, Quote, CodeXml, Database,
|
CheckSquare, Quote, CodeXml, Database,
|
||||||
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
|
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
|
||||||
|
ChevronsRightLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
|
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
|
||||||
|
import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension'
|
||||||
|
|
||||||
interface BlockActionMenuProps {
|
interface BlockActionMenuProps {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
@@ -369,6 +371,14 @@ export function BlockActionMenu({
|
|||||||
|
|
||||||
<div className="block-action-separator" />
|
<div className="block-action-separator" />
|
||||||
|
|
||||||
|
{/* Section repliable */}
|
||||||
|
<button type="button" className="block-action-item" onClick={() => { turnIntoToggleBlock(editor, blockPos, blockNode); onClose() }}>
|
||||||
|
<ChevronsRightLeft size={16} />
|
||||||
|
<span>{t('richTextEditor.blockActionInsertToggle')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="block-action-separator" />
|
||||||
|
|
||||||
{/* Création de diagramme */}
|
{/* Création de diagramme */}
|
||||||
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
||||||
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
|||||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||||
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
||||||
|
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
|
||||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||||
@@ -61,7 +62,8 @@ import {
|
|||||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
|
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||||
|
ChevronsRightLeft
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -202,6 +204,10 @@ const slashCommands: SlashItem[] = [
|
|||||||
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||||
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
|
||||||
|
command: (e) => { insertToggleBlock(e) },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
|
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
|
||||||
@@ -423,6 +429,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
UndoRedoFeedbackExtension,
|
UndoRedoFeedbackExtension,
|
||||||
LiveBlockExtension,
|
LiveBlockExtension,
|
||||||
StructuredViewBlockExtension,
|
StructuredViewBlockExtension,
|
||||||
|
ToggleExtension,
|
||||||
ClipArticleExtension,
|
ClipArticleExtension,
|
||||||
RtlPreserveExtension,
|
RtlPreserveExtension,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -1592,6 +1599,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', 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: 'embed' },
|
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'embed' },
|
||||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
|
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
|
||||||
|
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||||
{
|
{
|
||||||
title: t('richTextEditor.slashNoteLink'),
|
title: t('richTextEditor.slashNoteLink'),
|
||||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||||
|
|||||||
170
memento-note/components/tiptap-toggle-extension.tsx
Normal file
170
memento-note/components/tiptap-toggle-extension.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
|
||||||
|
import { ChevronRight, X, Trash2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
const ToggleView = ({ node, updateAttributes, deleteNode, getPos, editor }: any) => {
|
||||||
|
const [opened, setOpened] = useState(node.attrs.opened !== false)
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const next = !opened
|
||||||
|
setOpened(next)
|
||||||
|
updateAttributes({ opened: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrap = () => {
|
||||||
|
const pos = getPos()
|
||||||
|
if (typeof pos !== 'number') return
|
||||||
|
const { state, view } = editor
|
||||||
|
const toggleNode = state.doc.nodeAt(pos)
|
||||||
|
if (!toggleNode) return
|
||||||
|
const children: any[] = []
|
||||||
|
toggleNode.forEach((child: any) => {
|
||||||
|
children.push(child.toJSON())
|
||||||
|
})
|
||||||
|
if (children.length === 0) children.push({ type: 'paragraph' })
|
||||||
|
editor.chain().focus().deleteRange({ from: pos, to: pos + toggleNode.nodeSize }).run()
|
||||||
|
editor.chain().focus().insertContentAt(pos, children).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="toggle-block my-1 rounded-lg border border-border/50 bg-muted/20" dir="auto">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/30">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
contentEditable={false}
|
||||||
|
className="flex-shrink-0 p-0.5 rounded hover:bg-muted"
|
||||||
|
style={{
|
||||||
|
transform: opened ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium text-muted-foreground flex-1 cursor-pointer"
|
||||||
|
onClick={toggle}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{opened ? t('richTextEditor.toggleOpened') : t('richTextEditor.toggleClosed')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={unwrap}
|
||||||
|
contentEditable={false}
|
||||||
|
className="flex-shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground"
|
||||||
|
title={t('richTextEditor.toggleUnwrap')}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteNode}
|
||||||
|
contentEditable={false}
|
||||||
|
className="flex-shrink-0 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
|
title={t('richTextEditor.toggleDelete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('px-3 py-2', !opened && 'hidden')}>
|
||||||
|
<NodeViewContent className="toggle-content space-y-1" />
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToggleExtension = Node.create({
|
||||||
|
name: 'toggleBlock',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
content: 'block+',
|
||||||
|
|
||||||
|
defining: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
opened: {
|
||||||
|
default: true,
|
||||||
|
parseHTML: (element) => element.getAttribute('data-opened') !== 'false',
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-opened': attributes.opened,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="toggle-block"]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'details',
|
||||||
|
getAttrs: (element) => {
|
||||||
|
if (typeof element === 'string') return false
|
||||||
|
return { opened: !(element as HTMLElement).hasAttribute('closed') }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-type': 'toggle-block',
|
||||||
|
class: 'toggle-block',
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ToggleView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Shift-T': () => this.editor.commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: { opened: true },
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function insertToggleBlock(editor: any) {
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'toggleBlock',
|
||||||
|
attrs: { opened: true },
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph' },
|
||||||
|
],
|
||||||
|
}).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforme un bloc existant en section repliable.
|
||||||
|
* Le contenu du bloc original est déplacé dans le toggle.
|
||||||
|
*/
|
||||||
|
export function turnIntoToggleBlock(editor: any, blockPos: number, blockNode: any) {
|
||||||
|
if (!blockNode || blockPos < 0) return
|
||||||
|
|
||||||
|
const nodeJson = blockNode.toJSON()
|
||||||
|
|
||||||
|
editor.chain().focus().deleteRange({
|
||||||
|
from: blockPos,
|
||||||
|
to: blockPos + blockNode.nodeSize,
|
||||||
|
}).insertContentAt(blockPos, {
|
||||||
|
type: 'toggleBlock',
|
||||||
|
attrs: { opened: true },
|
||||||
|
content: [nodeJson],
|
||||||
|
}).setTextSelection(blockPos + 2).run()
|
||||||
|
}
|
||||||
@@ -2407,6 +2407,13 @@
|
|||||||
"slashTableDesc": "Insert a simple grid",
|
"slashTableDesc": "Insert a simple grid",
|
||||||
"slashDatabase": "Structured View",
|
"slashDatabase": "Structured View",
|
||||||
"slashDatabaseDesc": "Embed your notebook's structured data",
|
"slashDatabaseDesc": "Embed your notebook's structured data",
|
||||||
|
"slashToggle": "Toggle Section",
|
||||||
|
"slashToggleDesc": "Create a collapsible section",
|
||||||
|
"toggleOpened": "Expanded section — click to collapse",
|
||||||
|
"toggleClosed": "Collapsed section — click to expand",
|
||||||
|
"toggleDelete": "Delete section",
|
||||||
|
"toggleUnwrap": "Disable toggle section",
|
||||||
|
"blockActionInsertToggle": "Turn into toggle section",
|
||||||
"slashDiagram": "Diagram",
|
"slashDiagram": "Diagram",
|
||||||
"slashDiagramDesc": "Generate a flow or mindmap",
|
"slashDiagramDesc": "Generate a flow or mindmap",
|
||||||
"slashSlides": "Presentation",
|
"slashSlides": "Presentation",
|
||||||
|
|||||||
@@ -2411,6 +2411,13 @@
|
|||||||
"slashTableDesc": "Insérer un tableau simple",
|
"slashTableDesc": "Insérer un tableau simple",
|
||||||
"slashDatabase": "Vue structurée",
|
"slashDatabase": "Vue structurée",
|
||||||
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
|
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
|
||||||
|
"slashToggle": "Section repliable",
|
||||||
|
"slashToggleDesc": "Créer une section dépliable",
|
||||||
|
"toggleOpened": "Section dépliée — cliquer pour replier",
|
||||||
|
"toggleClosed": "Section repliée — cliquer pour déplier",
|
||||||
|
"toggleDelete": "Supprimer la section",
|
||||||
|
"toggleUnwrap": "Désactiver la section repliable",
|
||||||
|
"blockActionInsertToggle": "Transformer en section repliable",
|
||||||
"slashDiagram": "Diagramme",
|
"slashDiagram": "Diagramme",
|
||||||
"slashDiagramDesc": "Générer un flux ou une carte mentale",
|
"slashDiagramDesc": "Générer un flux ou une carte mentale",
|
||||||
"slashSlides": "Présentation",
|
"slashSlides": "Présentation",
|
||||||
|
|||||||
Reference in New Issue
Block a user