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:
@@ -1,28 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LiveBlockViewProps {
|
||||
node: {
|
||||
attrs: {
|
||||
sourceNoteId: string
|
||||
blockId: string
|
||||
snapshotContent: string
|
||||
sourceNoteTitle: string
|
||||
}
|
||||
}
|
||||
updateAttributes: (attrs: Record<string, string>) => void
|
||||
deleteNode: () => void
|
||||
}
|
||||
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
|
||||
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)
|
||||
@@ -30,6 +29,15 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
const [pulse, setPulse] = useState(false)
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | 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
|
||||
@@ -37,6 +45,10 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
.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)
|
||||
@@ -44,7 +56,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
})
|
||||
.catch(() => setIsOffline(true))
|
||||
}, [sourceNoteId, blockId])
|
||||
}, [sourceNoteId, blockId, snapshotContent, updateAttributes])
|
||||
|
||||
// Listen for real-time block update events
|
||||
useEffect(() => {
|
||||
@@ -70,14 +82,33 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
}, [blockId, updateAttributes])
|
||||
|
||||
const handleDetach = useCallback(async () => {
|
||||
// Convert this node to a plain paragraph with snapshot text
|
||||
deleteNode()
|
||||
}, [deleteNode])
|
||||
/** 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 handleOpenSource = useCallback(() => {
|
||||
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}, [sourceNoteId])
|
||||
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'
|
||||
@@ -87,54 +118,78 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
? '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 (
|
||||
<NodeViewWrapper>
|
||||
<div className="group/liveblock my-4 not-prose">
|
||||
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isDeleted ? (
|
||||
<AlertCircle size={10} className="text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
|
||||
{headerTitle}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
DÉCONNECTÉ
|
||||
{t('liveBlock.statusDisconnected')}
|
||||
</span>
|
||||
) : isOffline ? (
|
||||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
HORS-LIGNE
|
||||
{t('liveBlock.statusOffline')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
|
||||
LIVE
|
||||
{t('liveBlock.statusLive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<button
|
||||
onClick={handleDetach}
|
||||
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center gap-2 shrink-0 ${isDeleted ? '' : 'opacity-0 group-hover/liveblock:opacity-100'} transition-opacity`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetach}
|
||||
title={t('liveBlock.detachHelp')}
|
||||
className={`${actionButtonClass} ${
|
||||
isDeleted
|
||||
? 'text-rose-600 hover:text-rose-500 dark:text-rose-400 hover:underline'
|
||||
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)] dark:hover:text-[var(--color-dark-ink)]'
|
||||
}`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
{t('liveBlock.detachLink')}
|
||||
</button>
|
||||
{!isDeleted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSource}
|
||||
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
className={`${actionButtonClass} text-blue-600 dark:text-blue-400 hover:underline`}
|
||||
contentEditable={false}
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
{t('liveBlock.openSource')} <ArrowRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
title={t('liveBlock.removeBlock')}
|
||||
className={`${actionButtonClass} text-rose-600/80 hover:text-rose-600 dark:text-rose-400/80 dark:hover:text-rose-400`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
@@ -143,7 +198,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
|
||||
contentEditable={false}
|
||||
>
|
||||
{localContent || '(bloc vide)'}
|
||||
{localContent || t('liveBlock.emptyContent')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,6 +218,12 @@ export const LiveBlockExtension = Node.create({
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
hostNoteId: null as string | null,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourceNoteId: { default: '' },
|
||||
|
||||
Reference in New Issue
Block a user