feat: editor improvements and architectural grid prototype

Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antigravity
2026-05-27 19:45:15 +00:00
parent 2de66a863d
commit f46654f574
99 changed files with 29948 additions and 919 deletions

View File

@@ -2,12 +2,7 @@ import { cookies } from 'next/headers'
import { getAllNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { HomeClient } from '@/components/home-client'
import {
NOTES_LAYOUT_COOKIE,
NOTES_VIEW_TYPE_COOKIE,
parseNotesLayoutMode,
parseNotesViewType,
} from '@/lib/notes-view-preference'
import { NOTES_LAYOUT_COOKIE, parseNotesLayoutMode } from '@/lib/notes-view-preference'
export default async function HomePage() {
const [allNotes, settings, cookieStore] = await Promise.all([
@@ -17,13 +12,11 @@ export default async function HomePage() {
])
const initialLayoutMode = parseNotesLayoutMode(cookieStore.get(NOTES_LAYOUT_COOKIE)?.value)
const initialViewType = parseNotesViewType(cookieStore.get(NOTES_VIEW_TYPE_COOKIE)?.value)
return (
<HomeClient
initialNotes={allNotes}
initialLayoutMode={initialLayoutMode}
initialViewType={initialViewType}
initialSettings={{
showRecentNotes: settings?.showRecentNotes !== false,
noteHistory: settings?.noteHistory === true,

View File

@@ -1,16 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
function extractBlockContent(html: string, blockId: string): string | null {
const regex = new RegExp(
`<(?:p|h[1-6]|blockquote)[^>]*data-id="${blockId}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote)>`,
'i'
)
const match = regex.exec(html)
if (!match) return null
return match[1].replace(/<[^>]+>/g, '').trim()
}
import { extractBlockContentById } from '@/lib/blocks/extract-blocks'
// GET /api/blocks/[blockId]/status?sourceNoteId=xxx
export async function GET(
@@ -38,7 +29,7 @@ export async function GET(
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: '' })
}
const content = extractBlockContent(note.content, blockId)
const content = extractBlockContentById(note.content, blockId)
if (content === null) {
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: note.title || '' })

View File

@@ -1035,106 +1035,59 @@ html.font-system * {
/* --- Editor Wrapper --- */
.notion-editor-wrapper {
position: relative;
padding-left: 36px; /* Espace gutter pour la poignée */
}
/* --- Drag Handle Gutter --- */
.notion-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 28px; /* Légèrement plus grand pour faciliter la sélection */
border-radius: 6px;
color: var(--muted-foreground);
/* Animation de glissement fluide (top) et d'échelle au clic */
transition: opacity 0.2s ease,
top 0.12s cubic-bezier(0.25, 1, 0.5, 1),
background-color 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
user-select: none;
}
.notion-drag-handle:hover {
background-color: rgba(59, 130, 246, 0.15); /* blue-500 @ 15% */
color: #3b82f6; /* blue-500 */
}
.dark .notion-drag-handle:hover {
background-color: rgba(59, 130, 246, 0.25);
color: #60a5fa; /* blue-400 */
}
.notion-drag-handle:active {
transform: scale(0.9);
cursor: grabbing;
}
.notion-drag-handle-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: inherit;
outline: none;
}
/* --- Styles pour l'extension officielle Tiptap DragHandle --- */
/* --- Drag Handle (Novel / global-drag-handle) --- */
.drag-handle {
position: absolute;
z-index: 50;
display: flex;
align-items: stretch;
justify-content: center;
user-select: none;
cursor: grab;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease, transform 0.12s ease;
transform: translateX(0);
}
.drag-handle.visible {
opacity: 1;
pointer-events: auto;
}
.drag-handle[data-dragging="true"] {
cursor: grabbing;
opacity: 1;
}
/* Inner content wrapper - positioned in gutter */
.drag-handle > .notion-drag-handle {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
min-height: 28px;
border-radius: 6px;
width: 1.25rem;
height: 1.5rem;
border-radius: 4px;
color: var(--muted-foreground);
background: transparent;
transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
flex-shrink: 0;
opacity: 1;
z-index: 50;
cursor: grab;
user-select: none;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.drag-handle.visible > .notion-drag-handle:hover {
background-color: rgba(59, 130, 246, 0.15);
.drag-handle:hover {
background-color: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.dark .drag-handle.visible > .notion-drag-handle:hover {
background-color: rgba(59, 130, 246, 0.25);
.dark .drag-handle:hover {
background-color: rgba(59, 130, 246, 0.22);
color: #60a5fa;
}
.drag-handle:active > .notion-drag-handle {
transform: scale(0.9);
.drag-handle:active {
cursor: grabbing;
}
.drag-handle.hide {
opacity: 0;
pointer-events: none;
}
@media (pointer: coarse) {
.drag-handle {
display: none !important;
}
}
/* Curseur pendant le drag de bloc */
.notion-editor-wrapper .ProseMirror.dragging {
cursor: grabbing;
}
/* Pas de surbrillance node pendant le drag (Novel) */
.notion-editor-wrapper .ProseMirror:not(.dragging) .ProseMirror-selectednode,
.notion-editor-wrapper .ProseMirror:not(.dragging) .ProseMirror-selectednoderange {
outline: none !important;
}
/* --- Block Action Menu (glassmorphism dropdown) --- */
@@ -1197,6 +1150,30 @@ html.font-system * {
color: #f87171;
}
.smart-paste-menu {
min-width: 240px;
padding: 8px 4px 4px;
}
.smart-paste-menu__hint {
margin: 0 12px 4px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.55;
}
.smart-paste-menu__source {
margin: 0 12px 8px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.block-action-separator {
height: 1px;
margin: 4px 8px;
@@ -1233,6 +1210,296 @@ html.font-system * {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* --- Inline Database Block (US-NEXTGEN-EDITOR) --- */
.database-block-wrapper {
margin: 0.75rem 0;
}
.database-block__inner {
border: 1px solid #e8e6e3;
border-radius: 12px;
background: rgba(255, 255, 255, 0.6);
padding: 1rem 1.1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dark .database-block__inner {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(24, 24, 27, 0.5);
}
.database-block__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.database-block__title {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: var(--color-ink, #1a1a1a);
}
.database-block__id {
display: block;
font-size: 0.65rem;
font-family: ui-monospace, monospace;
opacity: 0.45;
margin-top: 0.1rem;
}
.database-block__hint {
font-size: 0.72rem;
opacity: 0.55;
margin: 0 0 0.85rem;
}
.database-block__view-toggle {
display: inline-flex;
padding: 2px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .database-block__view-toggle {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__view-toggle button {
border: none;
background: transparent;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.35rem 0.65rem;
border-radius: 6px;
cursor: pointer;
opacity: 0.65;
color: inherit;
}
.database-block__view-toggle button.is-active {
background: white;
opacity: 1;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.dark .database-block__view-toggle button.is-active {
background: rgba(255, 255, 255, 0.12);
}
.database-block__table-wrap {
overflow-x: auto;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.dark .database-block__table-wrap {
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.database-block__table th {
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
background: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.database-block__table td {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
vertical-align: top;
}
.database-block__works-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
}
.database-block__rollup {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.database-block__delete-btn {
border: none;
background: transparent;
font-size: 0.65rem;
opacity: 0.45;
cursor: pointer;
color: #ef4444;
}
.database-block__delete-btn:hover {
opacity: 1;
}
.database-block__inline-form,
.database-block__book-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.database-block__book-form {
flex-direction: column;
align-items: stretch;
}
.database-block__form-label,
.database-block__form-heading {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
width: 100%;
}
.database-block__input {
font-size: 0.8rem;
padding: 0.45rem 0.65rem;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: white;
color: inherit;
}
.dark .database-block__input {
background: rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.1);
}
.database-block__primary-btn,
.database-block__submit {
border: none;
border-radius: 8px;
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.45rem 0.85rem;
cursor: pointer;
}
.database-block__primary-btn:hover,
.database-block__submit:hover {
background: #2563eb;
}
.database-block__card {
position: relative;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: white;
}
.dark .database-block__card {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.08);
}
.database-block__card-delete {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
border: none;
border-radius: 6px;
padding: 4px;
background: rgba(0, 0, 0, 0.45);
color: white;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.database-block__card:hover .database-block__card-delete {
opacity: 1;
}
.database-block__card-cover {
aspect-ratio: 4 / 3;
overflow: hidden;
background: #f4f4f5;
}
.database-block__card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.database-block__card-body {
padding: 0.55rem 0.65rem 0.65rem;
}
.database-block__card-title {
font-size: 0.78rem;
font-weight: 700;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.database-block__tag {
font-size: 0.62rem;
font-weight: 600;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.database-block__tag--author {
background: rgba(59, 130, 246, 0.12);
color: #2563eb;
}
.database-block__tag--genre {
background: rgba(0, 0, 0, 0.06);
opacity: 0.75;
}
.database-block__card-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 140px;
border-radius: 10px;
border: 2px dashed rgba(0, 0, 0, 0.08);
opacity: 0.5;
text-align: center;
padding: 1rem;
}
/* --- Drop Indicator Line --- */
.notion-drop-indicator {
position: absolute;
@@ -1254,17 +1521,30 @@ html.font-system * {
}
/* --- Selected Node Visual Highlight during Drag --- */
.notion-editor-wrapper .ProseMirror-selectednode {
.notion-editor-wrapper .ProseMirror-selectednode,
.notion-editor-wrapper .ProseMirror-selectednoderange {
position: relative;
outline: none !important;
background-color: rgba(59, 130, 246, 0.08) !important;
border-radius: 6px;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4) !important;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35) !important;
}
.dark .notion-editor-wrapper .ProseMirror-selectednode {
background-color: rgba(96, 165, 250, 0.15) !important;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.5) !important;
.notion-editor-wrapper .ProseMirror-selectednode::before,
.notion-editor-wrapper .ProseMirror-selectednoderange::before {
content: '';
position: absolute;
inset: -0.25rem;
background-color: rgba(59, 130, 246, 0.08);
border-radius: 0.2rem;
pointer-events: none;
z-index: -1;
}
.dark .notion-editor-wrapper .ProseMirror-selectednode,
.dark .notion-editor-wrapper .ProseMirror-selectednoderange {
background-color: rgba(96, 165, 250, 0.12) !important;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.45) !important;
}
/* --- Premium Drag Target Line (Dropcursor) --- */
@@ -1287,10 +1567,13 @@ html.font-system * {
.notion-editor-wrapper .ProseMirror {
outline: none;
min-height: 120px;
padding: 4px 0;
/* Gutter à gauche pour la poignée globale (Novel) */
padding: 4px 0 4px 3rem;
font-size: 0.9375rem;
line-height: 1.7;
caret-color: var(--primary);
user-select: text;
-webkit-user-select: text;
}
.notion-editor-wrapper .ProseMirror>*:first-child {
@@ -1341,18 +1624,21 @@ html.font-system * {
/* --- Lists --- */
.notion-editor-wrapper .ProseMirror ul {
list-style-type: disc;
padding-inline-start: 1.5rem;
list-style-position: inside;
padding-inline-start: 0;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror ol {
list-style-type: decimal;
padding-inline-start: 1.5rem;
list-style-position: inside;
padding-inline-start: 0;
margin: 0.25em 0;
}
.notion-editor-wrapper .ProseMirror li>p {
margin: 0.1em 0;
padding-inline-start: 0.25rem;
}
.notion-editor-wrapper .ProseMirror li>ul,
@@ -2262,6 +2548,7 @@ html.font-system * {
line-height: 1.875;
color: var(--foreground);
outline: none;
padding-inline-start: 0;
}
.fullpage-editor .ProseMirror p {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,27 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
import { useEffect, useRef, useState, useCallback } from 'react'
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
import { Zap, AlertCircle, Unlink, ArrowRight, Trash2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { openNotePeek } from '@/lib/note-peek-sync'
declare module '@tiptap/core' {
interface Storage {
liveBlock: {
hostNoteId: string | null
}
}
}
// ---------------------------------------------------------------------------
// LiveBlock Node View
// ---------------------------------------------------------------------------
interface LiveBlockViewProps {
node: {
attrs: {
sourceNoteId: string
blockId: string
snapshotContent: string
sourceNoteTitle: string
}
}
updateAttributes: (attrs: Record<string, string>) => void
deleteNode: () => void
}
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
function LiveBlockView({ node, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) {
const { t } = useLanguage()
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
const [localContent, setLocalContent] = useState(snapshotContent || '')
const [isDeleted, setIsDeleted] = useState(false)
@@ -30,6 +29,15 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
const [pulse, setPulse] = useState(false)
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestSave = useCallback(() => {
const hostNoteId = editor.storage.liveBlock?.hostNoteId
if (hostNoteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId: hostNoteId, reason: 'live-block-mutation' },
}))
}
}, [editor])
// Fetch current block status on mount
useEffect(() => {
if (!sourceNoteId || !blockId) return
@@ -37,6 +45,10 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
.then(r => r.json())
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
if (!data.exists) {
if (snapshotContent?.trim()) {
setLocalContent(snapshotContent)
return
}
setIsDeleted(true)
} else {
setLocalContent(data.content)
@@ -44,7 +56,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
}
})
.catch(() => setIsOffline(true))
}, [sourceNoteId, blockId])
}, [sourceNoteId, blockId, snapshotContent, updateAttributes])
// Listen for real-time block update events
useEffect(() => {
@@ -70,14 +82,33 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
}
}, [blockId, updateAttributes])
const handleDetach = useCallback(async () => {
// Convert this node to a plain paragraph with snapshot text
deleteNode()
}, [deleteNode])
/** Convertit le bloc live en paragraphe local (conserve le texte affiché). */
const handleDetach = useCallback(() => {
const pos = getPos()
if (typeof pos !== 'number') return
const currentNode = editor.state.doc.nodeAt(pos)
if (!currentNode) return
const handleOpenSource = useCallback(() => {
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
}, [sourceNoteId])
const text = localContent.trim()
const paragraph = editor.state.schema.nodes.paragraph.create(
null,
text ? editor.state.schema.text(text) : undefined,
)
editor.view.dispatch(editor.state.tr.replaceWith(pos, pos + currentNode.nodeSize, paragraph))
editor.commands.focus()
requestSave()
}, [editor, getPos, localContent, requestSave])
const handleRemove = useCallback(() => {
deleteNode()
requestSave()
}, [deleteNode, requestSave])
const handleOpenSource = useCallback((event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
openNotePeek({ noteId: sourceNoteId, blockId: blockId || undefined })
}, [sourceNoteId, blockId])
const borderClass = isDeleted
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
@@ -87,54 +118,78 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
const headerTitle = isDeleted
? t('liveBlock.sourceDisconnected')
: (sourceNoteTitle || t('liveBlock.connectedNote'))
const actionButtonClass =
'text-[9.5px] font-bold flex items-center gap-1 transition-all shrink-0'
return (
<NodeViewWrapper>
<div className="group/liveblock my-4 not-prose">
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
{/* Header */}
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
{isDeleted ? (
<AlertCircle size={10} className="text-rose-500 shrink-0" />
) : (
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
)}
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
{headerTitle}
</span>
{isDeleted ? (
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
DÉCONNECTÉ
{t('liveBlock.statusDisconnected')}
</span>
) : isOffline ? (
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
HORS-LIGNE
{t('liveBlock.statusOffline')}
</span>
) : (
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
LIVE
{t('liveBlock.statusLive')}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isDeleted ? (
<button
onClick={handleDetach}
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
contentEditable={false}
>
<Unlink size={10} />
Décharger le lien
</button>
) : (
<div
className={`flex items-center gap-2 shrink-0 ${isDeleted ? '' : 'opacity-0 group-hover/liveblock:opacity-100'} transition-opacity`}
>
<button
type="button"
onClick={handleDetach}
title={t('liveBlock.detachHelp')}
className={`${actionButtonClass} ${
isDeleted
? 'text-rose-600 hover:text-rose-500 dark:text-rose-400 hover:underline'
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)] dark:hover:text-[var(--color-dark-ink)]'
}`}
contentEditable={false}
>
<Unlink size={10} />
{t('liveBlock.detachLink')}
</button>
{!isDeleted && (
<button
type="button"
onClick={handleOpenSource}
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
className={`${actionButtonClass} text-blue-600 dark:text-blue-400 hover:underline`}
contentEditable={false}
>
Ouvrir <ArrowRight size={10} />
{t('liveBlock.openSource')} <ArrowRight size={10} />
</button>
)}
<button
type="button"
onClick={handleRemove}
title={t('liveBlock.removeBlock')}
className={`${actionButtonClass} text-rose-600/80 hover:text-rose-600 dark:text-rose-400/80 dark:hover:text-rose-400`}
contentEditable={false}
>
<Trash2 size={10} />
</button>
</div>
</div>
{/* Content */}
@@ -143,7 +198,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
contentEditable={false}
>
{localContent || '(bloc vide)'}
{localContent || t('liveBlock.emptyContent')}
</p>
</div>
</div>
@@ -163,6 +218,12 @@ export const LiveBlockExtension = Node.create({
draggable: true,
selectable: true,
addStorage() {
return {
hostNoteId: null as string | null,
}
},
addAttributes() {
return {
sourceNoteId: { default: '' },

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

@@ -5,6 +5,23 @@ export interface ExtractedBlock {
content: string
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Contenu texte d'un bloc identifié par data-id (sans filtre longueur min). */
export function extractBlockContentById(html: string, blockId: string): string | null {
if (!html || !blockId) return null
const regex = new RegExp(
`<(?:p|h[1-6]|blockquote|li)[^>]*data-id="${escapeRegExp(blockId)}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote|li)>`,
'i',
)
const match = regex.exec(html)
if (!match) return null
const content = stripHtmlToPlainText(match[1])
return content.length > 0 ? content : null
}
export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
const blocks: ExtractedBlock[] = []
const regex = /<(?:p|h[1-6]|blockquote|li)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li)>/gi

View File

@@ -0,0 +1,25 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
export const BLOCK_DRAG_HANDLE_ID = 'notion-block-drag-handle'
export const BLOCK_DRAG_HANDLE_WIDTH = 20
/** Même logique de résolution de bloc que tiptap-extension-global-drag-handle */
export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: number } | null {
const handle = document.getElementById(BLOCK_DRAG_HANDLE_ID)
if (!handle || editor.isDestroyed) return null
const rect = handle.getBoundingClientRect()
const coords = editor.view.posAtCoords({
left: rect.right + 50 + BLOCK_DRAG_HANDLE_WIDTH,
top: rect.top + rect.height / 2,
})
if (coords?.pos == null) return null
const $pos = editor.state.doc.resolve(coords.pos)
const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos
const node = editor.state.doc.nodeAt(blockPos)
if (!node) return null
return { node, pos: blockPos }
}

View File

@@ -0,0 +1,122 @@
import type { Editor } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model'
const ID_ATTR = 'data-id'
const BLOCK_TYPES_WITH_ID = new Set([
'paragraph',
'heading',
'blockquote',
'bulletList',
'orderedList',
'taskList',
'codeBlock',
])
export function generateBlockId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
function readNodeId(node: PMNode | null | undefined): string | null {
const id = node?.attrs[ID_ATTR]
return typeof id === 'string' && id.length > 0 ? id : null
}
function findDataIdInSubtree(node: PMNode): string | null {
const direct = readNodeId(node)
if (direct) return direct
let found: string | null = null
node.descendants((child) => {
if (found) return false
found = readNodeId(child)
return !found
})
return found
}
function findDataIdFromDom(editor: Editor, blockPos: number): string | null {
const dom = editor.view.nodeDOM(blockPos)
if (!(dom instanceof HTMLElement)) return null
const direct = dom.getAttribute(ID_ATTR)
if (direct) return direct
const nested = dom.querySelector(`[${ID_ATTR}]`)
const nestedId = nested?.getAttribute(ID_ATTR)
return nestedId || null
}
function findTextBlockToAssign(
editor: Editor,
blockPos: number,
blockNode: PMNode,
): { pos: number; node: PMNode } | null {
if (BLOCK_TYPES_WITH_ID.has(blockNode.type.name)) {
return { pos: blockPos, node: blockNode }
}
let target: { pos: number; node: PMNode } | null = null
editor.state.doc.nodesBetween(blockPos, blockPos + blockNode.nodeSize, (node, pos) => {
if (target) return false
if (node.isTextblock && BLOCK_TYPES_WITH_ID.has(node.type.name)) {
target = { pos, node }
return false
}
return true
})
return target
}
/** Cherche un data-id sur le bloc résolu (nœud, descendants, ancêtres, DOM). */
export function findBlockReferenceId(
editor: Editor,
blockPos: number,
blockNode: PMNode | null,
): string | null {
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
if (!node) return null
const fromSubtree = findDataIdInSubtree(node)
if (fromSubtree) return fromSubtree
const $pos = editor.state.doc.resolve(Math.min(blockPos + 1, editor.state.doc.content.size))
for (let depth = $pos.depth; depth > 0; depth--) {
const id = readNodeId($pos.node(depth))
if (id) return id
}
return findDataIdFromDom(editor, blockPos)
}
/** Assigne un data-id si absent, retourne l'id utilisable pour la référence. */
export function ensureBlockReferenceId(
editor: Editor,
blockPos: number,
blockNode: PMNode | null,
): string | null {
const existing = findBlockReferenceId(editor, blockPos, blockNode)
if (existing) return existing
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
if (!node || blockPos < 0) return null
const target = findTextBlockToAssign(editor, blockPos, node)
if (!target) return null
const newId = generateBlockId()
editor.view.dispatch(
editor.state.tr.setNodeMarkup(target.pos, undefined, {
...target.node.attrs,
[ID_ATTR]: newId,
}),
)
return newId
}

View File

@@ -0,0 +1,30 @@
/** Copie texte — writeText async + repli synchrone execCommand. */
export async function copyTextToClipboard(text: string): Promise<boolean> {
if (typeof window === 'undefined') return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// repli ci-dessous
}
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
ta.style.top = '0'
document.body.appendChild(ta)
ta.focus()
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}

View File

@@ -0,0 +1,100 @@
export type DatabaseAuthor = { id: string; name: string }
export type DatabaseBook = {
id: string
title: string
author: string
cover: string
tag: string
}
export type DatabaseView = 'table' | 'card'
export type DatabaseBlockData = {
dbId: string
dbView: DatabaseView
dbAuthors: DatabaseAuthor[]
dbBooks: DatabaseBook[]
}
const DEFAULT_COVERS = [
'https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1506318137071-a8e063b4bec0?auto=format&fit=crop&q=80&w=400',
'https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=400',
]
export function randomDefaultCover(): string {
return DEFAULT_COVERS[Math.floor(Math.random() * DEFAULT_COVERS.length)]
}
export function createDefaultDatabaseBlockData(): DatabaseBlockData {
return {
dbId: `authors-works-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().slice(0, 8) : Date.now()}`,
dbView: 'table',
dbAuthors: [
{ id: 'a1', name: 'Jules Verne' },
{ id: 'a2', name: 'Liu Cixin' },
],
dbBooks: [
{
id: 'bk1',
title: 'Twenty Thousand Leagues Under The Sea',
author: 'Jules Verne',
cover: DEFAULT_COVERS[0],
tag: 'Aventure',
},
{
id: 'bk2',
title: 'The Three-Body Problem',
author: 'Liu Cixin',
cover: DEFAULT_COVERS[1],
tag: 'Hard SF',
},
{
id: 'bk3',
title: 'The Wandering Earth',
author: 'Liu Cixin',
cover: DEFAULT_COVERS[2],
tag: 'SF Spatial',
},
],
}
}
export function serializeDatabaseBlockData(data: DatabaseBlockData): {
dbId: string
dbView: DatabaseView
dbAuthorsJson: string
dbBooksJson: string
} {
return {
dbId: data.dbId,
dbView: data.dbView,
dbAuthorsJson: JSON.stringify(data.dbAuthors),
dbBooksJson: JSON.stringify(data.dbBooks),
}
}
export function parseDatabaseBlockAttrs(attrs: {
dbId?: string
dbView?: string
dbAuthorsJson?: string
dbBooksJson?: string
}): DatabaseBlockData {
const fallback = createDefaultDatabaseBlockData()
let authors = fallback.dbAuthors
let books = fallback.dbBooks
try {
if (attrs.dbAuthorsJson) authors = JSON.parse(attrs.dbAuthorsJson)
} catch { /* keep fallback */ }
try {
if (attrs.dbBooksJson) books = JSON.parse(attrs.dbBooksJson)
} catch { /* keep fallback */ }
return {
dbId: attrs.dbId || fallback.dbId,
dbView: attrs.dbView === 'card' ? 'card' : 'table',
dbAuthors: Array.isArray(authors) ? authors : fallback.dbAuthors,
dbBooks: Array.isArray(books) ? books : fallback.dbBooks,
}
}

View File

@@ -0,0 +1,19 @@
import type { EditorState } from '@tiptap/pm/state'
import type { Node as PMNode } from '@tiptap/pm/model'
function isEmptyParagraph(node: PMNode): boolean {
if (node.type.name !== 'paragraph') return false
return node.textContent.trim() === '' && node.content.size <= 1
}
/** Paragraphe vide sous le curseur (critère US-3 Smart Paste). */
export function getEmptyParagraphAtSelection(
state: EditorState,
): { pos: number; node: PMNode } | null {
const { $from, empty } = state.selection
if (!empty) return null
if (!isEmptyParagraph($from.parent)) return null
const pos = $from.before($from.depth)
return { pos, node: $from.parent }
}

View File

@@ -0,0 +1,17 @@
import GlobalDragHandle from 'tiptap-extension-global-drag-handle'
import AutoJoiner from 'tiptap-extension-auto-joiner'
import { BLOCK_DRAG_HANDLE_ID, BLOCK_DRAG_HANDLE_WIDTH } from './block-at-drag-handle'
/** Extensions drag handle Novel / Notion — une poignée globale, position fixed */
export const globalDragHandleExtensions = [
GlobalDragHandle.configure({
dragHandleWidth: BLOCK_DRAG_HANDLE_WIDTH,
scrollTreshold: 100,
dragHandleSelector: `#${BLOCK_DRAG_HANDLE_ID}`,
excludedTags: [],
customNodes: [],
}),
AutoJoiner.configure({
elementsToJoin: ['bulletList', 'orderedList'],
}),
]

View File

@@ -0,0 +1,67 @@
export type ParsedBlockReference = {
sourceNoteId: string
blockId: string
raw: string
}
export type StoredBlockReference = ParsedBlockReference & {
blockContent?: string
sourceNoteTitle?: string
}
export const LAST_BLOCK_REF_SESSION_KEY = 'memento:lastBlockRef'
/** Référence bloc copiée via « Copier la référence » : /home?openNote=…#block-… */
export function parseBlockReferenceFromText(text: string): ParsedBlockReference | null {
const trimmed = text.trim()
if (!trimmed || trimmed.includes('\n')) return null
const match = trimmed.match(/openNote=([^&#\s]+)(?:[^#]*?)#block-([a-zA-Z0-9_-]+)/i)
if (!match) return null
try {
return {
sourceNoteId: decodeURIComponent(match[1]),
blockId: match[2],
raw: trimmed,
}
} catch {
return null
}
}
export function rememberBlockReference(
raw: string,
extras?: { blockContent?: string; sourceNoteTitle?: string },
) {
if (typeof sessionStorage === 'undefined') return
const parsed = parseBlockReferenceFromText(raw)
if (!parsed) return
const payload: StoredBlockReference = {
...parsed,
...(extras?.blockContent ? { blockContent: extras.blockContent } : {}),
...(extras?.sourceNoteTitle ? { sourceNoteTitle: extras.sourceNoteTitle } : {}),
}
try {
sessionStorage.setItem(LAST_BLOCK_REF_SESSION_KEY, JSON.stringify(payload))
} catch {
// quota / private mode
}
}
export function recallLastBlockReference(): StoredBlockReference | null {
if (typeof sessionStorage === 'undefined') return null
try {
const stored = sessionStorage.getItem(LAST_BLOCK_REF_SESSION_KEY)
if (!stored) return null
if (stored.startsWith('{')) {
const payload = JSON.parse(stored) as StoredBlockReference
if (payload?.sourceNoteId && payload?.blockId && payload?.raw) return payload
return null
}
const parsed = parseBlockReferenceFromText(stored)
return parsed ?? null
} catch {
return null
}
}

View File

@@ -0,0 +1,39 @@
import { Extension } from '@tiptap/core'
import type { EditorView } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export type SmartPasteHandler = (view: EditorView, event: ClipboardEvent) => boolean
export const smartPastePluginKey = new PluginKey('smartPaste')
declare module '@tiptap/core' {
interface Storage {
smartPaste: {
onPaste: SmartPasteHandler | null
}
}
}
export const SmartPasteExtension = Extension.create({
name: 'smartPaste',
addStorage() {
return {
onPaste: null as SmartPasteHandler | null,
}
},
addProseMirrorPlugins() {
const storage = this.storage
return [
new Plugin({
key: smartPastePluginKey,
props: {
handlePaste(view, event) {
return storage.onPaste?.(view, event) ?? false
},
},
}),
]
},
})

View File

@@ -0,0 +1,18 @@
/** Ouvre la note source en aperçu split (panneau gauche) sans changer donglet. */
export const NOTE_PEEK_OPEN_EVENT = 'memento-note-peek-open'
export const NOTE_PEEK_CLOSE_EVENT = 'memento-note-peek-close'
export type NotePeekOpenDetail = {
noteId: string
blockId?: string
}
export function openNotePeek(detail: NotePeekOpenDetail) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(NOTE_PEEK_OPEN_EVENT, { detail }))
}
export function closeNotePeek() {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent(NOTE_PEEK_CLOSE_EVENT))
}

View File

@@ -3,6 +3,25 @@ import type { Note } from '@/lib/types'
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
/** SSR-safe HTML entity decode so excerpt text matches server and client. */
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/(?:&#0*39;|&apos;)/g, "'")
.replace(/&#(\d+);/g, (_, code: string) => {
const n = Number(code)
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#${code};`
})
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => {
const n = parseInt(hex, 16)
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#x${hex};`
})
}
/**
* Plain-text preview for list view (light markdown stripping).
*/
@@ -58,6 +77,7 @@ export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
let raw = note.content || ''
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
raw = raw.replace(/<[^>]+>/g, ' ')
raw = decodeHtmlEntities(raw)
return stripMarkdownPreview(raw, maxLen)
}

View File

@@ -1,12 +1,9 @@
import type { NotesLayoutMode, NotesViewType } from '@/components/notes-list-views'
import type { NotesLayoutMode } from '@/components/notes-list-views'
export const NOTES_LAYOUT_COOKIE = 'memento-notes-layout'
export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table', 'kanban', 'gallery']
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
if (value && (LAYOUT_VALUES as string[]).includes(value)) {
@@ -17,19 +14,8 @@ export function parseNotesLayoutMode(value: string | undefined | null): NotesLay
return 'list'
}
export function parseNotesViewType(value: string | undefined | null): NotesViewType {
if (value && (VIEW_TYPE_VALUES as string[]).includes(value)) return value as NotesViewType
return 'notes'
}
export function setNotesLayoutPreference(layout: NotesLayoutMode) {
if (typeof window === 'undefined') return
localStorage.setItem(NOTES_LAYOUT_STORAGE_KEY, layout)
document.cookie = `${NOTES_LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; SameSite=Lax`
}
export function setNotesViewTypePreference(viewType: NotesViewType) {
if (typeof window === 'undefined') return
localStorage.setItem(NOTES_VIEW_TYPE_STORAGE_KEY, viewType)
document.cookie = `${NOTES_VIEW_TYPE_COOKIE}=${viewType}; path=/; max-age=31536000; SameSite=Lax`
}

View File

@@ -0,0 +1,10 @@
import { useSyncExternalStore } from 'react'
/** True only after the client has hydrated (false on server + first client paint). */
export function useHydrated(): boolean {
return useSyncExternalStore(
() => () => {},
() => true,
() => false,
)
}

View File

@@ -74,7 +74,10 @@
"noReminders": "No active reminders.",
"documents": "Documents",
"searchNotebooksPlaceholder": "Search notebooks…",
"clearSearch": "Clear search"
"clearSearch": "Clear search",
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
"revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.",
"backToNotebooks": "Back to notebooks"
},
"notes": {
"title": "Notes",
@@ -2347,6 +2350,8 @@
"slashDividerDesc": "Horizontal separator",
"slashTable": "Table",
"slashTableDesc": "Insert a simple grid",
"slashDatabase": "Database",
"slashDatabaseDesc": "Inline authors & works database",
"slashDiagram": "Diagram",
"slashDiagramDesc": "Generate a flow or mindmap",
"slashSlides": "Presentation",
@@ -3310,5 +3315,76 @@
"uploadError": "Upload error",
"uploadFailed": "Upload failed",
"uploading": "Uploading..."
},
"blockAction": {
"delete": "Delete",
"duplicate": "Duplicate",
"turnInto": "Turn into",
"turnInto_heading1": "Heading 1",
"turnInto_heading2": "Heading 2",
"turnInto_heading3": "Heading 3",
"turnInto_bulletList": "Bullet List",
"turnInto_orderedList": "Numbered List",
"turnInto_taskList": "Task List",
"turnInto_blockquote": "Quote",
"turnInto_codeBlock": "Code Block",
"turnInto_database": "Database",
"copyRef": "Copy block reference",
"copied": "Reference copied!",
"copyRefFailed": "Could not copy block reference",
"copyRefNoNote": "Save the note before copying a block reference",
"copyRefUnsupported": "This block type cannot be referenced yet"
},
"smartPaste": {
"prompt": "Paste this block reference as:",
"liveBlock": "Connected block (live)",
"plainLink": "Plain text / link",
"unknownNote": "Untitled note"
},
"liveBlock": {
"connectedNote": "Connected note",
"sourceDisconnected": "Source disconnected",
"statusLive": "LIVE",
"statusOffline": "OFFLINE",
"statusDisconnected": "DISCONNECTED",
"detachLink": "Unlink block",
"detachHelp": "Convert this block to normal text in this note",
"openSource": "Open",
"removeBlock": "Remove block",
"emptyContent": "(empty block)"
},
"notePeek": {
"label": "Linked note",
"panelLabel": "Source note preview",
"close": "Close preview",
"openFully": "Open full screen",
"openFullyHelp": "Replace the current note with this one",
"readOnlyHint": "Read-only preview — open full screen to edit.",
"loadFailed": "Could not open the linked note preview."
},
"databaseBlock": {
"title": "Authors & Works",
"hint": "Inline database — data is stored in this note.",
"viewTable": "Table",
"viewCards": "Cards",
"colAuthor": "Author",
"colWorks": "Works",
"colRollup": "Rollup",
"noLinkedWorks": "No linked works",
"deleteShort": "Del",
"deleteCard": "Remove work",
"addAuthor": "Add author",
"authorPlaceholder": "Author name…",
"createAuthor": "Create",
"addWork": "Add a work",
"bookTitlePlaceholder": "Work title…",
"selectAuthor": "Select author…",
"tagPlaceholder": "Genre / tag…",
"coverPlaceholder": "Cover image URL (optional)",
"insertWork": "Insert work",
"defaultTag": "General",
"worksBase": "Works database",
"storedCount": "{count} work(s) stored",
"insertFailed": "Could not insert the database block. Try again on an empty line."
}
}

View File

@@ -74,7 +74,10 @@
"noReminders": "Aucun rappel actif.",
"documents": "Documents",
"searchNotebooksPlaceholder": "Rechercher un carnet…",
"clearSearch": "Effacer la recherche"
"clearSearch": "Effacer la recherche",
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
"revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.",
"backToNotebooks": "Retour aux carnets"
},
"notes": {
"title": "Notes",
@@ -2351,6 +2354,8 @@
"slashDividerDesc": "Séparateur horizontal",
"slashTable": "Tableau",
"slashTableDesc": "Insérer un tableau simple",
"slashDatabase": "Base de données",
"slashDatabaseDesc": "Base inline auteurs et œuvres",
"slashDiagram": "Diagramme",
"slashDiagramDesc": "Générer un flux ou une carte mentale",
"slashSlides": "Présentation",
@@ -3314,5 +3319,76 @@
"uploadError": "Erreur de téléchargement",
"uploadFailed": "Échec du téléchargement",
"uploading": "Téléchargement..."
},
"blockAction": {
"delete": "Supprimer",
"duplicate": "Dupliquer",
"turnInto": "Transformer en",
"turnInto_heading1": "Titre 1",
"turnInto_heading2": "Titre 2",
"turnInto_heading3": "Titre 3",
"turnInto_bulletList": "Liste à puces",
"turnInto_orderedList": "Liste numérotée",
"turnInto_taskList": "Liste de tâches",
"turnInto_blockquote": "Citation",
"turnInto_codeBlock": "Bloc de code",
"turnInto_database": "Base de données",
"copyRef": "Copier la référence du bloc",
"copied": "Référence copiée !",
"copyRefFailed": "Impossible de copier la référence du bloc",
"copyRefNoNote": "Enregistrez la note avant de copier une référence de bloc",
"copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé"
},
"smartPaste": {
"prompt": "Coller cette référence de bloc en tant que :",
"liveBlock": "Bloc connecté (live)",
"plainLink": "Texte / lien simple",
"unknownNote": "Note sans titre"
},
"liveBlock": {
"connectedNote": "Note connectée",
"sourceDisconnected": "Source déconnectée",
"statusLive": "LIVE",
"statusOffline": "HORS-LIGNE",
"statusDisconnected": "DÉCONNECTÉ",
"detachLink": "Décharger le lien",
"detachHelp": "Transformer ce bloc en texte normal dans cette note",
"openSource": "Ouvrir",
"removeBlock": "Supprimer le bloc",
"emptyContent": "(bloc vide)"
},
"notePeek": {
"label": "Note liée",
"panelLabel": "Aperçu de la note source",
"close": "Fermer l'aperçu",
"openFully": "Ouvrir en plein écran",
"openFullyHelp": "Remplacer la note courante par cette note",
"readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.",
"loadFailed": "Impossible d'ouvrir l'aperçu de la note liée."
},
"databaseBlock": {
"title": "Auteurs & Œuvres",
"hint": "Base inline — les données sont enregistrées dans cette note.",
"viewTable": "Tableau",
"viewCards": "Fiches",
"colAuthor": "Auteur",
"colWorks": "Œuvres",
"colRollup": "Total",
"noLinkedWorks": "Aucune œuvre liée",
"deleteShort": "Suppr.",
"deleteCard": "Retirer l'œuvre",
"addAuthor": "Ajouter un auteur",
"authorPlaceholder": "Nom de l'auteur…",
"createAuthor": "Créer",
"addWork": "Ajouter une œuvre",
"bookTitlePlaceholder": "Titre de l'œuvre…",
"selectAuthor": "Choisir un auteur…",
"tagPlaceholder": "Genre / étiquette…",
"coverPlaceholder": "URL de couverture (optionnel)",
"insertWork": "Insérer l'œuvre",
"defaultTag": "Général",
"worksBase": "Base d'œuvres",
"storedCount": "{count} œuvre(s) enregistrée(s)",
"insertFailed": "Impossible d'insérer le bloc base de données. Réessayez sur une ligne vide."
}
}

View File

@@ -63,7 +63,7 @@ const nextConfig: NextConfig = {
},
{
source: '/reminders',
destination: '/?reminders=1',
destination: '/home?reminders=1',
permanent: false,
},
]

View File

@@ -40,9 +40,13 @@
"@stripe/react-stripe-js": "^6.3.0",
"@stripe/stripe-js": "^9.5.0",
"@tanstack/react-query": "^5.100.9",
"@tiptap/extension-collaboration": "^3.23.6",
"@tiptap/extension-color": "^3.22.5",
"@tiptap/extension-drag-handle": "^3.23.6",
"@tiptap/extension-drag-handle-react": "^3.23.6",
"@tiptap/extension-highlight": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-node-range": "^3.23.6",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^3.22.5",
@@ -59,6 +63,7 @@
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@tiptap/y-tiptap": "^3.0.3",
"@types/d3": "^7.4.3",
"@types/jsdom": "^28.0.1",
"ai": "^6.0.23",
@@ -111,7 +116,11 @@
"stripe": "^22.1.1",
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"vazirmatn": "^33.0.3",
"y-protocols": "^1.0.7",
"yjs": "^13.6.30",
"zod": "^4.3.5"
},
"devDependencies": {
@@ -3765,350 +3774,11 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
@@ -4142,14 +3812,14 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"devOptional": true,
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -4163,14 +3833,14 @@
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
@@ -4182,7 +3852,7 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
@@ -7946,6 +7616,22 @@
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.23.6.tgz",
"integrity": "sha512-OvPtPUWH3uormtQ8Ei/di6h/gv9ttv+IDy8zz1t4hrP2XLQHgpd1yv8/bnwz0jsZhsl+4Km2Wb0H5oS3Rh5+cA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6",
"@tiptap/y-tiptap": "^3.0.2",
"yjs": "^13"
}
},
"node_modules/@tiptap/extension-color": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.22.5.tgz",
@@ -7972,6 +7658,43 @@
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-drag-handle": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.23.6.tgz",
"integrity": "sha512-+IJgVq7GDb4yDft5Dr8fW61qqdyeO30QgaS12GlvX+mfaurPOUtMhnr9POX1Vb4QMogxkwXD6pA0RA+AYkI3qw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/extension-collaboration": "3.23.6",
"@tiptap/extension-node-range": "3.23.6",
"@tiptap/pm": "3.23.6",
"@tiptap/y-tiptap": "^3.0.2"
}
},
"node_modules/@tiptap/extension-drag-handle-react": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle-react/-/extension-drag-handle-react-3.23.6.tgz",
"integrity": "sha512-ZScNZMinmjFNRgPNjvz0+ZHZXbM/s0JgYv14u/JptiGK7q63pSFDACL4mpRvx7aYwkkLs6egOOux1DVvWqhNHg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-drag-handle": "3.23.6",
"@tiptap/pm": "3.23.6",
"@tiptap/react": "3.23.6",
"react": "^16.8 || ^17 || ^18 || ^19",
"react-dom": "^16.8 || ^17 || ^18 || ^19"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
@@ -8150,6 +7873,20 @@
"@tiptap/extension-list": "3.22.5"
}
},
"node_modules/@tiptap/extension-node-range": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.23.6.tgz",
"integrity": "sha512-S+EN8HDnXOFxtamtkAkDY++B6jnVfjNBBHtDbO6i4as7PUeRY/JJIPPwlyL/cZULfP+ePYyr4eIVxFSMQxFXtg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.6",
"@tiptap/pm": "3.23.6"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
@@ -8489,6 +8226,26 @@
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/y-tiptap": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.3.tgz",
"integrity": "sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.100"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.7.1",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.10",
"y-protocols": "^1.0.1",
"yjs": "^13.5.38"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
@@ -8952,6 +8709,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -8961,6 +8719,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -14232,6 +13991,16 @@
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -14633,6 +14402,27 @@
"node": ">= 0.8.0"
}
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -16336,15 +16126,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -16853,7 +16634,7 @@
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
@@ -16872,7 +16653,7 @@
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -17052,7 +16833,7 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"devOptional": true,
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -19090,6 +18871,18 @@
"node": ">=14.0.0"
}
},
"node_modules/tiptap-extension-auto-joiner": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/tiptap-extension-auto-joiner/-/tiptap-extension-auto-joiner-0.1.3.tgz",
"integrity": "sha512-nY3aKeCpVb2WjjVEZkLtEqxsK3KU1zGioyglMhK1sUFNjKDccOfRyz/YDKrHRAVsKJPGnk2A8VA1827iGEAXWQ==",
"license": "MIT"
},
"node_modules/tiptap-extension-global-drag-handle": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/tiptap-extension-global-drag-handle/-/tiptap-extension-global-drag-handle-0.1.18.tgz",
"integrity": "sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==",
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
@@ -19920,24 +19713,6 @@
}
}
},
"node_modules/vitest/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/vitest/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -19953,15 +19728,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest/node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
@@ -19975,45 +19741,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/vitest/node_modules/sass": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"chokidar": "^5.0.0",
"immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=20.19.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/vitest/node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
@@ -20442,6 +20169,26 @@
"node": ">=0.4.0"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -20488,6 +20235,23 @@
"node": ">=12"
}
},
"node_modules/yjs": {
"version": "13.6.30",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -61,9 +61,13 @@
"@stripe/react-stripe-js": "^6.3.0",
"@stripe/stripe-js": "^9.5.0",
"@tanstack/react-query": "^5.100.9",
"@tiptap/extension-collaboration": "^3.23.6",
"@tiptap/extension-color": "^3.22.5",
"@tiptap/extension-drag-handle": "^3.23.6",
"@tiptap/extension-drag-handle-react": "^3.23.6",
"@tiptap/extension-highlight": "^3.22.5",
"@tiptap/extension-image": "^3.22.5",
"@tiptap/extension-node-range": "^3.23.6",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/extension-subscript": "^3.22.5",
"@tiptap/extension-superscript": "^3.22.5",
@@ -80,6 +84,7 @@
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@tiptap/suggestion": "^3.22.5",
"@tiptap/y-tiptap": "^3.0.3",
"@types/d3": "^7.4.3",
"@types/jsdom": "^28.0.1",
"ai": "^6.0.23",
@@ -132,7 +137,11 @@
"stripe": "^22.1.1",
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"vazirmatn": "^33.0.3",
"y-protocols": "^1.0.7",
"yjs": "^13.6.30",
"zod": "^4.3.5"
},
"devDependencies": {