- Wizard IA: création de carnet complet (étudiant/prof/ingénieur)
- 3 profils, choix niveau, nombre de notes (3-12)
- Étapes numérotées, messages de progression, écran de succès
- Notes riches avec callouts, toggles, équations, colonnes
- Structured View auto-créé avec propriétés Statut/Difficulté
- Embeddings en arrière-plan
- Export PDF: clone le DOM réel, rend KaTeX, préserve couleurs callouts
- Fix: boutons toggle/callout en hover-only
- Fix: parsing JSON robuste (backslashes LaTeX)
- Fix: flushSync warning (queueMicrotask)
- Fix: drag handle clamp viewport
- Bouton wizard dans la sidebar (✨ à côté du +)
- i18n FR/EN complet
173 lines
4.8 KiB
TypeScript
173 lines
4.8 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 group/toggle 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>
|
|
<div className="flex items-center gap-0.5 opacity-0 group-hover/toggle:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={unwrap}
|
|
contentEditable={false}
|
|
className="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="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>
|
|
|
|
<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()
|
|
}
|