'use client' import { Node, mergeAttributes } from '@tiptap/core' import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react' import { useEffect, useRef, useState, useCallback } from 'react' import { Zap, AlertCircle, Unlink, ArrowRight, Trash2 } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync' import { openNotePeek } from '@/lib/note-peek-sync' declare module '@tiptap/core' { interface Storage { liveBlock: { hostNoteId: string | null } } } // --------------------------------------------------------------------------- // LiveBlock Node View // --------------------------------------------------------------------------- function LiveBlockView({ node, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) { const { t } = useLanguage() const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs const [localContent, setLocalContent] = useState(snapshotContent || '') const [isDeleted, setIsDeleted] = useState(false) const [isOffline, setIsOffline] = useState(false) const [pulse, setPulse] = useState(false) const pulseTimerRef = useRef | null>(null) const requestSave = useCallback(() => { const hostNoteId = editor.storage.liveBlock?.hostNoteId if (hostNoteId) { window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, { detail: { noteId: hostNoteId, reason: 'live-block-mutation' }, })) } }, [editor]) // Fetch current block status on mount useEffect(() => { if (!sourceNoteId || !blockId) return fetch(`/api/blocks/${encodeURIComponent(blockId)}/status?sourceNoteId=${sourceNoteId}`) .then(r => r.json()) .then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => { if (!data.exists) { if (snapshotContent?.trim()) { setLocalContent(snapshotContent) return } setIsDeleted(true) } else { setLocalContent(data.content) updateAttributes({ snapshotContent: data.content, sourceNoteTitle: data.sourceNoteTitle }) } }) .catch(() => setIsOffline(true)) }, [sourceNoteId, blockId, snapshotContent, updateAttributes]) // Listen for real-time block update events useEffect(() => { const handleBlockUpdate = (e: CustomEvent) => { if (e.detail?.blockId !== blockId) return setLocalContent(e.detail.content) updateAttributes({ snapshotContent: e.detail.content }) setPulse(true) if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current) pulseTimerRef.current = setTimeout(() => setPulse(false), 1200) } const handleBlockDeleted = (e: CustomEvent) => { if (e.detail?.blockId !== blockId) return setIsDeleted(true) } window.addEventListener('live-block:update', handleBlockUpdate as EventListener) window.addEventListener('live-block:deleted', handleBlockDeleted as EventListener) return () => { window.removeEventListener('live-block:update', handleBlockUpdate as EventListener) window.removeEventListener('live-block:deleted', handleBlockDeleted as EventListener) if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current) } }, [blockId, updateAttributes]) /** Convertit le bloc live en paragraphe local (conserve le texte affiché). */ const handleDetach = useCallback(() => { const pos = getPos() if (typeof pos !== 'number') return const currentNode = editor.state.doc.nodeAt(pos) if (!currentNode) return const text = localContent.trim() const paragraph = editor.state.schema.nodes.paragraph.create( null, text ? editor.state.schema.text(text) : undefined, ) editor.view.dispatch(editor.state.tr.replaceWith(pos, pos + currentNode.nodeSize, paragraph)) editor.commands.focus() requestSave() }, [editor, getPos, localContent, requestSave]) const handleRemove = useCallback(() => { deleteNode() requestSave() }, [deleteNode, requestSave]) const handleOpenSource = useCallback((event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() openNotePeek({ noteId: sourceNoteId, blockId: blockId || undefined }) }, [sourceNoteId, blockId]) const borderClass = isDeleted ? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5' : isOffline ? 'border-l-amber-500 border-y-amber-200 border-r-amber-200 bg-amber-50/10 dark:border-l-amber-600 dark:border-y-amber-800/40 dark:border-r-amber-800/40' : pulse ? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10' : 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5' const headerTitle = isDeleted ? t('liveBlock.sourceDisconnected') : (sourceNoteTitle || t('liveBlock.connectedNote')) const actionButtonClass = 'text-[9.5px] font-bold flex items-center gap-1 transition-all shrink-0' return (
{/* Header */}
{isDeleted ? ( ) : ( )} {headerTitle} {isDeleted ? ( {t('liveBlock.statusDisconnected')} ) : isOffline ? ( {t('liveBlock.statusOffline')} ) : ( {t('liveBlock.statusLive')} )}
{!isDeleted && ( )}
{/* Content */}

{localContent || t('liveBlock.emptyContent')}

) } // --------------------------------------------------------------------------- // TipTap Node Definition // --------------------------------------------------------------------------- export const LiveBlockExtension = Node.create({ name: 'liveBlock', group: 'block', atom: true, draggable: true, selectable: true, addStorage() { return { hostNoteId: null as string | null, } }, addAttributes() { return { sourceNoteId: { default: '' }, blockId: { default: '' }, snapshotContent: { default: '' }, sourceNoteTitle: { default: '' }, } }, parseHTML() { return [{ tag: 'div[data-live-block]' }] }, renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes(HTMLAttributes, { 'data-live-block': 'true' })] }, addNodeView() { return ReactNodeViewRenderer(LiveBlockView) }, })