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:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -0,0 +1,225 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
import { ensureBlockReferenceId } from '@/lib/editor/block-reference-id'
import { rememberBlockReference } from '@/lib/editor/parse-block-reference'
import { toast } from 'sonner'
import {
Trash2, Copy, Repeat, Link, ChevronRight,
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, CodeXml, Database,
} from 'lucide-react'
import { replaceBlockWithDatabase } from '@/components/tiptap-database-block-extension'
interface BlockActionMenuProps {
editor: Editor
onClose: () => void
anchorRect: DOMRect
blockPos: number
blockNode: PMNode | null
noteId?: string
sourceNoteTitle?: string
onBlockReferenceCopied?: (html: string) => void
}
type TurnIntoType =
| 'heading1' | 'heading2' | 'heading3'
| 'bulletList' | 'orderedList' | 'taskList'
| 'blockquote' | 'codeBlock' | 'database'
interface TurnIntoOption {
id: TurnIntoType
icon: typeof Heading1
command?: (editor: Editor) => void
isDatabase?: boolean
}
const TURN_INTO_OPTIONS: TurnIntoOption[] = [
{ id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ id: 'bulletList', icon: List, command: (e) => e.chain().focus().toggleBulletList().run() },
{ id: 'orderedList', icon: ListOrdered, command: (e) => e.chain().focus().toggleOrderedList().run() },
{ id: 'taskList', icon: CheckSquare, command: (e) => e.chain().focus().toggleTaskList().run() },
{ id: 'blockquote', icon: Quote, command: (e) => e.chain().focus().toggleBlockquote().run() },
{ id: 'codeBlock', icon: CodeXml, command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ id: 'database', icon: Database, isDatabase: true },
]
function focusBlock(editor: Editor, blockPos: number) {
const docSize = editor.state.doc.content.size
const cursorPos = Math.min(blockPos + 1, docSize)
editor.chain().focus().setTextSelection(cursorPos).run()
}
function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNode | null): string {
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
if (!node || blockPos < 0) return ''
const from = blockPos + 1
const to = blockPos + node.nodeSize - 1
if (to > from) {
return editor.state.doc.textBetween(from, to, '\n', '\0').trim()
}
return node.textContent?.trim() ?? ''
}
export function BlockActionMenu({
editor,
onClose,
anchorRect,
blockPos,
blockNode,
noteId,
sourceNoteTitle,
onBlockReferenceCopied,
}: BlockActionMenuProps) {
const { t } = useLanguage()
const menuRef = useRef<HTMLDivElement>(null)
const [showTurnInto, setShowTurnInto] = useState(false)
const handleDelete = useCallback(() => {
if (blockNode && blockPos >= 0) {
editor.chain().focus().deleteRange({ from: blockPos, to: blockPos + blockNode.nodeSize }).run()
}
onClose()
}, [editor, blockNode, blockPos, onClose])
const handleDuplicate = useCallback(() => {
if (blockNode && blockPos >= 0) {
const insertPos = blockPos + blockNode.nodeSize
editor.view.dispatch(
editor.state.tr.insert(insertPos, blockNode.copy())
)
editor.commands.focus()
}
onClose()
}, [editor, blockNode, blockPos, onClose])
const handleCopyRef = useCallback(async () => {
if (!noteId?.trim()) {
toast.error(t('blockAction.copyRefNoNote'))
onClose()
return
}
const blockId = ensureBlockReferenceId(editor, blockPos, blockNode)
if (!blockId) {
toast.error(t('blockAction.copyRefUnsupported'))
onClose()
return
}
const html = editor.getHTML()
const blockContent = getBlockPlainContent(editor, blockPos, blockNode)
onBlockReferenceCopied?.(html)
const ref = `${window.location.origin}/home?openNote=${encodeURIComponent(noteId)}#block-${encodeURIComponent(blockId)}`
const copied = await copyTextToClipboard(ref)
if (copied) {
rememberBlockReference(ref, { blockContent, sourceNoteTitle })
toast.success(t('blockAction.copied'))
} else {
toast.error(t('blockAction.copyRefFailed'))
}
onClose()
}, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t])
const handleTurnInto = useCallback((option: TurnIntoOption) => {
if (blockPos >= 0 && blockNode) {
if (option.isDatabase) {
replaceBlockWithDatabase(editor, blockPos, blockNode)
} else if (option.command) {
focusBlock(editor, blockPos)
option.command(editor)
}
}
setShowTurnInto(false)
onClose()
}, [editor, blockNode, blockPos, onClose])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: anchorRect.right + 6,
top: anchorRect.top - 4,
zIndex: 9999,
}
if (Number(menuStyle.left) > window.innerWidth - 220) {
menuStyle.left = anchorRect.left - 210
}
if (Number(menuStyle.top) + 300 > window.innerHeight) {
menuStyle.top = window.innerHeight - 310
}
return createPortal(
<div ref={menuRef} style={menuStyle} className="block-action-menu">
<button type="button" className="block-action-item" onClick={handleDelete}>
<Trash2 size={16} />
<span>{t('blockAction.delete')}</span>
</button>
<button type="button" className="block-action-item" onClick={handleDuplicate}>
<Copy size={16} />
<span>{t('blockAction.duplicate')}</span>
</button>
<div className="block-action-separator" />
<button
type="button"
className="block-action-item block-action-submenu-trigger"
onClick={() => setShowTurnInto(!showTurnInto)}
onMouseEnter={() => setShowTurnInto(true)}
>
<Repeat size={16} />
<span>{t('blockAction.turnInto')}</span>
<ChevronRight size={14} className="ml-auto" />
</button>
{showTurnInto && (
<div className="block-action-submenu">
{TURN_INTO_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
className="block-action-item"
onClick={() => handleTurnInto(opt)}
>
<opt.icon size={16} />
<span>{t(`blockAction.turnInto_${opt.id}`)}</span>
</button>
))}
</div>
)}
<div className="block-action-separator" />
<button type="button" className="block-action-item" onClick={() => { void handleCopyRef() }}>
<Link size={16} />
<span>{t('blockAction.copyRef')}</span>
</button>
</div>,
document.body
)
}

View File

@@ -0,0 +1,255 @@
'use client'
import { useState, useCallback } from 'react'
import { Trash2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import {
type DatabaseBlockData,
randomDefaultCover,
} from '@/lib/editor/database-block-types'
interface DatabaseBlockEditorProps {
data: DatabaseBlockData
readOnly?: boolean
onChange: (data: DatabaseBlockData) => void
}
export function DatabaseBlockEditor({ data, readOnly, onChange }: DatabaseBlockEditorProps) {
const { t } = useLanguage()
const { dbId, dbView, dbAuthors, dbBooks } = data
const [newBookTitle, setNewBookTitle] = useState('')
const [newBookAuthor, setNewBookAuthor] = useState('')
const [newBookTag, setNewBookTag] = useState('')
const [newBookCover, setNewBookCover] = useState('')
const [newAuthorName, setNewAuthorName] = useState('')
const patch = useCallback((partial: Partial<DatabaseBlockData>) => {
onChange({ ...data, ...partial })
}, [data, onChange])
const handleAddAuthor = useCallback(() => {
const name = newAuthorName.trim()
if (!name) return
if (dbAuthors.some((a) => a.name.toLowerCase() === name.toLowerCase())) return
patch({
dbAuthors: [...dbAuthors, { id: `auth-${Date.now()}`, name }],
})
setNewAuthorName('')
}, [dbAuthors, newAuthorName, patch])
const handleAddBook = useCallback((e: React.FormEvent) => {
e.preventDefault()
const title = newBookTitle.trim()
let author = newBookAuthor.trim()
if (!title || !author) return
let nextAuthors = [...dbAuthors]
if (author === '__new__') return
if (!nextAuthors.some((a) => a.name.toLowerCase() === author.toLowerCase())) {
nextAuthors = [...nextAuthors, { id: `auth-${Date.now()}`, name: author }]
}
patch({
dbAuthors: nextAuthors,
dbBooks: [
...dbBooks,
{
id: `bk-${Date.now()}`,
title,
author,
cover: newBookCover.trim() || randomDefaultCover(),
tag: newBookTag.trim() || t('databaseBlock.defaultTag'),
},
],
})
setNewBookTitle('')
setNewBookAuthor('')
setNewBookTag('')
setNewBookCover('')
}, [dbAuthors, dbBooks, newBookAuthor, newBookCover, newBookTag, newBookTitle, patch, t])
const handleDeleteBook = useCallback((id: string) => {
patch({ dbBooks: dbBooks.filter((b) => b.id !== id) })
}, [dbBooks, patch])
const handleDeleteAuthor = useCallback((id: string) => {
patch({ dbAuthors: dbAuthors.filter((a) => a.id !== id) })
}, [dbAuthors, patch])
return (
<div className="database-block not-prose my-4 text-left">
<div className="database-block__inner">
<div className="database-block__header">
<div className="flex items-center gap-2 min-w-0">
<span className="text-lg shrink-0" aria-hidden>📚</span>
<div className="min-w-0">
<span className="database-block__title">{t('databaseBlock.title')}</span>
<span className="database-block__id">id: {dbId}</span>
</div>
</div>
{!readOnly && (
<div className="database-block__view-toggle">
<button
type="button"
onClick={() => patch({ dbView: 'table' })}
className={dbView === 'table' ? 'is-active' : ''}
>
{t('databaseBlock.viewTable')}
</button>
<button
type="button"
onClick={() => patch({ dbView: 'card' })}
className={dbView === 'card' ? 'is-active' : ''}
>
{t('databaseBlock.viewCards')}
</button>
</div>
)}
</div>
<p className="database-block__hint">{t('databaseBlock.hint')}</p>
{dbView === 'table' ? (
<div className="space-y-4">
<div className="database-block__table-wrap">
<table className="database-block__table">
<thead>
<tr>
<th>{t('databaseBlock.colAuthor')}</th>
<th>{t('databaseBlock.colWorks')}</th>
<th className="text-right">{t('databaseBlock.colRollup')}</th>
{!readOnly && <th className="w-12" />}
</tr>
</thead>
<tbody>
{dbAuthors.map((auth) => {
const authorBooks = dbBooks.filter(
(b) => b.author.toLowerCase() === auth.name.toLowerCase(),
)
const worksStr = authorBooks.map((b) => b.title).join(', ')
|| t('databaseBlock.noLinkedWorks')
return (
<tr key={auth.id}>
<td className="font-semibold">{auth.name}</td>
<td className="database-block__works-cell" title={worksStr}>{worksStr}</td>
<td className="database-block__rollup">{authorBooks.length}</td>
{!readOnly && (
<td className="text-center">
<button
type="button"
onClick={() => handleDeleteAuthor(auth.id)}
className="database-block__delete-btn"
>
{t('databaseBlock.deleteShort')}
</button>
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
{!readOnly && (
<div className="database-block__inline-form">
<span className="database-block__form-label">{t('databaseBlock.addAuthor')}</span>
<input
type="text"
placeholder={t('databaseBlock.authorPlaceholder')}
value={newAuthorName}
onChange={(e) => setNewAuthorName(e.target.value)}
className="database-block__input flex-1"
/>
<button type="button" onClick={handleAddAuthor} className="database-block__primary-btn">
{t('databaseBlock.createAuthor')}
</button>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{dbBooks.map((book) => (
<div key={book.id} className="database-block__card group/book">
{!readOnly && (
<button
type="button"
onClick={() => handleDeleteBook(book.id)}
className="database-block__card-delete"
title={t('databaseBlock.deleteCard')}
>
<Trash2 size={11} />
</button>
)}
<div className="database-block__card-cover">
<img src={book.cover} alt={book.title} referrerPolicy="no-referrer" />
</div>
<div className="database-block__card-body">
<p className="database-block__card-title" title={book.title}>{book.title}</p>
<div className="flex flex-wrap gap-1 pt-1">
<span className="database-block__tag database-block__tag--author">{book.author}</span>
<span className="database-block__tag database-block__tag--genre">{book.tag}</span>
</div>
</div>
</div>
))}
<div className="database-block__card-placeholder">
<span className="text-xl mb-1" aria-hidden>📖</span>
<span className="font-bold text-[10px] uppercase tracking-widest">{t('databaseBlock.worksBase')}</span>
<span className="text-[8.5px] text-zinc-400 mt-1">
{t('databaseBlock.storedCount', { count: dbBooks.length })}
</span>
</div>
</div>
)}
{!readOnly && (
<form onSubmit={handleAddBook} className="database-block__book-form">
<span className="database-block__form-heading">{t('databaseBlock.addWork')}</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
placeholder={t('databaseBlock.bookTitlePlaceholder')}
value={newBookTitle}
onChange={(e) => setNewBookTitle(e.target.value)}
className="database-block__input"
required
/>
<select
value={newBookAuthor}
onChange={(e) => setNewBookAuthor(e.target.value)}
className="database-block__input"
required
>
<option value="">{t('databaseBlock.selectAuthor')}</option>
{dbAuthors.map((auth) => (
<option key={auth.id} value={auth.name}>{auth.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
placeholder={t('databaseBlock.tagPlaceholder')}
value={newBookTag}
onChange={(e) => setNewBookTag(e.target.value)}
className="database-block__input"
/>
<input
type="text"
placeholder={t('databaseBlock.coverPlaceholder')}
value={newBookCover}
onChange={(e) => setNewBookCover(e.target.value)}
className="database-block__input"
/>
</div>
<button type="submit" className="database-block__submit">
{t('databaseBlock.insertWork')}
</button>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
/**
* Poignée drag globale (Novel / tiptap-extension-global-drag-handle).
* Lextension TipTap positionne cet élément ; le clic ouvre le menu bloc.
*/
import { memo, useEffect, useRef } from 'react'
import type { Editor } from '@tiptap/core'
import { BLOCK_DRAG_HANDLE_ID, resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
type EditorBlockDragHandleProps = {
editor: Editor | null
onOpenMenu: (anchorRect: DOMRect) => void
}
export const EditorBlockDragHandle = memo(function EditorBlockDragHandle({
editor,
onOpenMenu,
}: EditorBlockDragHandleProps) {
const dragStartedRef = useRef(false)
const onOpenMenuRef = useRef(onOpenMenu)
onOpenMenuRef.current = onOpenMenu
useEffect(() => {
if (!editor || editor.isDestroyed) return
const el = document.getElementById(BLOCK_DRAG_HANDLE_ID)
if (!el) return
const onPointerDown = () => {
dragStartedRef.current = false
}
const onDragStart = () => {
dragStartedRef.current = true
}
const onClick = (e: MouseEvent) => {
if (dragStartedRef.current) {
dragStartedRef.current = false
return
}
e.preventDefault()
e.stopPropagation()
const block = resolveBlockAtDragHandle(editor)
if (!block) return
onOpenMenuRef.current(el.getBoundingClientRect())
}
el.addEventListener('pointerdown', onPointerDown)
el.addEventListener('dragstart', onDragStart)
el.addEventListener('click', onClick)
return () => {
el.removeEventListener('pointerdown', onPointerDown)
el.removeEventListener('dragstart', onDragStart)
el.removeEventListener('click', onClick)
}
}, [editor])
return (
<div
id={BLOCK_DRAG_HANDLE_ID}
className="drag-handle hide"
role="button"
tabIndex={-1}
aria-hidden="true"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
<circle cx="4" cy="3" r="1.2" />
<circle cx="10" cy="3" r="1.2" />
<circle cx="4" cy="7" r="1.2" />
<circle cx="10" cy="7" r="1.2" />
<circle cx="4" cy="11" r="1.2" />
<circle cx="10" cy="11" r="1.2" />
</svg>
</div>
)
})

View File

@@ -5,14 +5,11 @@ import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
import {
NOTES_LAYOUT_STORAGE_KEY,
NOTES_VIEW_TYPE_STORAGE_KEY,
parseNotesLayoutMode,
parseNotesViewType,
setNotesLayoutPreference,
setNotesViewTypePreference,
} from '@/lib/notes-view-preference'
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
import {
@@ -87,14 +84,12 @@ interface HomeClientProps {
initialNotes: Note[]
initialSettings: InitialSettings
initialLayoutMode?: NotesLayoutMode
initialViewType?: NotesViewType
}
export function HomeClient({
initialNotes,
initialSettings,
initialLayoutMode = 'list',
initialViewType = 'notes',
}: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
@@ -136,7 +131,6 @@ export function HomeClient({
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
@@ -164,20 +158,11 @@ export function HomeClient({
useEffect(() => {
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
if (storedLayout !== initialLayoutMode) {
setLayoutMode(storedLayout)
setNotesLayoutPreference(storedLayout)
}
if (storedViewType !== initialViewType) {
setViewType(storedViewType)
setNotesViewTypePreference(storedViewType)
}
}, [initialLayoutMode, initialViewType])
useEffect(() => {
setNotesViewTypePreference(viewType)
}, [viewType])
}, [initialLayoutMode])
useEffect(() => {
setNotesLayoutPreference(layoutMode)
@@ -768,17 +753,20 @@ export function HomeClient({
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
'flex w-full min-h-0 flex-1 flex-col',
editingNote ? 'h-full overflow-hidden' : 'gap-3 py-1'
)}
>
{editingNote ? (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
onNoteSaved={handleNoteSaved}
fullPage
/>
<div className="flex flex-1 min-h-0 h-full w-full overflow-hidden">
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
onNoteSaved={handleNoteSaved}
fullPage
/>
</div>
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div
@@ -941,35 +929,7 @@ export function HomeClient({
)}
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30">
<button
type="button"
onClick={() => setViewType('notes')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'notes'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewNotes')}
</button>
<button
type="button"
onClick={() => setViewType('tasks')}
className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
viewType === 'tasks'
? 'bg-foreground text-background shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t('notes.viewTasks')}
</button>
</div>
{viewType === 'notes' && (
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
<button
type="button"
onClick={() => selectLayoutMode('grid')}
@@ -1043,9 +1003,8 @@ export function HomeClient({
</>
)}
</div>
)}
{viewType === 'notes' && currentNotebook && structuredModeActive && (
{currentNotebook && structuredModeActive && (
<button
type="button"
onClick={() => setAddPropertyOpen(true)}
@@ -1207,7 +1166,6 @@ export function HomeClient({
<NotesListViews
notes={sortedNotes}
pinnedNotes={sortedPinnedNotes}
viewType={viewType}
layoutMode={classicLayoutMode}
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
onOpenHistory={handleOpenHistory}

View File

@@ -1,8 +1,9 @@
'use client'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteEditorProvider } from './note-editor-context'
import { NoteEditorFullPage } from './note-editor-full-page'
import { NoteEditorDialog } from './note-editor-dialog'
import { NoteEditorPeekHost } from './note-editor-peek-host'
import { Note } from '@/lib/types'
interface NoteEditorProps {
@@ -16,11 +17,13 @@ interface NoteEditorProps {
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
<NoteEditorPeekHost noteId={note.id} fullPage={fullPage}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
</NoteEditorPeekHost>
</NoteEditorProvider>
)
}
@@ -35,4 +38,4 @@ export { NoteEditorProvider } from './note-editor-context'
export { NoteTitleBlock } from './note-title-block'
export { NoteContentArea } from './note-content-area'
export { NoteMetadataSection } from './note-metadata-section'
export { NoteEditorToolbar } from './note-editor-toolbar'
export { NoteEditorToolbar } from './note-editor-toolbar'

View File

@@ -107,6 +107,7 @@ export function NoteContentArea() {
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
sourceUrl={note.sourceUrl}
/>
</div>
@@ -122,6 +123,7 @@ export function NoteContentArea() {
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}
noteTitle={state.title || note.title}
sourceUrl={note.sourceUrl}
/>
<GhostTags

View File

@@ -25,14 +25,12 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
import { MemoryEchoSection } from '@/components/memory-echo-section'
import { useRouter } from 'next/navigation'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const router = useRouter()
const { t, language } = useLanguage()
const { requestAiConsent } = useAiConsent()
const dateLocale = language === 'fr' ? fr : enUS
@@ -66,10 +64,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
return (
<>
{/* ── outer container ── */}
<div className="h-screen flex items-stretch overflow-hidden transition-all duration-500">
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />

View File

@@ -0,0 +1,90 @@
'use client'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useRouter, useSearchParams } from 'next/navigation'
import { toast } from 'sonner'
import type { Note } from '@/lib/types'
import { getNoteById } from '@/app/actions/notes'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import {
NOTE_PEEK_OPEN_EVENT,
NOTE_PEEK_CLOSE_EVENT,
type NotePeekOpenDetail,
} from '@/lib/note-peek-sync'
import { NoteEditorSplitPeek } from './note-editor-split-peek'
import { useLanguage } from '@/lib/i18n'
interface NoteEditorPeekHostProps {
noteId: string
fullPage?: boolean
children: ReactNode
}
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
useEffect(() => {
const onOpenPeek = (event: Event) => {
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
if (!detail?.noteId) return
if (detail.noteId === noteId) return
void getNoteById(detail.noteId).then((fetched) => {
if (fetched) {
setPeekState({ note: fetched, blockId: detail.blockId })
} else {
toast.error(t('notePeek.loadFailed'))
}
})
}
const onClosePeek = () => setPeekState(null)
window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
return () => {
window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
}
}, [noteId, t])
const handleClosePeek = useCallback(() => {
setPeekState(null)
}, [])
const handleOpenPeekFully = useCallback(() => {
if (!peekState) return
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'before-peek-full-open' },
}))
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', peekState.note.id)
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
setPeekState(null)
}, [noteId, peekState, router, searchParams])
const shellClass = fullPage
? 'flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden'
: 'relative flex min-h-0 flex-1 flex-col overflow-hidden'
return (
<div className={`${shellClass}${peekState ? (isRtl ? ' flex-row-reverse' : ' flex-row') : ''}`}>
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">{children}</div>
<AnimatePresence initial={false}>
{peekState && (
<NoteEditorSplitPeek
key={peekState.note.id}
note={peekState.note}
blockId={peekState.blockId}
onClose={handleClosePeek}
onOpenFully={handleOpenPeekFully}
/>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import { useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { X, Maximize2 } from 'lucide-react'
import type { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NoteEditorSplitPeekProps {
note: Note
blockId?: string
onClose: () => void
onOpenFully: () => void
}
function PeekEditorBody({ blockId }: { blockId?: string }) {
const { note } = useNoteEditorContext()
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const scrollRootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!blockId) return
const timer = window.setTimeout(() => {
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(blockId) : blockId
const el = scrollRootRef.current?.querySelector(`[data-id="${escaped}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 450)
return () => window.clearTimeout(timer)
}, [blockId, note.id])
return (
<div ref={scrollRootRef} className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-10 space-y-8 pb-24">
<p
className="text-[10px] uppercase tracking-[.25em] font-bold text-[var(--color-concrete)]"
suppressHydrationWarning
>
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
</p>
<NoteTitleBlock />
<div className="max-w-xl mx-auto w-full">
<NoteContentArea />
</div>
<p className="text-[11px] text-[var(--color-concrete)] italic">{t('notePeek.readOnlyHint')}</p>
</div>
</div>
)
}
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
return (
<motion.aside
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 340, damping: 34 }}
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
isRtl
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
}`}
aria-label={t('notePeek.panelLabel')}
>
<NoteEditorProvider note={note} readOnly fullPage>
<div className="shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--color-concrete)] truncate">
{t('notePeek.label')}
</span>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onOpenFully}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors"
title={t('notePeek.openFullyHelp')}
>
<Maximize2 size={12} />
{t('notePeek.openFully')}
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-[var(--color-concrete)] hover:text-[var(--color-ink)] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={t('notePeek.close')}
aria-label={t('notePeek.close')}
>
<X size={16} />
</button>
</div>
</div>
<PeekEditorBody blockId={blockId} />
</NoteEditorProvider>
</motion.aside>
)
}

View File

@@ -27,6 +27,7 @@ import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated'
type NotesEditorialViewProps = {
notes: Note[]
@@ -364,6 +365,7 @@ export function NotesEditorialView({
const { t, language } = useLanguage()
const { data: session } = useSession()
const { data: allLabels } = useLabelsQuery()
const hydrated = useHydrated()
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
useEffect(() => {
@@ -388,9 +390,9 @@ export function NotesEditorialView({
return (
<motion.article
key={note.id}
initial={{ opacity: 0, y: 20 }}
initial={hydrated ? { opacity: 0, y: 20 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * index, duration: 0.6 }}
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
className="space-y-4 group cursor-pointer relative pb-8"
onClick={() => onOpen(note)}
>

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
import { useMemo, useState, useEffect, useCallback } from 'react'
import {
DndContext,
DragOverlay,
@@ -28,7 +28,6 @@ import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNote
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { useLabelsQuery } from '@/lib/query-hooks'
import { updateNote } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import { LabelBadge } from '@/components/label-badge'
@@ -39,8 +38,6 @@ import { toast } from 'sonner'
import {
Pin,
FileText,
Link2,
CheckSquare,
ChevronUp,
ChevronDown,
Wind,
@@ -50,6 +47,7 @@ import {
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
@@ -60,52 +58,6 @@ export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
return mode === 'grid' || mode === 'list' || mode === 'table'
}
export type NotesViewType = 'notes' | 'tasks'
type TaskItem = {
id: string
noteId: string
noteTitle: string
text: string
completed: boolean
lineIndex: number
}
function getNoteTasksStats(content: string) {
const lines = (content || '').split('\n')
let total = 0
let completed = 0
for (const line of lines) {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
total++
if (match[1].toLowerCase() === 'x') completed++
}
}
return { completed, total }
}
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
const tasks: TaskItem[] = []
for (const note of notes) {
const title = note.title?.trim() || 'Sans titre'
const lines = (note.content || '').split('\n')
lines.forEach((line, idx) => {
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
if (match) {
tasks.push({
id: `${note.id}-${idx}`,
noteId: note.id,
noteTitle: title,
text: match[2].trim(),
completed: match[1].toLowerCase() === 'x',
lineIndex: idx,
})
}
})
}
return tasks
}
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
const colors = [
@@ -247,7 +199,6 @@ export type { NoteCollectionActions } from '@/lib/note-change-sync'
type NotesListViewsProps = {
notes: Note[]
pinnedNotes?: Note[]
viewType: NotesViewType
layoutMode: NotesLayoutMode
onOpen: (note: Note, readOnly?: boolean) => void
onOpenHistory?: (note: Note) => void
@@ -258,7 +209,6 @@ type NotesListViewsProps = {
export function NotesListViews({
notes,
pinnedNotes = [],
viewType,
layoutMode,
onOpen,
onOpenHistory,
@@ -275,8 +225,7 @@ export function NotesListViews({
const { data: session } = useSession()
const { notebooks } = useNotebooks()
const { data: allLabels = [] } = useLabelsQuery()
const [, startTransition] = useTransition()
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'modified' | null>(null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
@@ -298,24 +247,7 @@ export function NotesListViews({
return [...pinnedNotes, ...unpinned]
}, [notes, pinnedNotes])
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
const completedTasksCount = extractTasks.filter((task) => task.completed).length
const handleToggleTask = (task: TaskItem) => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (!note) return
const lines = (note.content || '').split('\n')
const line = lines[task.lineIndex]
if (!line) return
const nextChar = task.completed ? ' ' : 'x'
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
startTransition(async () => {
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
onNotePatch?.(note.id, { content: lines.join('\n') })
})
}
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
const handleSort = (field: 'title' | 'notebook' | 'modified') => {
if (sortColumn !== field) {
setSortColumn(field)
setSortDirection('asc')
@@ -339,9 +271,6 @@ export function NotesListViews({
} else if (sortColumn === 'notebook') {
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
} else if (sortColumn === 'tasks') {
valA = getNoteTasksStats(a.content || '').completed
valB = getNoteTasksStats(b.content || '').completed
} else {
valA = new Date(a.updatedAt).getTime()
valB = new Date(b.updatedAt).getTime()
@@ -357,86 +286,6 @@ export function NotesListViews({
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
) : null
if (viewType === 'tasks') {
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
{t('notes.tasksHeader')}
</span>
</div>
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
{t('notes.tasksSummary')
.replace('{count}', String(extractTasks.length))
.replace('{completed}', String(completedTasksCount))}
</div>
</div>
{extractTasks.length > 0 ? (
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
<div className="divide-y divide-foreground/[0.04]">
{extractTasks.map((task) => (
<div
key={task.id}
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
>
<div className="flex items-center gap-3.5 flex-grow min-w-0">
<button
type="button"
onClick={() => handleToggleTask(task)}
className={cn(
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
task.completed
? 'bg-brand-accent border-brand-accent text-white'
: 'border-border hover:border-brand-accent/60 bg-transparent',
)}
>
{task.completed && <span className="text-xs font-bold"></span>}
</button>
<span
className={cn(
'text-[13px] font-light leading-relaxed truncate',
task.completed && 'line-through text-muted-foreground',
)}
>
{task.text}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
</span>
<button
type="button"
onClick={() => {
const note = allDisplayNotes.find((n) => n.id === task.noteId)
if (note) onOpen(note)
}}
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
title={t('notes.openSourceNote')}
>
<Link2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
) : (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
<CheckSquare size={18} className="text-muted-foreground/60" />
</div>
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
</div>
)}
</div>
)
}
if (layoutMode === 'grid') {
return (
<NotesMasonryGrid
@@ -484,15 +333,7 @@ export function NotesListViews({
{t('notes.tableLabels')}
</th>
<th
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('tasks')}
>
<span className="inline-flex items-center gap-1">
{t('notes.tableTasks')} <SortIcon field="tasks" />
</span>
</th>
<th
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => handleSort('modified')}
>
<span className="inline-flex items-center gap-1">
@@ -506,7 +347,6 @@ export function NotesListViews({
const title = getNoteDisplayTitle(note, untitled)
const nb = notebooks.find((n) => n.id === note.notebookId)
const nbColor = getNotebookColor(note.notebookId, nb?.name)
const stats = getNoteTasksStats(note.content || '')
return (
<tr
key={note.id}
@@ -538,15 +378,6 @@ export function NotesListViews({
<td className="px-4 py-2">
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
</td>
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
{stats.total > 0 ? (
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
{stats.completed}/{stats.total} <span className="text-[9px] font-sans"></span>
</span>
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
</td>
@@ -834,9 +665,9 @@ function GridCard({
}: GridCardSharedProps) {
const router = useRouter()
const { t, language } = useLanguage()
const hydrated = useHydrated()
const title = getNoteDisplayTitle(note, untitled)
const excerpt = getNotePlainExcerpt(note, 110)
const stats = getNoteTasksStats(note.content || '')
const formattedDate = formatGridCardDate(note.updatedAt, language)
const handlePinClick = (e: React.MouseEvent) => {
@@ -857,9 +688,9 @@ function GridCard({
return (
<motion.div
initial={isOverlay ? false : { opacity: 0, y: 15 }}
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
transition={isOverlay || !hydrated ? undefined : { delay: 0.04 * index, duration: 0.5 }}
onClick={() => onOpen(note)}
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
>
@@ -874,11 +705,6 @@ function GridCard({
<Pin size={11} className="fill-amber-500" />
</div>
)}
{stats.total > 0 && (
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
{stats.completed}/{stats.total}
</div>
)}
</div>
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
<div className="space-y-2.5 flex-1">

View File

@@ -25,13 +25,25 @@ import { ChartExtension } from './tiptap-chart-extension'
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
import { EditorBlockDragHandle } from './editor-block-drag-handle'
import { BlockActionMenu } from './block-action-menu'
import { SmartPasteMenu } from './smart-paste-menu'
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { openNotePeek } from '@/lib/note-peek-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
@@ -42,7 +54,7 @@ import {
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -64,6 +76,7 @@ interface RichTextEditorProps {
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
noteId?: string
noteTitle?: string
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
sourceUrl?: string | null
}
@@ -86,7 +99,7 @@ type SlashItem = {
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
@@ -176,6 +189,10 @@ const slashCommands: SlashItem[] = [
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
}
},
{
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertDatabaseBlockAtSelection(e) },
},
]
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
@@ -233,11 +250,28 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
const [blockMenuState, setBlockMenuState] = useState<{
anchor: DOMRect
pos: number
node: PMNode | null
} | null>(null)
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
const smartPastePendingRef = useRef<{
reference: ParsedBlockReference
blockPos: number
blockNode: PMNode
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
} | null>(null)
const [smartPasteMenu, setSmartPasteMenu] = useState<{
anchor: { top: number; left: number }
reference: ParsedBlockReference
sourceNoteTitle?: string
} | null>(null)
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
const [noteLinkQuery, setNoteLinkQuery] = useState('')
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
@@ -355,16 +389,38 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
Typography,
ChartExtension,
UniqueIdExtension,
...globalDragHandleExtensions,
SmartPasteExtension,
LiveBlockExtension,
DatabaseBlockExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
],
content: content || '',
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: { class: 'notion-editor' },
attributes: { class: 'notion-editor tiptap' },
handleDOMEvents: {
keydown: (view, event) => {
if (event.defaultPrevented) return false
if (event.key !== 'Enter' && event.key !== ' ') return false
const { from, empty } = view.state.selection
if (!empty) return false
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
if (!/\/(database|db)$/i.test(textBefore)) return false
event.preventDefault()
const slashIdx = textBefore.lastIndexOf('/')
const deleteFrom = from - (textBefore.length - slashIdx)
const ed = editorInstanceRef.current
if (!ed) return false
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
if (!insertDatabaseBlockAtSelection(ed)) {
toast.error(t('databaseBlock.insertFailed'))
}
return true
},
click: (_view, event) => {
const link = (event.target as HTMLElement).closest('a[href]')
if (!link) return false
@@ -373,11 +429,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (noteIdMatch) {
event.preventDefault()
event.stopPropagation()
window.open(
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
'_blank',
'noopener,noreferrer'
)
const targetId = decodeURIComponent(noteIdMatch[1])
const blockMatch = href.match(/#block-([^&#]+)/)
openNotePeek({
noteId: targetId,
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
})
return true
}
if (href.startsWith('http://') || href.startsWith('https://')) {
@@ -405,11 +462,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
try {
toast.info(t('notes.uploading'))
const url = await onImageUpload(file)
const editorInstance = editorInstanceRef.current
if (!editorInstance) continue
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
const ed = editorInstanceRef.current
if (!ed) continue
const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(editorInstance.getHTML())
emitContentChange(ed.getHTML())
}
} catch {
toast.error(t('notes.uploadFailed'))
@@ -447,6 +504,80 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
editorInstanceRef.current = editor ?? null
}, [editor])
useEffect(() => {
if (!editor) return
editor.storage.liveBlock.hostNoteId = noteId ?? null
}, [editor, noteId])
useEffect(() => {
if (!editor) return
editor.storage.smartPaste.onPaste = (view, event) => {
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
let blockRef = parseBlockReferenceFromText(clipboardText)
if (!blockRef) {
blockRef = recallLastBlockReference()
}
if (!blockRef) return false
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
if (!emptyParagraph) return false
event.preventDefault()
event.stopPropagation()
const coords = view.coordsAtPos(view.state.selection.from)
smartPastePendingRef.current = {
reference: blockRef,
blockPos: emptyParagraph.pos,
blockNode: emptyParagraph.node,
}
queueMicrotask(() => {
setSmartPasteMenu({
anchor: { top: coords.bottom, left: coords.left },
reference: blockRef,
})
})
void fetch(
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
)
.then((res) => (res.ok ? res.json() : null))
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
const recalled = recallLastBlockReference()
const sessionFallback =
recalled?.raw === blockRef.raw
? {
content: recalled.blockContent?.trim() ?? '',
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
}
: { content: '', sourceNoteTitle: '' }
const exists = Boolean(data?.exists) || sessionFallback.content.length > 0
const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '')
const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || ''
smartPastePendingRef.current!.blockStatus = {
exists,
content,
sourceNoteTitle,
}
setSmartPasteMenu((prev) =>
prev?.reference.raw === blockRef.raw
? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle }
: prev,
)
})
.catch(() => {})
return true
}
return () => {
editor.storage.smartPaste.onPaste = null
}
}, [editor])
// Chart suggestions dialog state
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
@@ -620,6 +751,159 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
handleSelectBlockRef.current = handleSelectBlock
const openBlockActionMenu = useCallback((anchorRect: DOMRect) => {
if (!editor) return
const block = resolveBlockAtDragHandle(editor)
if (!block) return
dragBlockRef.current = block
setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node })
}, [editor])
const closeBlockActionMenu = useCallback(() => {
setBlockMenuState(null)
}, [])
const closeSmartPasteMenu = useCallback(() => {
smartPastePendingRef.current = null
setSmartPasteMenu(null)
}, [])
const handleBlockReferenceCopied = useCallback((html: string) => {
emitContentChange(html)
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'block-reference-copy' },
}))
}
}, [emitContentChange, noteId])
const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => {
const cached = smartPastePendingRef.current?.blockStatus
if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) {
return cached
}
const recalled = recallLastBlockReference()
const sessionFallback =
recalled?.raw === reference.raw
? {
content: recalled.blockContent?.trim() ?? '',
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
}
: { content: '', sourceNoteTitle: '' }
const res = await fetch(
`/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`,
)
if (!res.ok) {
return {
exists: sessionFallback.content.length > 0,
content: sessionFallback.content,
sourceNoteTitle: sessionFallback.sourceNoteTitle,
}
}
const data = await res.json()
if (data.exists) {
return {
exists: true,
content: data.content ?? '',
sourceNoteTitle: data.sourceNoteTitle ?? '',
}
}
return {
exists: sessionFallback.content.length > 0,
content: sessionFallback.content || data.content || '',
sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '',
}
}, [])
const handleSmartPasteLive = useCallback(async () => {
const pending = smartPastePendingRef.current
const ed = editorInstanceRef.current
if (!pending || !ed) {
closeSmartPasteMenu()
return
}
const status = await fetchBlockStatus(pending.reference)
if (noteId) {
try {
await fetch('/api/blocks/embed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceNoteId: pending.reference.sourceNoteId,
blockId: pending.reference.blockId,
targetNoteId: noteId,
}),
})
} catch {
// Non-fatal
}
}
const liveBlockType = ed.schema.nodes.liveBlock
if (liveBlockType) {
ed.chain()
.focus()
.command(({ tr, dispatch }) => {
const node = liveBlockType.create({
sourceNoteId: pending.reference.sourceNoteId,
blockId: pending.reference.blockId,
snapshotContent: status.content,
sourceNoteTitle: status.sourceNoteTitle,
})
tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node)
if (dispatch) dispatch(tr)
return true
})
.run()
emitContentChange(ed.getHTML())
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'smart-paste-live-block' },
}))
}
}
closeSmartPasteMenu()
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId])
const handleSmartPastePlain = useCallback(async () => {
const pending = smartPastePendingRef.current
const ed = editorInstanceRef.current
if (!pending || !ed) {
closeSmartPasteMenu()
return
}
const status = await fetchBlockStatus(pending.reference)
const linkHref = pending.reference.raw.match(/^https?:\/\//)
? pending.reference.raw
: `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}`
const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw
ed.chain()
.focus()
.insertContent({
type: 'text',
text: linkText,
marks: [{
type: 'link',
attrs: {
href: linkHref,
target: '_blank',
rel: 'noopener noreferrer',
},
}],
})
.run()
emitContentChange(ed.getHTML())
closeSmartPasteMenu()
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
@@ -645,8 +929,34 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
<EditorContent editor={editor} />
{editor && blockMenuState && (
<BlockActionMenu
editor={editor}
anchorRect={blockMenuState.anchor}
blockPos={blockMenuState.pos}
blockNode={blockMenuState.node}
noteId={noteId}
sourceNoteTitle={noteTitle}
onBlockReferenceCopied={handleBlockReferenceCopied}
onClose={closeBlockActionMenu}
/>
)}
{smartPasteMenu && (
<SmartPasteMenu
anchor={smartPasteMenu.anchor}
reference={smartPasteMenu.reference}
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
onLive={() => { void handleSmartPasteLive() }}
onPlain={() => { void handleSmartPastePlain() }}
onClose={closeSmartPasteMenu}
/>
)}
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
@@ -973,6 +1283,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), 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: 'basic' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1021,6 +1332,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
finally { setAiLoading(false) }
} else if (item.title === 'Suggest Charts') {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
}
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
@@ -1029,7 +1345,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
const q = query.toLowerCase()
const textFiltered = localCommands.filter(c =>
c.title.toLowerCase().includes(q)
|| c.description.toLowerCase().includes(q)
|| (c.shortcut?.toLowerCase().includes(q) ?? false)
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false)
)
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
@@ -1070,7 +1392,15 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (e.key === 'Enter') {
e.preventDefault()
const item = filtered[selectedIndex]
if (item) handleSelect(item)
if (item) {
handleSelect(item)
} else if (/^(database|db)$/i.test(query)) {
deleteSlashText()
closeMenu()
if (!insertDatabaseBlockAtSelection(editor)) {
toast.error(t('databaseBlock.insertFailed'))
}
}
return
}
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }

View File

@@ -40,7 +40,7 @@ import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
import { getAllNotes, getTrashCount, getNotesWithReminders } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
@@ -60,7 +60,7 @@ import { performSignOut } from '@/lib/auth-client'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
import { UsageMeter } from './usage-meter'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
function NoteLink({
@@ -175,6 +175,96 @@ function SidebarBrainstorms() {
)
}
function SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
const { t } = useLanguage()
const [reminders, setReminders] = useState<
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
getNotesWithReminders()
.then((rows) => {
if (!cancelled) setReminders(rows as typeof reminders)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
if (loading) {
return (
<div className="px-4 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />
))}
</div>
)
}
const now = new Date()
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
const overdue = active.filter((r) => new Date(r.reminder!) < now)
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
if (active.length === 0) {
return (
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
</div>
)
}
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
<button
key={note.id}
type="button"
onClick={() => onOpenNote(note.id, note.notebookId)}
className="w-full text-start px-4 py-2.5 rounded-xl hover:bg-brand-accent/5 transition-colors group"
>
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">
{note.title || t('notes.untitled')}
</p>
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
{note.reminder &&
new Date(note.reminder).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</button>
)
return (
<div className="space-y-4 pb-2">
{overdue.length > 0 && (
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">
{t('reminders.overdue')}
</p>
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
</div>
)}
{upcoming.length > 0 && (
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">
{t('reminders.upcoming')}
</p>
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
</div>
)}
</div>
)
}
function SidebarCarnetItem({
carnet,
isActive,
@@ -536,8 +626,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
useEffect(() => {
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
else setActiveView('notebooks')
}, [pathname])
else if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
const isNotebooksRoute =
(pathname === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') &&
!isRemindersRoute &&
!isSharedRoute
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
@@ -645,6 +746,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
router.push(`/home?${params.toString()}`)
}
const handleRemindersClick = () => {
setActiveView('reminders')
router.push('/home?reminders=1&forceList=1')
}
const handleReminderNoteClick = (noteId: string, notebookId: string | null) => {
const params = new URLSearchParams()
params.set('reminders', '1')
if (notebookId) params.set('notebook', notebookId)
params.set('openNote', noteId)
router.push(`/home?${params.toString()}`)
}
const handleInboxClick = () => {
router.push('/home?forceList=1')
}
@@ -972,14 +1086,59 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{/* Boutons de navigation principaux */}
<div className="flex flex-col gap-1.5 w-full px-1.5">
{([
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
{
id: 'notebooks',
icon: BookOpen,
label: t('nav.notebooks'),
onClick: () => {
setActiveView('notebooks')
router.push('/home?forceList=1')
},
isActive: isNotebooksRoute,
},
{
id: 'insights',
icon: Sparkles,
label: t('nav.insights'),
onClick: () => {
setActiveView('insights')
router.push('/insights')
},
isActive: pathname === '/insights',
},
{
id: 'revision',
icon: GraduationCap,
label: t('nav.revision'),
onClick: () => {
setActiveView('revision')
router.push('/revision')
},
isActive: pathname.startsWith('/revision'),
},
{
id: 'agents',
icon: Bot,
label: t('agents.intelligenceOS') || 'Intelligence IA',
onClick: () => {
setActiveView('agents')
router.push('/agents')
},
isActive: pathname.startsWith('/agents') || pathname.startsWith('/lab'),
},
{
id: 'reminders',
icon: Bell,
label: t('sidebar.reminders'),
onClick: handleRemindersClick,
isActive: isRemindersRoute,
},
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-current={item.isActive ? 'page' : undefined}
onClick={item.onClick}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
@@ -992,7 +1151,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
)}
<item.icon size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
<span
aria-hidden="true"
className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider"
>
{item.label}
</span>
</button>
@@ -1232,6 +1394,60 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</div>
</div>
</motion.div>
) : activeView === 'insights' ? (
<motion.div
key="insights"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<Sparkles size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.insights')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.insightsPanelBody')}
</p>
<button
type="button"
onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : activeView === 'revision' ? (
<motion.div
key="revision"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<GraduationCap size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.revision')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.revisionPanelBody')}
</p>
<button
type="button"
onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : activeView === 'reminders' ? (
<motion.div
key="reminders"
@@ -1239,13 +1455,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="flex flex-col min-h-0"
>
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
{t('sidebar.reminders')}
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders')}</p>
<div className="flex items-center gap-1.5 px-4 pt-4 mb-3 shrink-0">
<Bell size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('sidebar.reminders')}
</h3>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0">
<SidebarReminders onOpenNote={handleReminderNoteClick} />
</div>
</motion.div>
) : activeView === 'agents' ? (

View File

@@ -0,0 +1,80 @@
'use client'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import { Link2, Blocks } from 'lucide-react'
import type { ParsedBlockReference } from '@/lib/editor/parse-block-reference'
export type SmartPasteMenuProps = {
anchor: { top: number; left: number }
reference: ParsedBlockReference
sourceNoteTitle?: string
onLive: () => void
onPlain: () => void
onClose: () => void
}
export function SmartPasteMenu({
anchor,
reference,
sourceNoteTitle,
onLive,
onPlain,
onClose,
}: SmartPasteMenuProps) {
const { t } = useLanguage()
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
const menuStyle: React.CSSProperties = {
position: 'fixed',
left: anchor.left,
top: anchor.top + 8,
zIndex: 9999,
maxWidth: 320,
}
if (Number(menuStyle.left) + 320 > window.innerWidth) {
menuStyle.left = Math.max(8, window.innerWidth - 328)
}
if (Number(menuStyle.top) + 140 > window.innerHeight) {
menuStyle.top = anchor.top - 132
}
const previewTitle = sourceNoteTitle?.trim() || t('smartPaste.unknownNote')
return createPortal(
<div ref={menuRef} style={menuStyle} className="block-action-menu smart-paste-menu">
<p className="smart-paste-menu__hint">{t('smartPaste.prompt')}</p>
<p className="smart-paste-menu__source" title={reference.raw}>
{previewTitle}
</p>
<button type="button" className="block-action-item" onClick={onLive}>
<Blocks size={16} />
<span>{t('smartPaste.liveBlock')}</span>
</button>
<button type="button" className="block-action-item" onClick={onPlain}>
<Link2 size={16} />
<span>{t('smartPaste.plainLink')}</span>
</button>
</div>,
document.body,
)
}

View File

@@ -0,0 +1,117 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
import { useCallback } from 'react'
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
import { DatabaseBlockEditor } from '@/components/database-block-editor'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import {
createDefaultDatabaseBlockData,
parseDatabaseBlockAttrs,
serializeDatabaseBlockData,
type DatabaseBlockData,
} from '@/lib/editor/database-block-types'
function DatabaseBlockView({ node, updateAttributes, editor }: NodeViewProps) {
const data = parseDatabaseBlockAttrs(node.attrs)
const requestSave = useCallback(() => {
const hostNoteId = editor.storage.liveBlock?.hostNoteId
if (hostNoteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId: hostNoteId, reason: 'database-block-mutation' },
}))
}
}, [editor])
const handleChange = useCallback((next: DatabaseBlockData) => {
updateAttributes(serializeDatabaseBlockData(next))
requestSave()
}, [requestSave, updateAttributes])
return (
<NodeViewWrapper className="database-block-wrapper" data-drag-handle contentEditable={false}>
<DatabaseBlockEditor data={data} onChange={handleChange} />
</NodeViewWrapper>
)
}
export const DatabaseBlockExtension = Node.create({
name: 'databaseBlock',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
dbId: {
default: '',
parseHTML: (element) => element.getAttribute('data-db-id') || '',
renderHTML: (attributes) => (attributes.dbId ? { 'data-db-id': attributes.dbId } : {}),
},
dbView: {
default: 'table',
parseHTML: (element) => element.getAttribute('data-db-view') || 'table',
renderHTML: (attributes) => ({ 'data-db-view': attributes.dbView || 'table' }),
},
dbAuthorsJson: {
default: '[]',
parseHTML: (element) => element.getAttribute('data-db-authors') || '[]',
renderHTML: (attributes) => ({ 'data-db-authors': attributes.dbAuthorsJson || '[]' }),
},
dbBooksJson: {
default: '[]',
parseHTML: (element) => element.getAttribute('data-db-books') || '[]',
renderHTML: (attributes) => ({ 'data-db-books': attributes.dbBooksJson || '[]' }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-database-block]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-database-block': 'true' })]
},
addNodeView() {
return ReactNodeViewRenderer(DatabaseBlockView)
},
})
export function insertDatabaseBlockAtSelection(editor: Editor): boolean {
const type = editor.schema.nodes.databaseBlock
if (!type) return false
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
const { empty, $from } = editor.state.selection
const parent = $from.parent
if (empty && parent.type.name === 'paragraph' && parent.content.size === 0) {
const pos = $from.before()
return editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
tr.replaceWith(pos, pos + parent.nodeSize, type.create(attrs))
if (dispatch) dispatch(tr)
return true
})
.run()
}
return editor.chain().focus().insertContent({ type: 'databaseBlock', attrs }).run()
}
export function replaceBlockWithDatabase(editor: Editor, blockPos: number, blockNode: PMNode): void {
const type = editor.schema.nodes.databaseBlock
if (!type || blockPos < 0 || !blockNode) return
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
const dbNode = type.create(attrs)
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, dbNode))
editor.commands.focus()
}

View File

@@ -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: '' },

View File

@@ -1,8 +1,25 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Transaction } from '@tiptap/pm/state'
import type { Node as PMNode } from '@tiptap/pm/model'
const BLOCK_TYPES = ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock']
function assignMissingBlockIds(tr: Transaction, doc: PMNode) {
let modified = false
doc.descendants((node, pos) => {
if (!BLOCK_TYPES.includes(node.type.name)) return
if (node.attrs['data-id']) return
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
'data-id': generateBlockId(),
})
modified = true
})
return modified
}
function generateBlockId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
@@ -17,6 +34,18 @@ function generateBlockId(): string {
export const UniqueIdExtension = Extension.create({
name: 'uniqueId',
onCreate({ editor }) {
const assign = () => {
if (editor.isDestroyed) return
const { tr, doc } = editor.state
if (assignMissingBlockIds(tr, doc)) {
editor.view.dispatch(tr)
}
}
assign()
requestAnimationFrame(assign)
},
addProseMirrorPlugins() {
return [
new Plugin({
@@ -26,20 +55,7 @@ export const UniqueIdExtension = Extension.create({
if (!hasDocChanged) return null
const { tr, doc } = newState
let modified = false
doc.descendants((node, pos) => {
if (!BLOCK_TYPES.includes(node.type.name)) return
if (node.attrs['data-id']) return
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
'data-id': generateBlockId(),
})
modified = true
})
return modified ? tr : null
return assignMissingBlockIds(tr, doc) ? tr : null
},
}),
]