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:
225
memento-note/components/block-action-menu.tsx
Normal file
225
memento-note/components/block-action-menu.tsx
Normal 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
|
||||
)
|
||||
}
|
||||
255
memento-note/components/database-block-editor.tsx
Normal file
255
memento-note/components/database-block-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Poignée drag globale (Novel / tiptap-extension-global-drag-handle).
|
||||
* L’extension 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>
|
||||
)
|
||||
})
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
80
memento-note/components/smart-paste-menu.tsx
Normal file
80
memento-note/components/smart-paste-menu.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
117
memento-note/components/tiptap-database-block-extension.tsx
Normal file
117
memento-note/components/tiptap-database-block-extension.tsx
Normal 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()
|
||||
}
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user