Files
Momento/memento-note/components/tiptap-toggle-extension.tsx
Antigravity fccad72d47
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m33s
CI / Deploy production (on server) (push) Has been skipped
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
2026-06-14 16:23:56 +00:00

171 lines
4.7 KiB
TypeScript

'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()
}