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:
@@ -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,
|
||||
|
||||
@@ -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 || '' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
225
memento-note/components/block-action-menu.tsx
Normal file
225
memento-note/components/block-action-menu.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
import { ensureBlockReferenceId } from '@/lib/editor/block-reference-id'
|
||||
import { rememberBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Trash2, Copy, Repeat, Link, ChevronRight,
|
||||
Heading1, Heading2, Heading3, List, ListOrdered,
|
||||
CheckSquare, Quote, CodeXml, Database,
|
||||
} from 'lucide-react'
|
||||
import { replaceBlockWithDatabase } from '@/components/tiptap-database-block-extension'
|
||||
|
||||
interface BlockActionMenuProps {
|
||||
editor: Editor
|
||||
onClose: () => void
|
||||
anchorRect: DOMRect
|
||||
blockPos: number
|
||||
blockNode: PMNode | null
|
||||
noteId?: string
|
||||
sourceNoteTitle?: string
|
||||
onBlockReferenceCopied?: (html: string) => void
|
||||
}
|
||||
|
||||
type TurnIntoType =
|
||||
| 'heading1' | 'heading2' | 'heading3'
|
||||
| 'bulletList' | 'orderedList' | 'taskList'
|
||||
| 'blockquote' | 'codeBlock' | 'database'
|
||||
|
||||
interface TurnIntoOption {
|
||||
id: TurnIntoType
|
||||
icon: typeof Heading1
|
||||
command?: (editor: Editor) => void
|
||||
isDatabase?: boolean
|
||||
}
|
||||
|
||||
const TURN_INTO_OPTIONS: TurnIntoOption[] = [
|
||||
{ id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ id: 'bulletList', icon: List, command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||
{ id: 'orderedList', icon: ListOrdered, command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||
{ id: 'taskList', icon: CheckSquare, command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||
{ id: 'blockquote', icon: Quote, command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||
{ id: 'codeBlock', icon: CodeXml, command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ id: 'database', icon: Database, isDatabase: true },
|
||||
]
|
||||
|
||||
function focusBlock(editor: Editor, blockPos: number) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
const cursorPos = Math.min(blockPos + 1, docSize)
|
||||
editor.chain().focus().setTextSelection(cursorPos).run()
|
||||
}
|
||||
|
||||
function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNode | null): string {
|
||||
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
|
||||
if (!node || blockPos < 0) return ''
|
||||
const from = blockPos + 1
|
||||
const to = blockPos + node.nodeSize - 1
|
||||
if (to > from) {
|
||||
return editor.state.doc.textBetween(from, to, '\n', '\0').trim()
|
||||
}
|
||||
return node.textContent?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function BlockActionMenu({
|
||||
editor,
|
||||
onClose,
|
||||
anchorRect,
|
||||
blockPos,
|
||||
blockNode,
|
||||
noteId,
|
||||
sourceNoteTitle,
|
||||
onBlockReferenceCopied,
|
||||
}: BlockActionMenuProps) {
|
||||
const { t } = useLanguage()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [showTurnInto, setShowTurnInto] = useState(false)
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (blockNode && blockPos >= 0) {
|
||||
editor.chain().focus().deleteRange({ from: blockPos, to: blockPos + blockNode.nodeSize }).run()
|
||||
}
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (blockNode && blockPos >= 0) {
|
||||
const insertPos = blockPos + blockNode.nodeSize
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.insert(insertPos, blockNode.copy())
|
||||
)
|
||||
editor.commands.focus()
|
||||
}
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
const handleCopyRef = useCallback(async () => {
|
||||
if (!noteId?.trim()) {
|
||||
toast.error(t('blockAction.copyRefNoNote'))
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const blockId = ensureBlockReferenceId(editor, blockPos, blockNode)
|
||||
if (!blockId) {
|
||||
toast.error(t('blockAction.copyRefUnsupported'))
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const html = editor.getHTML()
|
||||
const blockContent = getBlockPlainContent(editor, blockPos, blockNode)
|
||||
onBlockReferenceCopied?.(html)
|
||||
|
||||
const ref = `${window.location.origin}/home?openNote=${encodeURIComponent(noteId)}#block-${encodeURIComponent(blockId)}`
|
||||
const copied = await copyTextToClipboard(ref)
|
||||
if (copied) {
|
||||
rememberBlockReference(ref, { blockContent, sourceNoteTitle })
|
||||
toast.success(t('blockAction.copied'))
|
||||
} else {
|
||||
toast.error(t('blockAction.copyRefFailed'))
|
||||
}
|
||||
onClose()
|
||||
}, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t])
|
||||
|
||||
const handleTurnInto = useCallback((option: TurnIntoOption) => {
|
||||
if (blockPos >= 0 && blockNode) {
|
||||
if (option.isDatabase) {
|
||||
replaceBlockWithDatabase(editor, blockPos, blockNode)
|
||||
} else if (option.command) {
|
||||
focusBlock(editor, blockPos)
|
||||
option.command(editor)
|
||||
}
|
||||
}
|
||||
setShowTurnInto(false)
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchorRect.right + 6,
|
||||
top: anchorRect.top - 4,
|
||||
zIndex: 9999,
|
||||
}
|
||||
|
||||
if (Number(menuStyle.left) > window.innerWidth - 220) {
|
||||
menuStyle.left = anchorRect.left - 210
|
||||
}
|
||||
if (Number(menuStyle.top) + 300 > window.innerHeight) {
|
||||
menuStyle.top = window.innerHeight - 310
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={menuRef} style={menuStyle} className="block-action-menu">
|
||||
<button type="button" className="block-action-item" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<span>{t('blockAction.delete')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={handleDuplicate}>
|
||||
<Copy size={16} />
|
||||
<span>{t('blockAction.duplicate')}</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="block-action-item block-action-submenu-trigger"
|
||||
onClick={() => setShowTurnInto(!showTurnInto)}
|
||||
onMouseEnter={() => setShowTurnInto(true)}
|
||||
>
|
||||
<Repeat size={16} />
|
||||
<span>{t('blockAction.turnInto')}</span>
|
||||
<ChevronRight size={14} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
{showTurnInto && (
|
||||
<div className="block-action-submenu">
|
||||
{TURN_INTO_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className="block-action-item"
|
||||
onClick={() => handleTurnInto(opt)}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
<span>{t(`blockAction.turnInto_${opt.id}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCopyRef() }}>
|
||||
<Link size={16} />
|
||||
<span>{t('blockAction.copyRef')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
255
memento-note/components/database-block-editor.tsx
Normal file
255
memento-note/components/database-block-editor.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import {
|
||||
type DatabaseBlockData,
|
||||
randomDefaultCover,
|
||||
} from '@/lib/editor/database-block-types'
|
||||
|
||||
interface DatabaseBlockEditorProps {
|
||||
data: DatabaseBlockData
|
||||
readOnly?: boolean
|
||||
onChange: (data: DatabaseBlockData) => void
|
||||
}
|
||||
|
||||
export function DatabaseBlockEditor({ data, readOnly, onChange }: DatabaseBlockEditorProps) {
|
||||
const { t } = useLanguage()
|
||||
const { dbId, dbView, dbAuthors, dbBooks } = data
|
||||
|
||||
const [newBookTitle, setNewBookTitle] = useState('')
|
||||
const [newBookAuthor, setNewBookAuthor] = useState('')
|
||||
const [newBookTag, setNewBookTag] = useState('')
|
||||
const [newBookCover, setNewBookCover] = useState('')
|
||||
const [newAuthorName, setNewAuthorName] = useState('')
|
||||
|
||||
const patch = useCallback((partial: Partial<DatabaseBlockData>) => {
|
||||
onChange({ ...data, ...partial })
|
||||
}, [data, onChange])
|
||||
|
||||
const handleAddAuthor = useCallback(() => {
|
||||
const name = newAuthorName.trim()
|
||||
if (!name) return
|
||||
if (dbAuthors.some((a) => a.name.toLowerCase() === name.toLowerCase())) return
|
||||
patch({
|
||||
dbAuthors: [...dbAuthors, { id: `auth-${Date.now()}`, name }],
|
||||
})
|
||||
setNewAuthorName('')
|
||||
}, [dbAuthors, newAuthorName, patch])
|
||||
|
||||
const handleAddBook = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const title = newBookTitle.trim()
|
||||
let author = newBookAuthor.trim()
|
||||
if (!title || !author) return
|
||||
|
||||
let nextAuthors = [...dbAuthors]
|
||||
if (author === '__new__') return
|
||||
if (!nextAuthors.some((a) => a.name.toLowerCase() === author.toLowerCase())) {
|
||||
nextAuthors = [...nextAuthors, { id: `auth-${Date.now()}`, name: author }]
|
||||
}
|
||||
|
||||
patch({
|
||||
dbAuthors: nextAuthors,
|
||||
dbBooks: [
|
||||
...dbBooks,
|
||||
{
|
||||
id: `bk-${Date.now()}`,
|
||||
title,
|
||||
author,
|
||||
cover: newBookCover.trim() || randomDefaultCover(),
|
||||
tag: newBookTag.trim() || t('databaseBlock.defaultTag'),
|
||||
},
|
||||
],
|
||||
})
|
||||
setNewBookTitle('')
|
||||
setNewBookAuthor('')
|
||||
setNewBookTag('')
|
||||
setNewBookCover('')
|
||||
}, [dbAuthors, dbBooks, newBookAuthor, newBookCover, newBookTag, newBookTitle, patch, t])
|
||||
|
||||
const handleDeleteBook = useCallback((id: string) => {
|
||||
patch({ dbBooks: dbBooks.filter((b) => b.id !== id) })
|
||||
}, [dbBooks, patch])
|
||||
|
||||
const handleDeleteAuthor = useCallback((id: string) => {
|
||||
patch({ dbAuthors: dbAuthors.filter((a) => a.id !== id) })
|
||||
}, [dbAuthors, patch])
|
||||
|
||||
return (
|
||||
<div className="database-block not-prose my-4 text-left">
|
||||
<div className="database-block__inner">
|
||||
<div className="database-block__header">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-lg shrink-0" aria-hidden>📚</span>
|
||||
<div className="min-w-0">
|
||||
<span className="database-block__title">{t('databaseBlock.title')}</span>
|
||||
<span className="database-block__id">id: {dbId}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="database-block__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dbView: 'table' })}
|
||||
className={dbView === 'table' ? 'is-active' : ''}
|
||||
>
|
||||
{t('databaseBlock.viewTable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dbView: 'card' })}
|
||||
className={dbView === 'card' ? 'is-active' : ''}
|
||||
>
|
||||
{t('databaseBlock.viewCards')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="database-block__hint">{t('databaseBlock.hint')}</p>
|
||||
|
||||
{dbView === 'table' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="database-block__table-wrap">
|
||||
<table className="database-block__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('databaseBlock.colAuthor')}</th>
|
||||
<th>{t('databaseBlock.colWorks')}</th>
|
||||
<th className="text-right">{t('databaseBlock.colRollup')}</th>
|
||||
{!readOnly && <th className="w-12" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dbAuthors.map((auth) => {
|
||||
const authorBooks = dbBooks.filter(
|
||||
(b) => b.author.toLowerCase() === auth.name.toLowerCase(),
|
||||
)
|
||||
const worksStr = authorBooks.map((b) => b.title).join(', ')
|
||||
|| t('databaseBlock.noLinkedWorks')
|
||||
return (
|
||||
<tr key={auth.id}>
|
||||
<td className="font-semibold">{auth.name}</td>
|
||||
<td className="database-block__works-cell" title={worksStr}>{worksStr}</td>
|
||||
<td className="database-block__rollup">{authorBooks.length}</td>
|
||||
{!readOnly && (
|
||||
<td className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteAuthor(auth.id)}
|
||||
className="database-block__delete-btn"
|
||||
>
|
||||
{t('databaseBlock.deleteShort')}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="database-block__inline-form">
|
||||
<span className="database-block__form-label">{t('databaseBlock.addAuthor')}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.authorPlaceholder')}
|
||||
value={newAuthorName}
|
||||
onChange={(e) => setNewAuthorName(e.target.value)}
|
||||
className="database-block__input flex-1"
|
||||
/>
|
||||
<button type="button" onClick={handleAddAuthor} className="database-block__primary-btn">
|
||||
{t('databaseBlock.createAuthor')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{dbBooks.map((book) => (
|
||||
<div key={book.id} className="database-block__card group/book">
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteBook(book.id)}
|
||||
className="database-block__card-delete"
|
||||
title={t('databaseBlock.deleteCard')}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
<div className="database-block__card-cover">
|
||||
<img src={book.cover} alt={book.title} referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<div className="database-block__card-body">
|
||||
<p className="database-block__card-title" title={book.title}>{book.title}</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
<span className="database-block__tag database-block__tag--author">{book.author}</span>
|
||||
<span className="database-block__tag database-block__tag--genre">{book.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="database-block__card-placeholder">
|
||||
<span className="text-xl mb-1" aria-hidden>📖</span>
|
||||
<span className="font-bold text-[10px] uppercase tracking-widest">{t('databaseBlock.worksBase')}</span>
|
||||
<span className="text-[8.5px] text-zinc-400 mt-1">
|
||||
{t('databaseBlock.storedCount', { count: dbBooks.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddBook} className="database-block__book-form">
|
||||
<span className="database-block__form-heading">{t('databaseBlock.addWork')}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.bookTitlePlaceholder')}
|
||||
value={newBookTitle}
|
||||
onChange={(e) => setNewBookTitle(e.target.value)}
|
||||
className="database-block__input"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={newBookAuthor}
|
||||
onChange={(e) => setNewBookAuthor(e.target.value)}
|
||||
className="database-block__input"
|
||||
required
|
||||
>
|
||||
<option value="">{t('databaseBlock.selectAuthor')}</option>
|
||||
{dbAuthors.map((auth) => (
|
||||
<option key={auth.id} value={auth.name}>{auth.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.tagPlaceholder')}
|
||||
value={newBookTag}
|
||||
onChange={(e) => setNewBookTag(e.target.value)}
|
||||
className="database-block__input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.coverPlaceholder')}
|
||||
value={newBookCover}
|
||||
onChange={(e) => setNewBookCover(e.target.value)}
|
||||
className="database-block__input"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="database-block__submit">
|
||||
{t('databaseBlock.insertWork')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Poignée drag globale (Novel / tiptap-extension-global-drag-handle).
|
||||
* L’extension TipTap positionne cet élément ; le clic ouvre le menu bloc.
|
||||
*/
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { BLOCK_DRAG_HANDLE_ID, resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
|
||||
type EditorBlockDragHandleProps = {
|
||||
editor: Editor | null
|
||||
onOpenMenu: (anchorRect: DOMRect) => void
|
||||
}
|
||||
|
||||
export const EditorBlockDragHandle = memo(function EditorBlockDragHandle({
|
||||
editor,
|
||||
onOpenMenu,
|
||||
}: EditorBlockDragHandleProps) {
|
||||
const dragStartedRef = useRef(false)
|
||||
const onOpenMenuRef = useRef(onOpenMenu)
|
||||
onOpenMenuRef.current = onOpenMenu
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return
|
||||
const el = document.getElementById(BLOCK_DRAG_HANDLE_ID)
|
||||
if (!el) return
|
||||
|
||||
const onPointerDown = () => {
|
||||
dragStartedRef.current = false
|
||||
}
|
||||
|
||||
const onDragStart = () => {
|
||||
dragStartedRef.current = true
|
||||
}
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (dragStartedRef.current) {
|
||||
dragStartedRef.current = false
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const block = resolveBlockAtDragHandle(editor)
|
||||
if (!block) return
|
||||
onOpenMenuRef.current(el.getBoundingClientRect())
|
||||
}
|
||||
|
||||
el.addEventListener('pointerdown', onPointerDown)
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('click', onClick)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointerdown', onPointerDown)
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('click', onClick)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div
|
||||
id={BLOCK_DRAG_HANDLE_ID}
|
||||
className="drag-handle hide"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="4" cy="3" r="1.2" />
|
||||
<circle cx="10" cy="3" r="1.2" />
|
||||
<circle cx="4" cy="7" r="1.2" />
|
||||
<circle cx="10" cy="7" r="1.2" />
|
||||
<circle cx="4" cy="11" r="1.2" />
|
||||
<circle cx="10" cy="11" r="1.2" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -5,14 +5,11 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
NOTES_VIEW_TYPE_STORAGE_KEY,
|
||||
parseNotesLayoutMode,
|
||||
parseNotesViewType,
|
||||
setNotesLayoutPreference,
|
||||
setNotesViewTypePreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
|
||||
import {
|
||||
@@ -87,14 +84,12 @@ interface HomeClientProps {
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
initialLayoutMode?: NotesLayoutMode
|
||||
initialViewType?: NotesViewType
|
||||
}
|
||||
|
||||
export function HomeClient({
|
||||
initialNotes,
|
||||
initialSettings,
|
||||
initialLayoutMode = 'list',
|
||||
initialViewType = 'notes',
|
||||
}: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -136,7 +131,6 @@ export function HomeClient({
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
@@ -164,20 +158,11 @@ export function HomeClient({
|
||||
|
||||
useEffect(() => {
|
||||
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
||||
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
|
||||
if (storedLayout !== initialLayoutMode) {
|
||||
setLayoutMode(storedLayout)
|
||||
setNotesLayoutPreference(storedLayout)
|
||||
}
|
||||
if (storedViewType !== initialViewType) {
|
||||
setViewType(storedViewType)
|
||||
setNotesViewTypePreference(storedViewType)
|
||||
}
|
||||
}, [initialLayoutMode, initialViewType])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesViewTypePreference(viewType)
|
||||
}, [viewType])
|
||||
}, [initialLayoutMode])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesLayoutPreference(layoutMode)
|
||||
@@ -768,17 +753,20 @@ export function HomeClient({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
|
||||
'flex w-full min-h-0 flex-1 flex-col',
|
||||
editingNote ? 'h-full overflow-hidden' : 'gap-3 py-1'
|
||||
)}
|
||||
>
|
||||
{editingNote ? (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={handleEditorClose}
|
||||
onNoteSaved={handleNoteSaved}
|
||||
fullPage
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0 h-full w-full overflow-hidden">
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={handleEditorClose}
|
||||
onNoteSaved={handleNoteSaved}
|
||||
fullPage
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||||
<div
|
||||
@@ -941,35 +929,7 @@ export function HomeClient({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('notes')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'notes'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewNotes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('tasks')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'tasks'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewTasks')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewType === 'notes' && (
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectLayoutMode('grid')}
|
||||
@@ -1043,9 +1003,8 @@ export function HomeClient({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewType === 'notes' && currentNotebook && structuredModeActive && (
|
||||
{currentNotebook && structuredModeActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddPropertyOpen(true)}
|
||||
@@ -1207,7 +1166,6 @@ export function HomeClient({
|
||||
<NotesListViews
|
||||
notes={sortedNotes}
|
||||
pinnedNotes={sortedPinnedNotes}
|
||||
viewType={viewType}
|
||||
layoutMode={classicLayoutMode}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteEditorProvider } from './note-editor-context'
|
||||
import { NoteEditorFullPage } from './note-editor-full-page'
|
||||
import { NoteEditorDialog } from './note-editor-dialog'
|
||||
import { NoteEditorPeekHost } from './note-editor-peek-host'
|
||||
import { Note } from '@/lib/types'
|
||||
|
||||
interface NoteEditorProps {
|
||||
@@ -16,11 +17,13 @@ interface NoteEditorProps {
|
||||
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
|
||||
return (
|
||||
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
|
||||
{fullPage ? (
|
||||
<NoteEditorFullPage onClose={onClose} />
|
||||
) : (
|
||||
<NoteEditorDialog onClose={onClose} />
|
||||
)}
|
||||
<NoteEditorPeekHost noteId={note.id} fullPage={fullPage}>
|
||||
{fullPage ? (
|
||||
<NoteEditorFullPage onClose={onClose} />
|
||||
) : (
|
||||
<NoteEditorDialog onClose={onClose} />
|
||||
)}
|
||||
</NoteEditorPeekHost>
|
||||
</NoteEditorProvider>
|
||||
)
|
||||
}
|
||||
@@ -35,4 +38,4 @@ export { NoteEditorProvider } from './note-editor-context'
|
||||
export { NoteTitleBlock } from './note-title-block'
|
||||
export { NoteContentArea } from './note-content-area'
|
||||
export { NoteMetadataSection } from './note-metadata-section'
|
||||
export { NoteEditorToolbar } from './note-editor-toolbar'
|
||||
export { NoteEditorToolbar } from './note-editor-toolbar'
|
||||
|
||||
@@ -107,6 +107,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,6 +123,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
<GhostTags
|
||||
|
||||
@@ -25,14 +25,12 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
@@ -66,10 +64,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
return (
|
||||
<>
|
||||
{/* ── outer container ── */}
|
||||
<div className="h-screen flex items-stretch overflow-hidden transition-all duration-500">
|
||||
|
||||
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
|
||||
{/* ── main scrollable column ── */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
|
||||
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import {
|
||||
NOTE_PEEK_OPEN_EVENT,
|
||||
NOTE_PEEK_CLOSE_EVENT,
|
||||
type NotePeekOpenDetail,
|
||||
} from '@/lib/note-peek-sync'
|
||||
import { NoteEditorSplitPeek } from './note-editor-split-peek'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface NoteEditorPeekHostProps {
|
||||
noteId: string
|
||||
fullPage?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onOpenPeek = (event: Event) => {
|
||||
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
||||
if (!detail?.noteId) return
|
||||
if (detail.noteId === noteId) return
|
||||
|
||||
void getNoteById(detail.noteId).then((fetched) => {
|
||||
if (fetched) {
|
||||
setPeekState({ note: fetched, blockId: detail.blockId })
|
||||
} else {
|
||||
toast.error(t('notePeek.loadFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
const onClosePeek = () => setPeekState(null)
|
||||
|
||||
window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
|
||||
window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
|
||||
return () => {
|
||||
window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
|
||||
window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
|
||||
}
|
||||
}, [noteId, t])
|
||||
|
||||
const handleClosePeek = useCallback(() => {
|
||||
setPeekState(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenPeekFully = useCallback(() => {
|
||||
if (!peekState) return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'before-peek-full-open' },
|
||||
}))
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('openNote', peekState.note.id)
|
||||
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
||||
setPeekState(null)
|
||||
}, [noteId, peekState, router, searchParams])
|
||||
|
||||
const shellClass = fullPage
|
||||
? 'flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden'
|
||||
: 'relative flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
|
||||
return (
|
||||
<div className={`${shellClass}${peekState ? (isRtl ? ' flex-row-reverse' : ' flex-row') : ''}`}>
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">{children}</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{peekState && (
|
||||
<NoteEditorSplitPeek
|
||||
key={peekState.note.id}
|
||||
note={peekState.note}
|
||||
blockId={peekState.blockId}
|
||||
onClose={handleClosePeek}
|
||||
onOpenFully={handleOpenPeekFully}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { X, Maximize2 } from 'lucide-react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteTitleBlock } from './note-title-block'
|
||||
import { NoteContentArea } from './note-content-area'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
interface NoteEditorSplitPeekProps {
|
||||
note: Note
|
||||
blockId?: string
|
||||
onClose: () => void
|
||||
onOpenFully: () => void
|
||||
}
|
||||
|
||||
function PeekEditorBody({ blockId }: { blockId?: string }) {
|
||||
const { note } = useNoteEditorContext()
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockId) return
|
||||
const timer = window.setTimeout(() => {
|
||||
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(blockId) : blockId
|
||||
const el = scrollRootRef.current?.querySelector(`[data-id="${escaped}"]`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 450)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [blockId, note.id])
|
||||
|
||||
return (
|
||||
<div ref={scrollRootRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-10 space-y-8 pb-24">
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-[.25em] font-bold text-[var(--color-concrete)]"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
|
||||
</p>
|
||||
<NoteTitleBlock />
|
||||
<div className="max-w-xl mx-auto w-full">
|
||||
<NoteContentArea />
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--color-concrete)] italic">{t('notePeek.readOnlyHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 34 }}
|
||||
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
|
||||
isRtl
|
||||
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
}`}
|
||||
aria-label={t('notePeek.panelLabel')}
|
||||
>
|
||||
<NoteEditorProvider note={note} readOnly fullPage>
|
||||
<div className="shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--color-concrete)] truncate">
|
||||
{t('notePeek.label')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFully}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors"
|
||||
title={t('notePeek.openFullyHelp')}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
{t('notePeek.openFully')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-[var(--color-concrete)] hover:text-[var(--color-ink)] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title={t('notePeek.close')}
|
||||
aria-label={t('notePeek.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PeekEditorBody blockId={blockId} />
|
||||
</NoteEditorProvider>
|
||||
</motion.aside>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
|
||||
type NotesEditorialViewProps = {
|
||||
notes: Note[]
|
||||
@@ -364,6 +365,7 @@ export function NotesEditorialView({
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const { data: allLabels } = useLabelsQuery()
|
||||
const hydrated = useHydrated()
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -388,9 +390,9 @@ export function NotesEditorialView({
|
||||
return (
|
||||
<motion.article
|
||||
key={note.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={hydrated ? { opacity: 0, y: 20 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 * index, duration: 0.6 }}
|
||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||
className="space-y-4 group cursor-pointer relative pb-8"
|
||||
onClick={() => onOpen(note)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -28,7 +28,6 @@ import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNote
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -39,8 +38,6 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Pin,
|
||||
FileText,
|
||||
Link2,
|
||||
CheckSquare,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Wind,
|
||||
@@ -50,6 +47,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -60,52 +58,6 @@ export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
|
||||
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
|
||||
return mode === 'grid' || mode === 'list' || mode === 'table'
|
||||
}
|
||||
export type NotesViewType = 'notes' | 'tasks'
|
||||
|
||||
type TaskItem = {
|
||||
id: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
text: string
|
||||
completed: boolean
|
||||
lineIndex: number
|
||||
}
|
||||
|
||||
function getNoteTasksStats(content: string) {
|
||||
const lines = (content || '').split('\n')
|
||||
let total = 0
|
||||
let completed = 0
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
total++
|
||||
if (match[1].toLowerCase() === 'x') completed++
|
||||
}
|
||||
}
|
||||
return { completed, total }
|
||||
}
|
||||
|
||||
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
|
||||
const tasks: TaskItem[] = []
|
||||
for (const note of notes) {
|
||||
const title = note.title?.trim() || 'Sans titre'
|
||||
const lines = (note.content || '').split('\n')
|
||||
lines.forEach((line, idx) => {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
tasks.push({
|
||||
id: `${note.id}-${idx}`,
|
||||
noteId: note.id,
|
||||
noteTitle: title,
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
lineIndex: idx,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
|
||||
const colors = [
|
||||
@@ -247,7 +199,6 @@ export type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
type NotesListViewsProps = {
|
||||
notes: Note[]
|
||||
pinnedNotes?: Note[]
|
||||
viewType: NotesViewType
|
||||
layoutMode: NotesLayoutMode
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
@@ -258,7 +209,6 @@ type NotesListViewsProps = {
|
||||
export function NotesListViews({
|
||||
notes,
|
||||
pinnedNotes = [],
|
||||
viewType,
|
||||
layoutMode,
|
||||
onOpen,
|
||||
onOpenHistory,
|
||||
@@ -275,8 +225,7 @@ export function NotesListViews({
|
||||
const { data: session } = useSession()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { data: allLabels = [] } = useLabelsQuery()
|
||||
const [, startTransition] = useTransition()
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'modified' | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
@@ -298,24 +247,7 @@ export function NotesListViews({
|
||||
return [...pinnedNotes, ...unpinned]
|
||||
}, [notes, pinnedNotes])
|
||||
|
||||
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
|
||||
const completedTasksCount = extractTasks.filter((task) => task.completed).length
|
||||
|
||||
const handleToggleTask = (task: TaskItem) => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (!note) return
|
||||
const lines = (note.content || '').split('\n')
|
||||
const line = lines[task.lineIndex]
|
||||
if (!line) return
|
||||
const nextChar = task.completed ? ' ' : 'x'
|
||||
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
|
||||
onNotePatch?.(note.id, { content: lines.join('\n') })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
|
||||
const handleSort = (field: 'title' | 'notebook' | 'modified') => {
|
||||
if (sortColumn !== field) {
|
||||
setSortColumn(field)
|
||||
setSortDirection('asc')
|
||||
@@ -339,9 +271,6 @@ export function NotesListViews({
|
||||
} else if (sortColumn === 'notebook') {
|
||||
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
|
||||
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
|
||||
} else if (sortColumn === 'tasks') {
|
||||
valA = getNoteTasksStats(a.content || '').completed
|
||||
valB = getNoteTasksStats(b.content || '').completed
|
||||
} else {
|
||||
valA = new Date(a.updatedAt).getTime()
|
||||
valB = new Date(b.updatedAt).getTime()
|
||||
@@ -357,86 +286,6 @@ export function NotesListViews({
|
||||
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
||||
) : null
|
||||
|
||||
if (viewType === 'tasks') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
|
||||
{t('notes.tasksHeader')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
|
||||
{t('notes.tasksSummary')
|
||||
.replace('{count}', String(extractTasks.length))
|
||||
.replace('{completed}', String(completedTasksCount))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extractTasks.length > 0 ? (
|
||||
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<div className="divide-y divide-foreground/[0.04]">
|
||||
{extractTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3.5 flex-grow min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTask(task)}
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
|
||||
task.completed
|
||||
? 'bg-brand-accent border-brand-accent text-white'
|
||||
: 'border-border hover:border-brand-accent/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
{task.completed && <span className="text-xs font-bold">✓</span>}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-light leading-relaxed truncate',
|
||||
task.completed && 'line-through text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
|
||||
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (note) onOpen(note)
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
|
||||
title={t('notes.openSourceNote')}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
|
||||
<CheckSquare size={18} className="text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode === 'grid') {
|
||||
return (
|
||||
<NotesMasonryGrid
|
||||
@@ -484,15 +333,7 @@ export function NotesListViews({
|
||||
{t('notes.tableLabels')}
|
||||
</th>
|
||||
<th
|
||||
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('tasks')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTasks')} <SortIcon field="tasks" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -506,7 +347,6 @@ export function NotesListViews({
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const nb = notebooks.find((n) => n.id === note.notebookId)
|
||||
const nbColor = getNotebookColor(note.notebookId, nb?.name)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
return (
|
||||
<tr
|
||||
key={note.id}
|
||||
@@ -538,15 +378,6 @@ export function NotesListViews({
|
||||
<td className="px-4 py-2">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
|
||||
{stats.total > 0 ? (
|
||||
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
|
||||
{stats.completed}/{stats.total} <span className="text-[9px] font-sans">✓</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</td>
|
||||
@@ -834,9 +665,9 @@ function GridCard({
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const hydrated = useHydrated()
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const excerpt = getNotePlainExcerpt(note, 110)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
const formattedDate = formatGridCardDate(note.updatedAt, language)
|
||||
|
||||
const handlePinClick = (e: React.MouseEvent) => {
|
||||
@@ -857,9 +688,9 @@ function GridCard({
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={isOverlay ? false : { opacity: 0, y: 15 }}
|
||||
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
transition={isOverlay || !hydrated ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
onClick={() => onOpen(note)}
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
|
||||
>
|
||||
@@ -874,11 +705,6 @@ function GridCard({
|
||||
<Pin size={11} className="fill-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{stats.total > 0 && (
|
||||
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
|
||||
{stats.completed}/{stats.total} ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
||||
<div className="space-y-2.5 flex-1">
|
||||
|
||||
@@ -25,13 +25,25 @@ import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
|
||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
||||
import { BlockActionMenu } from './block-action-menu'
|
||||
import { SmartPasteMenu } from './smart-paste-menu'
|
||||
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
||||
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
@@ -42,7 +54,7 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -64,6 +76,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
noteTitle?: string
|
||||
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||
sourceUrl?: string | null
|
||||
}
|
||||
@@ -86,7 +99,7 @@ type SlashItem = {
|
||||
|
||||
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
|
||||
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
|
||||
|
||||
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
|
||||
|
||||
@@ -176,6 +189,10 @@ const slashCommands: SlashItem[] = [
|
||||
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertDatabaseBlockAtSelection(e) },
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -233,11 +250,28 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
const [blockMenuState, setBlockMenuState] = useState<{
|
||||
anchor: DOMRect
|
||||
pos: number
|
||||
node: PMNode | null
|
||||
} | null>(null)
|
||||
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
|
||||
const smartPastePendingRef = useRef<{
|
||||
reference: ParsedBlockReference
|
||||
blockPos: number
|
||||
blockNode: PMNode
|
||||
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
|
||||
} | null>(null)
|
||||
const [smartPasteMenu, setSmartPasteMenu] = useState<{
|
||||
anchor: { top: number; left: number }
|
||||
reference: ParsedBlockReference
|
||||
sourceNoteTitle?: string
|
||||
} | null>(null)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
@@ -355,16 +389,38 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Typography,
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
LiveBlockExtension,
|
||||
DatabaseBlockExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
attributes: { class: 'notion-editor tiptap' },
|
||||
handleDOMEvents: {
|
||||
keydown: (view, event) => {
|
||||
if (event.defaultPrevented) return false
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return false
|
||||
const { from, empty } = view.state.selection
|
||||
if (!empty) return false
|
||||
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
|
||||
if (!/\/(database|db)$/i.test(textBefore)) return false
|
||||
event.preventDefault()
|
||||
const slashIdx = textBefore.lastIndexOf('/')
|
||||
const deleteFrom = from - (textBefore.length - slashIdx)
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) return false
|
||||
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
|
||||
if (!insertDatabaseBlockAtSelection(ed)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
return true
|
||||
},
|
||||
click: (_view, event) => {
|
||||
const link = (event.target as HTMLElement).closest('a[href]')
|
||||
if (!link) return false
|
||||
@@ -373,11 +429,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (noteIdMatch) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(
|
||||
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
const targetId = decodeURIComponent(noteIdMatch[1])
|
||||
const blockMatch = href.match(/#block-([^&#]+)/)
|
||||
openNotePeek({
|
||||
noteId: targetId,
|
||||
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
@@ -405,11 +462,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
try {
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const editorInstance = editorInstanceRef.current
|
||||
if (!editorInstance) continue
|
||||
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) continue
|
||||
const inserted = ed.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(editorInstance.getHTML())
|
||||
emitContentChange(ed.getHTML())
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
@@ -447,6 +504,80 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
editorInstanceRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.storage.liveBlock.hostNoteId = noteId ?? null
|
||||
}, [editor, noteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
editor.storage.smartPaste.onPaste = (view, event) => {
|
||||
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
|
||||
let blockRef = parseBlockReferenceFromText(clipboardText)
|
||||
if (!blockRef) {
|
||||
blockRef = recallLastBlockReference()
|
||||
}
|
||||
if (!blockRef) return false
|
||||
|
||||
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
||||
if (!emptyParagraph) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const coords = view.coordsAtPos(view.state.selection.from)
|
||||
smartPastePendingRef.current = {
|
||||
reference: blockRef,
|
||||
blockPos: emptyParagraph.pos,
|
||||
blockNode: emptyParagraph.node,
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
setSmartPasteMenu({
|
||||
anchor: { top: coords.bottom, left: coords.left },
|
||||
reference: blockRef,
|
||||
})
|
||||
})
|
||||
|
||||
void fetch(
|
||||
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
|
||||
)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
|
||||
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === blockRef.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
const exists = Boolean(data?.exists) || sessionFallback.content.length > 0
|
||||
const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '')
|
||||
const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || ''
|
||||
smartPastePendingRef.current!.blockStatus = {
|
||||
exists,
|
||||
content,
|
||||
sourceNoteTitle,
|
||||
}
|
||||
setSmartPasteMenu((prev) =>
|
||||
prev?.reference.raw === blockRef.raw
|
||||
? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle }
|
||||
: prev,
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.storage.smartPaste.onPaste = null
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
|
||||
@@ -620,6 +751,159 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
handleSelectBlockRef.current = handleSelectBlock
|
||||
|
||||
const openBlockActionMenu = useCallback((anchorRect: DOMRect) => {
|
||||
if (!editor) return
|
||||
const block = resolveBlockAtDragHandle(editor)
|
||||
if (!block) return
|
||||
dragBlockRef.current = block
|
||||
setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node })
|
||||
}, [editor])
|
||||
|
||||
const closeBlockActionMenu = useCallback(() => {
|
||||
setBlockMenuState(null)
|
||||
}, [])
|
||||
|
||||
const closeSmartPasteMenu = useCallback(() => {
|
||||
smartPastePendingRef.current = null
|
||||
setSmartPasteMenu(null)
|
||||
}, [])
|
||||
|
||||
const handleBlockReferenceCopied = useCallback((html: string) => {
|
||||
emitContentChange(html)
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'block-reference-copy' },
|
||||
}))
|
||||
}
|
||||
}, [emitContentChange, noteId])
|
||||
|
||||
const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => {
|
||||
const cached = smartPastePendingRef.current?.blockStatus
|
||||
if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === reference.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
|
||||
const res = await fetch(
|
||||
`/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content,
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle,
|
||||
}
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
return {
|
||||
exists: true,
|
||||
content: data.content ?? '',
|
||||
sourceNoteTitle: data.sourceNoteTitle ?? '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content || data.content || '',
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '',
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSmartPasteLive = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
targetNoteId: noteId,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlockType = ed.schema.nodes.liveBlock
|
||||
if (liveBlockType) {
|
||||
ed.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
const node = liveBlockType.create({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
snapshotContent: status.content,
|
||||
sourceNoteTitle: status.sourceNoteTitle,
|
||||
})
|
||||
tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node)
|
||||
if (dispatch) dispatch(tr)
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
emitContentChange(ed.getHTML())
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'smart-paste-live-block' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId])
|
||||
|
||||
const handleSmartPastePlain = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
const linkHref = pending.reference.raw.match(/^https?:\/\//)
|
||||
? pending.reference.raw
|
||||
: `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}`
|
||||
const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw
|
||||
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: linkText,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: linkHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}],
|
||||
})
|
||||
.run()
|
||||
|
||||
emitContentChange(ed.getHTML())
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -645,8 +929,34 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && blockMenuState && (
|
||||
<BlockActionMenu
|
||||
editor={editor}
|
||||
anchorRect={blockMenuState.anchor}
|
||||
blockPos={blockMenuState.pos}
|
||||
blockNode={blockMenuState.node}
|
||||
noteId={noteId}
|
||||
sourceNoteTitle={noteTitle}
|
||||
onBlockReferenceCopied={handleBlockReferenceCopied}
|
||||
onClose={closeBlockActionMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{smartPasteMenu && (
|
||||
<SmartPasteMenu
|
||||
anchor={smartPasteMenu.anchor}
|
||||
reference={smartPasteMenu.reference}
|
||||
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
|
||||
onLive={() => { void handleSmartPasteLive() }}
|
||||
onPlain={() => { void handleSmartPastePlain() }}
|
||||
onClose={closeSmartPasteMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
@@ -973,6 +1283,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
|
||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1021,6 +1332,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
@@ -1029,7 +1345,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||
const q = query.toLowerCase()
|
||||
const textFiltered = localCommands.filter(c =>
|
||||
c.title.toLowerCase().includes(q)
|
||||
|| c.description.toLowerCase().includes(q)
|
||||
|| (c.shortcut?.toLowerCase().includes(q) ?? false)
|
||||
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false)
|
||||
)
|
||||
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
|
||||
|
||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||
@@ -1070,7 +1392,15 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = filtered[selectedIndex]
|
||||
if (item) handleSelect(item)
|
||||
if (item) {
|
||||
handleSelect(item)
|
||||
} else if (/^(database|db)$/i.test(query)) {
|
||||
deleteSlashText()
|
||||
closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useSearchModal } from '@/context/search-modal-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||
import { getAllNotes, getTrashCount, getNotesWithReminders } from '@/app/actions/notes'
|
||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Notebook, Note } from '@/lib/types'
|
||||
@@ -60,7 +60,7 @@ import { performSignOut } from '@/lib/auth-client'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
function NoteLink({
|
||||
@@ -175,6 +175,96 @@ function SidebarBrainstorms() {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [reminders, setReminders] = useState<
|
||||
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
|
||||
>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
getNotesWithReminders()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setReminders(rows as typeof reminders)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
|
||||
const overdue = active.filter((r) => new Date(r.reminder!) < now)
|
||||
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.id, note.notebookId)}
|
||||
className="w-full text-start px-4 py-2.5 rounded-xl hover:bg-brand-accent/5 transition-colors group"
|
||||
>
|
||||
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.title || t('notes.untitled')}
|
||||
</p>
|
||||
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
|
||||
{note.reminder &&
|
||||
new Date(note.reminder).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
{overdue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">
|
||||
{t('reminders.overdue')}
|
||||
</p>
|
||||
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
|
||||
</div>
|
||||
)}
|
||||
{upcoming.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">
|
||||
{t('reminders.upcoming')}
|
||||
</p>
|
||||
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarCarnetItem({
|
||||
carnet,
|
||||
isActive,
|
||||
@@ -536,8 +626,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
|
||||
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
|
||||
else setActiveView('notebooks')
|
||||
}, [pathname])
|
||||
else if (pathname === '/insights') setActiveView('insights')
|
||||
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
||||
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
||||
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
||||
}, [pathname, searchParams])
|
||||
|
||||
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
||||
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
||||
const isNotebooksRoute =
|
||||
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
||||
!pathname.startsWith('/settings') &&
|
||||
!isRemindersRoute &&
|
||||
!isSharedRoute
|
||||
|
||||
const displayName = user?.name || user?.email || ''
|
||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||
@@ -645,6 +746,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleRemindersClick = () => {
|
||||
setActiveView('reminders')
|
||||
router.push('/home?reminders=1&forceList=1')
|
||||
}
|
||||
|
||||
const handleReminderNoteClick = (noteId: string, notebookId: string | null) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('reminders', '1')
|
||||
if (notebookId) params.set('notebook', notebookId)
|
||||
params.set('openNote', noteId)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleInboxClick = () => {
|
||||
router.push('/home?forceList=1')
|
||||
}
|
||||
@@ -972,14 +1086,59 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{/* Boutons de navigation principaux */}
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
||||
{
|
||||
id: 'notebooks',
|
||||
icon: BookOpen,
|
||||
label: t('nav.notebooks'),
|
||||
onClick: () => {
|
||||
setActiveView('notebooks')
|
||||
router.push('/home?forceList=1')
|
||||
},
|
||||
isActive: isNotebooksRoute,
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
icon: Sparkles,
|
||||
label: t('nav.insights'),
|
||||
onClick: () => {
|
||||
setActiveView('insights')
|
||||
router.push('/insights')
|
||||
},
|
||||
isActive: pathname === '/insights',
|
||||
},
|
||||
{
|
||||
id: 'revision',
|
||||
icon: GraduationCap,
|
||||
label: t('nav.revision'),
|
||||
onClick: () => {
|
||||
setActiveView('revision')
|
||||
router.push('/revision')
|
||||
},
|
||||
isActive: pathname.startsWith('/revision'),
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
icon: Bot,
|
||||
label: t('agents.intelligenceOS') || 'Intelligence IA',
|
||||
onClick: () => {
|
||||
setActiveView('agents')
|
||||
router.push('/agents')
|
||||
},
|
||||
isActive: pathname.startsWith('/agents') || pathname.startsWith('/lab'),
|
||||
},
|
||||
{
|
||||
id: 'reminders',
|
||||
icon: Bell,
|
||||
label: t('sidebar.reminders'),
|
||||
onClick: handleRemindersClick,
|
||||
isActive: isRemindersRoute,
|
||||
},
|
||||
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
aria-current={item.isActive ? 'page' : undefined}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
@@ -992,7 +1151,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
|
||||
)}
|
||||
<item.icon size={16} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1232,6 +1394,60 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'insights' ? (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4 pt-4 space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('nav.insights')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t('sidebar.insightsPanelBody')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'revision' ? (
|
||||
<motion.div
|
||||
key="revision"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4 pt-4 space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GraduationCap size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('nav.revision')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t('sidebar.revisionPanelBody')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
<motion.div
|
||||
key="reminders"
|
||||
@@ -1239,13 +1455,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col min-h-0"
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
|
||||
{t('sidebar.reminders')}
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders')}</p>
|
||||
<div className="flex items-center gap-1.5 px-4 pt-4 mb-3 shrink-0">
|
||||
<Bell size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('sidebar.reminders')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0">
|
||||
<SidebarReminders onOpenNote={handleReminderNoteClick} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'agents' ? (
|
||||
|
||||
80
memento-note/components/smart-paste-menu.tsx
Normal file
80
memento-note/components/smart-paste-menu.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Link2, Blocks } from 'lucide-react'
|
||||
import type { ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
|
||||
export type SmartPasteMenuProps = {
|
||||
anchor: { top: number; left: number }
|
||||
reference: ParsedBlockReference
|
||||
sourceNoteTitle?: string
|
||||
onLive: () => void
|
||||
onPlain: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SmartPasteMenu({
|
||||
anchor,
|
||||
reference,
|
||||
sourceNoteTitle,
|
||||
onLive,
|
||||
onPlain,
|
||||
onClose,
|
||||
}: SmartPasteMenuProps) {
|
||||
const { t } = useLanguage()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchor.left,
|
||||
top: anchor.top + 8,
|
||||
zIndex: 9999,
|
||||
maxWidth: 320,
|
||||
}
|
||||
|
||||
if (Number(menuStyle.left) + 320 > window.innerWidth) {
|
||||
menuStyle.left = Math.max(8, window.innerWidth - 328)
|
||||
}
|
||||
if (Number(menuStyle.top) + 140 > window.innerHeight) {
|
||||
menuStyle.top = anchor.top - 132
|
||||
}
|
||||
|
||||
const previewTitle = sourceNoteTitle?.trim() || t('smartPaste.unknownNote')
|
||||
|
||||
return createPortal(
|
||||
<div ref={menuRef} style={menuStyle} className="block-action-menu smart-paste-menu">
|
||||
<p className="smart-paste-menu__hint">{t('smartPaste.prompt')}</p>
|
||||
<p className="smart-paste-menu__source" title={reference.raw}>
|
||||
{previewTitle}
|
||||
</p>
|
||||
<button type="button" className="block-action-item" onClick={onLive}>
|
||||
<Blocks size={16} />
|
||||
<span>{t('smartPaste.liveBlock')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={onPlain}>
|
||||
<Link2 size={16} />
|
||||
<span>{t('smartPaste.plainLink')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
117
memento-note/components/tiptap-database-block-extension.tsx
Normal file
117
memento-note/components/tiptap-database-block-extension.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { useCallback } from 'react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { DatabaseBlockEditor } from '@/components/database-block-editor'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import {
|
||||
createDefaultDatabaseBlockData,
|
||||
parseDatabaseBlockAttrs,
|
||||
serializeDatabaseBlockData,
|
||||
type DatabaseBlockData,
|
||||
} from '@/lib/editor/database-block-types'
|
||||
|
||||
function DatabaseBlockView({ node, updateAttributes, editor }: NodeViewProps) {
|
||||
const data = parseDatabaseBlockAttrs(node.attrs)
|
||||
|
||||
const requestSave = useCallback(() => {
|
||||
const hostNoteId = editor.storage.liveBlock?.hostNoteId
|
||||
if (hostNoteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId: hostNoteId, reason: 'database-block-mutation' },
|
||||
}))
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleChange = useCallback((next: DatabaseBlockData) => {
|
||||
updateAttributes(serializeDatabaseBlockData(next))
|
||||
requestSave()
|
||||
}, [requestSave, updateAttributes])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="database-block-wrapper" data-drag-handle contentEditable={false}>
|
||||
<DatabaseBlockEditor data={data} onChange={handleChange} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const DatabaseBlockExtension = Node.create({
|
||||
name: 'databaseBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
dbId: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-db-id') || '',
|
||||
renderHTML: (attributes) => (attributes.dbId ? { 'data-db-id': attributes.dbId } : {}),
|
||||
},
|
||||
dbView: {
|
||||
default: 'table',
|
||||
parseHTML: (element) => element.getAttribute('data-db-view') || 'table',
|
||||
renderHTML: (attributes) => ({ 'data-db-view': attributes.dbView || 'table' }),
|
||||
},
|
||||
dbAuthorsJson: {
|
||||
default: '[]',
|
||||
parseHTML: (element) => element.getAttribute('data-db-authors') || '[]',
|
||||
renderHTML: (attributes) => ({ 'data-db-authors': attributes.dbAuthorsJson || '[]' }),
|
||||
},
|
||||
dbBooksJson: {
|
||||
default: '[]',
|
||||
parseHTML: (element) => element.getAttribute('data-db-books') || '[]',
|
||||
renderHTML: (attributes) => ({ 'data-db-books': attributes.dbBooksJson || '[]' }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-database-block]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-database-block': 'true' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(DatabaseBlockView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertDatabaseBlockAtSelection(editor: Editor): boolean {
|
||||
const type = editor.schema.nodes.databaseBlock
|
||||
if (!type) return false
|
||||
|
||||
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
|
||||
const { empty, $from } = editor.state.selection
|
||||
const parent = $from.parent
|
||||
|
||||
if (empty && parent.type.name === 'paragraph' && parent.content.size === 0) {
|
||||
const pos = $from.before()
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
tr.replaceWith(pos, pos + parent.nodeSize, type.create(attrs))
|
||||
if (dispatch) dispatch(tr)
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
return editor.chain().focus().insertContent({ type: 'databaseBlock', attrs }).run()
|
||||
}
|
||||
|
||||
export function replaceBlockWithDatabase(editor: Editor, blockPos: number, blockNode: PMNode): void {
|
||||
const type = editor.schema.nodes.databaseBlock
|
||||
if (!type || blockPos < 0 || !blockNode) return
|
||||
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
|
||||
const dbNode = type.create(attrs)
|
||||
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, dbNode))
|
||||
editor.commands.focus()
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Storage {
|
||||
liveBlock: {
|
||||
hostNoteId: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveBlock Node View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LiveBlockViewProps {
|
||||
node: {
|
||||
attrs: {
|
||||
sourceNoteId: string
|
||||
blockId: string
|
||||
snapshotContent: string
|
||||
sourceNoteTitle: string
|
||||
}
|
||||
}
|
||||
updateAttributes: (attrs: Record<string, string>) => void
|
||||
deleteNode: () => void
|
||||
}
|
||||
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
|
||||
const [localContent, setLocalContent] = useState(snapshotContent || '')
|
||||
const [isDeleted, setIsDeleted] = useState(false)
|
||||
@@ -30,6 +29,15 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
const [pulse, setPulse] = useState(false)
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const requestSave = useCallback(() => {
|
||||
const hostNoteId = editor.storage.liveBlock?.hostNoteId
|
||||
if (hostNoteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId: hostNoteId, reason: 'live-block-mutation' },
|
||||
}))
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Fetch current block status on mount
|
||||
useEffect(() => {
|
||||
if (!sourceNoteId || !blockId) return
|
||||
@@ -37,6 +45,10 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
.then(r => r.json())
|
||||
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
|
||||
if (!data.exists) {
|
||||
if (snapshotContent?.trim()) {
|
||||
setLocalContent(snapshotContent)
|
||||
return
|
||||
}
|
||||
setIsDeleted(true)
|
||||
} else {
|
||||
setLocalContent(data.content)
|
||||
@@ -44,7 +56,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
})
|
||||
.catch(() => setIsOffline(true))
|
||||
}, [sourceNoteId, blockId])
|
||||
}, [sourceNoteId, blockId, snapshotContent, updateAttributes])
|
||||
|
||||
// Listen for real-time block update events
|
||||
useEffect(() => {
|
||||
@@ -70,14 +82,33 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
}, [blockId, updateAttributes])
|
||||
|
||||
const handleDetach = useCallback(async () => {
|
||||
// Convert this node to a plain paragraph with snapshot text
|
||||
deleteNode()
|
||||
}, [deleteNode])
|
||||
/** Convertit le bloc live en paragraphe local (conserve le texte affiché). */
|
||||
const handleDetach = useCallback(() => {
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
const currentNode = editor.state.doc.nodeAt(pos)
|
||||
if (!currentNode) return
|
||||
|
||||
const handleOpenSource = useCallback(() => {
|
||||
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}, [sourceNoteId])
|
||||
const text = localContent.trim()
|
||||
const paragraph = editor.state.schema.nodes.paragraph.create(
|
||||
null,
|
||||
text ? editor.state.schema.text(text) : undefined,
|
||||
)
|
||||
editor.view.dispatch(editor.state.tr.replaceWith(pos, pos + currentNode.nodeSize, paragraph))
|
||||
editor.commands.focus()
|
||||
requestSave()
|
||||
}, [editor, getPos, localContent, requestSave])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
deleteNode()
|
||||
requestSave()
|
||||
}, [deleteNode, requestSave])
|
||||
|
||||
const handleOpenSource = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openNotePeek({ noteId: sourceNoteId, blockId: blockId || undefined })
|
||||
}, [sourceNoteId, blockId])
|
||||
|
||||
const borderClass = isDeleted
|
||||
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
|
||||
@@ -87,54 +118,78 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
|
||||
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
|
||||
|
||||
const headerTitle = isDeleted
|
||||
? t('liveBlock.sourceDisconnected')
|
||||
: (sourceNoteTitle || t('liveBlock.connectedNote'))
|
||||
|
||||
const actionButtonClass =
|
||||
'text-[9.5px] font-bold flex items-center gap-1 transition-all shrink-0'
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="group/liveblock my-4 not-prose">
|
||||
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isDeleted ? (
|
||||
<AlertCircle size={10} className="text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
|
||||
{headerTitle}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
DÉCONNECTÉ
|
||||
{t('liveBlock.statusDisconnected')}
|
||||
</span>
|
||||
) : isOffline ? (
|
||||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
HORS-LIGNE
|
||||
{t('liveBlock.statusOffline')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
|
||||
LIVE
|
||||
{t('liveBlock.statusLive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<button
|
||||
onClick={handleDetach}
|
||||
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center gap-2 shrink-0 ${isDeleted ? '' : 'opacity-0 group-hover/liveblock:opacity-100'} transition-opacity`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetach}
|
||||
title={t('liveBlock.detachHelp')}
|
||||
className={`${actionButtonClass} ${
|
||||
isDeleted
|
||||
? 'text-rose-600 hover:text-rose-500 dark:text-rose-400 hover:underline'
|
||||
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)] dark:hover:text-[var(--color-dark-ink)]'
|
||||
}`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
{t('liveBlock.detachLink')}
|
||||
</button>
|
||||
{!isDeleted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSource}
|
||||
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
className={`${actionButtonClass} text-blue-600 dark:text-blue-400 hover:underline`}
|
||||
contentEditable={false}
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
{t('liveBlock.openSource')} <ArrowRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
title={t('liveBlock.removeBlock')}
|
||||
className={`${actionButtonClass} text-rose-600/80 hover:text-rose-600 dark:text-rose-400/80 dark:hover:text-rose-400`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
@@ -143,7 +198,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
|
||||
contentEditable={false}
|
||||
>
|
||||
{localContent || '(bloc vide)'}
|
||||
{localContent || t('liveBlock.emptyContent')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,6 +218,12 @@ export const LiveBlockExtension = Node.create({
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
hostNoteId: null as string | null,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourceNoteId: { default: '' },
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
const BLOCK_TYPES = ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock']
|
||||
|
||||
function assignMissingBlockIds(tr: Transaction, doc: PMNode) {
|
||||
let modified = false
|
||||
doc.descendants((node, pos) => {
|
||||
if (!BLOCK_TYPES.includes(node.type.name)) return
|
||||
if (node.attrs['data-id']) return
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-id': generateBlockId(),
|
||||
})
|
||||
modified = true
|
||||
})
|
||||
return modified
|
||||
}
|
||||
|
||||
function generateBlockId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
@@ -17,6 +34,18 @@ function generateBlockId(): string {
|
||||
export const UniqueIdExtension = Extension.create({
|
||||
name: 'uniqueId',
|
||||
|
||||
onCreate({ editor }) {
|
||||
const assign = () => {
|
||||
if (editor.isDestroyed) return
|
||||
const { tr, doc } = editor.state
|
||||
if (assignMissingBlockIds(tr, doc)) {
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
assign()
|
||||
requestAnimationFrame(assign)
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
@@ -26,20 +55,7 @@ export const UniqueIdExtension = Extension.create({
|
||||
if (!hasDocChanged) return null
|
||||
|
||||
const { tr, doc } = newState
|
||||
let modified = false
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!BLOCK_TYPES.includes(node.type.name)) return
|
||||
if (node.attrs['data-id']) return
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-id': generateBlockId(),
|
||||
})
|
||||
modified = true
|
||||
})
|
||||
|
||||
return modified ? tr : null
|
||||
return assignMissingBlockIds(tr, doc) ? tr : null
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -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
|
||||
|
||||
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal file
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal 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 }
|
||||
}
|
||||
122
memento-note/lib/editor/block-reference-id.ts
Normal file
122
memento-note/lib/editor/block-reference-id.ts
Normal 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
|
||||
}
|
||||
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal file
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
100
memento-note/lib/editor/database-block-types.ts
Normal file
100
memento-note/lib/editor/database-block-types.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal file
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal 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 }
|
||||
}
|
||||
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal file
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal 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'],
|
||||
}),
|
||||
]
|
||||
67
memento-note/lib/editor/parse-block-reference.ts
Normal file
67
memento-note/lib/editor/parse-block-reference.ts
Normal 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
|
||||
}
|
||||
}
|
||||
39
memento-note/lib/editor/smart-paste-extension.ts
Normal file
39
memento-note/lib/editor/smart-paste-extension.ts
Normal 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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
18
memento-note/lib/note-peek-sync.ts
Normal file
18
memento-note/lib/note-peek-sync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Ouvre la note source en aperçu split (panneau gauche) sans changer d’onglet. */
|
||||
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))
|
||||
}
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/(?:�*39;|')/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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
10
memento-note/lib/use-hydrated.ts
Normal file
10
memento-note/lib/use-hydrated.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
source: '/reminders',
|
||||
destination: '/?reminders=1',
|
||||
destination: '/home?reminders=1',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
|
||||
610
memento-note/package-lock.json
generated
610
memento-note/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user