feat(editor): implement next-gen editor with unique gutter drag handle, block actions menu, smart paste transclusion, and redesigned inline structured view block (US-NEXTGEN-EDITOR, US-4)
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
Heading1, Heading2, Heading3, List, ListOrdered,
|
||||
CheckSquare, Quote, CodeXml, Database,
|
||||
} from 'lucide-react'
|
||||
import { replaceBlockWithDatabase } from '@/components/tiptap-database-block-extension'
|
||||
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
|
||||
|
||||
interface BlockActionMenuProps {
|
||||
editor: Editor
|
||||
@@ -132,7 +132,8 @@ export function BlockActionMenu({
|
||||
const handleTurnInto = useCallback((option: TurnIntoOption) => {
|
||||
if (blockPos >= 0 && blockNode) {
|
||||
if (option.isDatabase) {
|
||||
replaceBlockWithDatabase(editor, blockPos, blockNode)
|
||||
const notebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
replaceBlockWithStructuredView(editor, blockPos, blockNode, notebookId)
|
||||
} else if (option.command) {
|
||||
focusBlock(editor, blockPos)
|
||||
option.command(editor)
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -107,7 +107,8 @@ export function NoteContentArea() {
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
notebookId={note.notebookId}
|
||||
noteTitle={state.title || note.title || undefined}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
</div>
|
||||
@@ -123,7 +124,8 @@ export function NoteContentArea() {
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
notebookId={note.notebookId}
|
||||
noteTitle={state.title || note.title || undefined}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
<GhostTags
|
||||
|
||||
@@ -25,7 +25,7 @@ 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 { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
|
||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
@@ -69,13 +69,14 @@ export interface RichTextEditorHandle {
|
||||
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
export interface RichTextEditorProps {
|
||||
content?: string
|
||||
onChange?: (content: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
notebookId?: string | null
|
||||
noteTitle?: string
|
||||
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||
sourceUrl?: string | null
|
||||
@@ -190,12 +191,12 @@ const slashCommands: SlashItem[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertDatabaseBlockAtSelection(e) },
|
||||
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertStructuredViewBlockAtSelection(e) },
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
|
||||
const res = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -250,7 +251,7 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
@@ -392,7 +393,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
LiveBlockExtension,
|
||||
DatabaseBlockExtension,
|
||||
StructuredViewBlockExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
@@ -409,15 +410,15 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
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
|
||||
if (!/\/(database|db|vue|tableau|structured)$/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'))
|
||||
if (!insertStructuredViewBlockAtSelection(ed, notebookId)) {
|
||||
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
|
||||
}
|
||||
return true
|
||||
},
|
||||
@@ -509,6 +510,13 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
editor.storage.liveBlock.hostNoteId = noteId ?? null
|
||||
}, [editor, noteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if ((editor.storage as any).structuredViewBlock) {
|
||||
(editor.storage as any).structuredViewBlock.notebookId = notebookId ?? null
|
||||
}
|
||||
}, [editor, notebookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
@@ -516,7 +524,10 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
|
||||
let blockRef = parseBlockReferenceFromText(clipboardText)
|
||||
if (!blockRef) {
|
||||
blockRef = recallLastBlockReference()
|
||||
const recalled = recallLastBlockReference()
|
||||
if (recalled && (!clipboardText.trim() || clipboardText.trim() === recalled.raw)) {
|
||||
blockRef = recalled
|
||||
}
|
||||
}
|
||||
if (!blockRef) return false
|
||||
|
||||
@@ -961,7 +972,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
|
||||
{chartSuggestionsOpen && (
|
||||
{chartSuggestionsOpen && editor && (
|
||||
<ChartSuggestionsDialog
|
||||
isOpen={chartSuggestionsOpen}
|
||||
content={currentNoteContent}
|
||||
@@ -1096,7 +1107,7 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
|
||||
setTranslateOpen(false)
|
||||
try {
|
||||
const lang = option === 'translate' ? (targetLang || language) : language
|
||||
const result = await aiReformulate(text, option, lang)
|
||||
const result = await aiReformulate(text, option, t, lang)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
if (option === 'explain') {
|
||||
setAiModal({ type: 'explain', origText: text, html: result, from, to })
|
||||
@@ -1283,7 +1294,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'] },
|
||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1323,7 +1334,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (!consented) return
|
||||
const allText = editor.state.doc.textContent
|
||||
if (!allText || allText.split(/\s+/).length < 5) return
|
||||
const result = await aiReformulate(allText, item.aiOption)
|
||||
const result = await aiReformulate(allText, item.aiOption, t)
|
||||
editor.chain().focus().setContent(result).run()
|
||||
} catch (err) {
|
||||
console.error('AI slash error:', err)
|
||||
@@ -1334,8 +1345,9 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
|
||||
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
|
||||
}
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
@@ -1394,11 +1406,12 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const item = filtered[selectedIndex]
|
||||
if (item) {
|
||||
handleSelect(item)
|
||||
} else if (/^(database|db)$/i.test(query)) {
|
||||
} else if (/^(database|db|vue|tableau|structured)$/i.test(query)) {
|
||||
deleteSlashText()
|
||||
closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
|
||||
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
|
||||
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
1244
memento-note/components/structured-view-block-embed.tsx
Normal file
1244
memento-note/components/structured-view-block-embed.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, Fragment } from 'react'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -26,7 +27,41 @@ import { PropertyValueEditor } from './property-value-editor'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { ChevronDown, ChevronUp, Filter, Trash2 } from 'lucide-react'
|
||||
import { enUS, fr, faIR } from 'date-fns/locale'
|
||||
import { ChevronDown, ChevronUp, Filter, Trash2, Sparkles, Brain, Loader2, ArrowUpRight, Link2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
|
||||
const localeMap: Record<string, any> = {
|
||||
en: enUS,
|
||||
fr,
|
||||
fa: faIR
|
||||
}
|
||||
|
||||
function getDateLocale(lang: string) {
|
||||
return localeMap[lang] || enUS
|
||||
}
|
||||
|
||||
const getBestFallbackKeyword = (title: string): string => {
|
||||
const STOP_WORDS = new Set([
|
||||
'dans', 'avec', 'pour', 'plus', 'tout', 'tous', 'cette', 'ceux', 'mais', 'sans',
|
||||
'faire', 'fait', 'comme', 'sont', 'ont', 'etaient', 'etait', 'sujet', 'fiche', 'page',
|
||||
'note', 'notes', 'guide', 'guides', 'projet', 'projets', 'test', 'tests', 'demo', 'base',
|
||||
'bases', 'donnee', 'donnees', 'tableau', 'tableaux', 'quel', 'quels', 'quelle', 'quelles',
|
||||
'about', 'with', 'from', 'that', 'this', 'your', 'have', 'were', 'was', 'project',
|
||||
'projects', 'test', 'tests', 'page', 'pages', 'file', 'files', 'some', 'more', 'their',
|
||||
'them', 'they', 'what', 'which', 'where', 'when', 'how', 'who', 'why'
|
||||
])
|
||||
|
||||
const words = title
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}\d]+/u)
|
||||
.filter(w => w.length > 3 && !STOP_WORDS.has(w))
|
||||
|
||||
if (words.length === 0) return ''
|
||||
words.sort((a, b) => b.length - a.length)
|
||||
return words[0]
|
||||
}
|
||||
|
||||
type NotesStructuredTableProps = {
|
||||
notes: Note[]
|
||||
@@ -54,6 +89,96 @@ export function NotesStructuredTable({
|
||||
const [propertyToDelete, setPropertyToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||
const [deletingProperty, setDeletingProperty] = useState(false)
|
||||
|
||||
// Memory Echo states
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [activeEchoNoteId, setActiveEchoNoteId] = useState<string | null>(null)
|
||||
const [echoLoading, setEchoLoading] = useState(false)
|
||||
const [echoError, setEchoError] = useState<string | null>(null)
|
||||
const [echoConnections, setEchoConnections] = useState<Array<{ noteId: string; title: string; similarity: number; isTextMatch?: boolean }>>([])
|
||||
|
||||
const handleFetchRealEcho = async (noteId: string) => {
|
||||
if (activeEchoNoteId === noteId) {
|
||||
setActiveEchoNoteId(null)
|
||||
setEchoConnections([])
|
||||
setEchoError(null)
|
||||
return
|
||||
}
|
||||
setActiveEchoNoteId(noteId)
|
||||
setEchoLoading(true)
|
||||
setEchoConnections([])
|
||||
setEchoError(null)
|
||||
|
||||
try {
|
||||
// 1. GDPR AI Consent check
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) {
|
||||
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
|
||||
setEchoLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Fetch connections
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=5`)
|
||||
|
||||
if (res.status === 403) {
|
||||
setEchoError("Le consentement pour le traitement par IA est requis pour utiliser la résonance sémantique.")
|
||||
setEchoLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (json.connections && Array.isArray(json.connections) && json.connections.length > 0) {
|
||||
setEchoConnections(json.connections)
|
||||
} else {
|
||||
// 3. Fallback: Si aucune connexion pré-calculée en DB, on fait une recherche textuelle en direct
|
||||
const targetNote = notes.find(n => n.id === noteId)
|
||||
const q = (targetNote?.title || '').trim()
|
||||
|
||||
if (!q) {
|
||||
setEchoError("Cette note n'a pas encore de titre ou de contenu suffisant pour trouver des résonances sémantiques.")
|
||||
setEchoLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let fallbackRes = await fetch(`/api/notes?search=${encodeURIComponent(q)}&limit=5`)
|
||||
let fallbackJson = await fallbackRes.json()
|
||||
|
||||
let filteredNotes = (fallbackJson.success && Array.isArray(fallbackJson.data))
|
||||
? fallbackJson.data.filter((n: any) => n.id !== noteId)
|
||||
: []
|
||||
|
||||
if (filteredNotes.length === 0) {
|
||||
// Fallback to searching significant words if full title matches nothing else
|
||||
const fallbackWord = getBestFallbackKeyword(q)
|
||||
if (fallbackWord) {
|
||||
const resFallback = await fetch(`/api/notes?search=${encodeURIComponent(fallbackWord)}&limit=5`)
|
||||
const jsonFallback = await resFallback.json()
|
||||
if (jsonFallback.success && Array.isArray(jsonFallback.data)) {
|
||||
filteredNotes = jsonFallback.data.filter((n: any) => n.id !== noteId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredNotes.length === 0) {
|
||||
setEchoError(`Aucune note similaire à "${q}" n'a été détectée dans votre espace de travail.`)
|
||||
} else {
|
||||
setEchoConnections(filteredNotes.map((n: any, idx: number) => ({
|
||||
noteId: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
similarity: 0.82 - idx * 0.04,
|
||||
isTextMatch: true
|
||||
})))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setEchoError("Une erreur est survenue lors de la recherche.")
|
||||
} finally {
|
||||
setEchoLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filters: ColumnFilter[] = useMemo(() => {
|
||||
if (!filterPropId) return []
|
||||
return [{ propertyId: filterPropId, operator: filterOp, value: filterValue }]
|
||||
@@ -173,36 +298,134 @@ export function NotesStructuredTable({
|
||||
{displayed.map((note) => {
|
||||
const vals = noteValues[note.id] ?? {}
|
||||
return (
|
||||
<tr key={note.id} className="hover:bg-foreground/[0.02] transition-colors group">
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(note)}
|
||||
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[220px] group-hover:text-brand-accent transition-colors"
|
||||
>
|
||||
{getNoteDisplayTitle(note, untitled)}
|
||||
</button>
|
||||
</td>
|
||||
{schema.properties.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="px-4 py-2 align-top"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="min-w-[100px] max-w-[180px]">
|
||||
<PropertyValueEditor
|
||||
property={p}
|
||||
value={vals[p.id]}
|
||||
compact
|
||||
onChange={(v) => onPropertyChange(note.id, p.id, v)}
|
||||
/>
|
||||
<Fragment key={note.id}>
|
||||
<tr key={note.id} className="hover:bg-foreground/[0.015] transition-colors group">
|
||||
<td className="px-4 py-2 align-middle">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(note)}
|
||||
className="font-memento-serif text-[13px] font-medium text-left truncate max-w-[200px] group-hover:text-brand-accent transition-colors shrink-0"
|
||||
>
|
||||
{getNoteDisplayTitle(note, untitled)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFetchRealEcho(note.id)}
|
||||
className={cn(
|
||||
"opacity-0 group-hover:opacity-100 p-1 rounded transition-all shrink-0",
|
||||
activeEchoNoteId === note.id
|
||||
? "text-purple-400 bg-purple-500/10 opacity-100"
|
||||
: "text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10"
|
||||
)}
|
||||
title="Résonances sémantiques"
|
||||
>
|
||||
<Sparkles size={11} className={activeEchoNoteId === note.id ? "animate-pulse" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy')}
|
||||
</td>
|
||||
</tr>
|
||||
{schema.properties.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="px-4 py-2 align-top"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="min-w-[100px] max-w-[180px]">
|
||||
<PropertyValueEditor
|
||||
property={p}
|
||||
value={vals[p.id]}
|
||||
compact
|
||||
onChange={(v) => onPropertyChange(note.id, p.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2 text-[11px] text-muted-foreground whitespace-nowrap align-middle">
|
||||
{formatAbsoluteDateLocalized(new Date(note.updatedAt), language, 'MMM d, yyyy', getDateLocale(language))}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Memory Echo collapsible details */}
|
||||
{activeEchoNoteId === note.id && (
|
||||
<tr className="bg-purple-500/[0.015]">
|
||||
<td colSpan={schema.properties.length + 3} className="px-5 py-3.5 border-t border-b border-purple-500/10">
|
||||
<div className="space-y-3 animate-in slide-in-from-top-1 duration-200">
|
||||
<div className="flex items-center justify-between text-[11px] font-bold text-foreground/80">
|
||||
<span className="flex items-center gap-1.5 text-purple-400">
|
||||
<Brain className="w-3.5 h-3.5 animate-pulse" />
|
||||
{t('structuredViewBlock.echoPopoverTitle') || 'Résonance Sémantique 🔮'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setActiveEchoNoteId(null); setEchoConnections([]); }}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{echoLoading ? (
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-purple-400" />
|
||||
<span>{t('structuredViewBlock.echoLoading') || 'Recherche de connexions sémantiques...'}</span>
|
||||
</div>
|
||||
) : echoError ? (
|
||||
<p className="text-[11px] text-muted-foreground/90 font-medium py-1">
|
||||
{echoError}
|
||||
</p>
|
||||
) : echoConnections.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground italic py-1">
|
||||
{t('structuredViewBlock.noEchoFound') || 'Aucune résonance sémantique détectée.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-w-2xl">
|
||||
{echoConnections.map((conn) => (
|
||||
<div
|
||||
key={conn.noteId}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl bg-card border border-border/50 hover:border-purple-500/40 hover:bg-purple-500/[0.02] text-left text-[11px] transition-all group shadow-sm gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNotePeek({ noteId: conn.noteId })}
|
||||
className="flex-1 text-left truncate font-semibold text-foreground/80 hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{conn.title}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.dispatchEvent(new CustomEvent('memento-insert-citation', {
|
||||
detail: {
|
||||
noteId: conn.noteId,
|
||||
noteTitle: conn.title || 'Sans titre',
|
||||
excerpt: '',
|
||||
atEnd: false
|
||||
}
|
||||
}))
|
||||
}}
|
||||
className="p-1 text-muted-foreground hover:text-purple-400 hover:bg-purple-500/10 rounded transition-colors"
|
||||
title="Insérer le lien dans l'éditeur"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-[9px] text-purple-400 font-bold bg-purple-500/10 px-2 py-0.5 rounded-full flex items-center gap-0.5 border border-purple-500/20">
|
||||
{conn.isTextMatch ? 'Mot-clé' : `${Math.round((conn.similarity || 0) * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
'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()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { StructuredViewBlockEmbed } from '@/components/structured-view-block-embed'
|
||||
|
||||
function StructuredViewBlockView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper className="structured-view-block-wrapper" data-drag-handle contentEditable={false}>
|
||||
<StructuredViewBlockEmbed
|
||||
notebookId={props.node.attrs.notebookId}
|
||||
displayMode={props.node.attrs.displayMode}
|
||||
filterJson={props.node.attrs.filterJson}
|
||||
isLocal={props.node.attrs.isLocal}
|
||||
localColumnsJson={props.node.attrs.localColumnsJson}
|
||||
localRowsJson={props.node.attrs.localRowsJson}
|
||||
updateAttributes={props.updateAttributes}
|
||||
editor={props.editor}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultColumns = [
|
||||
{ id: 'col-title', name: 'Nom', type: 'text' },
|
||||
{ id: 'col-done', name: 'Fait', type: 'checkbox' },
|
||||
{ id: 'col-status', name: 'Statut', type: 'select', options: ['À faire', 'En cours', 'Terminé'] }
|
||||
]
|
||||
const defaultRows = [
|
||||
{ id: 'row-1', values: { 'col-title': '', 'col-done': false, 'col-status': 'À faire' } },
|
||||
{ id: 'row-2', values: { 'col-title': '', 'col-done': false, 'col-status': 'À faire' } }
|
||||
]
|
||||
|
||||
export const StructuredViewBlockExtension = Node.create({
|
||||
name: 'structuredViewBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
notebookId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('data-sv-notebook-id'),
|
||||
renderHTML: (attrs) => attrs.notebookId
|
||||
? { 'data-sv-notebook-id': attrs.notebookId }
|
||||
: {},
|
||||
},
|
||||
displayMode: {
|
||||
default: 'table',
|
||||
parseHTML: (el) => el.getAttribute('data-sv-mode') || 'table',
|
||||
renderHTML: (attrs) => ({ 'data-sv-mode': attrs.displayMode || 'table' }),
|
||||
},
|
||||
filterJson: {
|
||||
default: '{}',
|
||||
parseHTML: (el) => el.getAttribute('data-sv-filter') || '{}',
|
||||
renderHTML: (attrs) => ({ 'data-sv-filter': attrs.filterJson || '{}' }),
|
||||
},
|
||||
isLocal: {
|
||||
default: true, // Par défaut, on crée une base de données locale autonome
|
||||
parseHTML: (el) => el.getAttribute('data-sv-is-local') === 'true',
|
||||
renderHTML: (attrs) => attrs.isLocal
|
||||
? { 'data-sv-is-local': 'true' }
|
||||
: { 'data-sv-is-local': 'false' },
|
||||
},
|
||||
localColumnsJson: {
|
||||
default: JSON.stringify(defaultColumns),
|
||||
parseHTML: (el) => el.getAttribute('data-sv-local-cols') || '[]',
|
||||
renderHTML: (attrs) => ({ 'data-sv-local-cols': attrs.localColumnsJson || '[]' }),
|
||||
},
|
||||
localRowsJson: {
|
||||
default: JSON.stringify(defaultRows),
|
||||
parseHTML: (el) => el.getAttribute('data-sv-local-rows') || '[]',
|
||||
renderHTML: (attrs) => ({ 'data-sv-local-rows': attrs.localRowsJson || '[]' }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: 'div[data-structured-view-block]' },
|
||||
{
|
||||
tag: 'div[data-database-block]',
|
||||
getAttrs: () => ({
|
||||
isLocal: true,
|
||||
localColumnsJson: JSON.stringify(defaultColumns),
|
||||
localRowsJson: JSON.stringify(defaultRows),
|
||||
})
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-structured-view-block': 'true' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(StructuredViewBlockView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertStructuredViewBlockAtSelection(editor: Editor, notebookId?: string | null): boolean {
|
||||
const type = editor.schema.nodes.structuredViewBlock
|
||||
if (!type) return false
|
||||
|
||||
// Par défaut, si la note a un carnet et qu'on n'est pas dans l'inbox, on peut l'insérer liée au carnet.
|
||||
// Mais si aucun notebookId n'est passé ou si on veut favoriser la base locale autonome Notion-like par défaut :
|
||||
// On l'insère en base locale autonome par défaut !
|
||||
const attrs = {
|
||||
notebookId: null,
|
||||
displayMode: 'table',
|
||||
filterJson: '{}',
|
||||
isLocal: true,
|
||||
localColumnsJson: JSON.stringify(defaultColumns),
|
||||
localRowsJson: JSON.stringify(defaultRows)
|
||||
}
|
||||
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: 'structuredViewBlock', attrs }).run()
|
||||
}
|
||||
|
||||
export function replaceBlockWithStructuredView(
|
||||
editor: Editor,
|
||||
blockPos: number,
|
||||
blockNode: PMNode,
|
||||
notebookId?: string | null
|
||||
): void {
|
||||
const type = editor.schema.nodes.structuredViewBlock
|
||||
if (!type || blockPos < 0 || !blockNode) return
|
||||
const attrs = {
|
||||
notebookId: null,
|
||||
displayMode: 'table',
|
||||
filterJson: '{}',
|
||||
isLocal: true,
|
||||
localColumnsJson: JSON.stringify(defaultColumns),
|
||||
localRowsJson: JSON.stringify(defaultRows)
|
||||
}
|
||||
const svNode = type.create(attrs)
|
||||
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, svNode))
|
||||
editor.commands.focus()
|
||||
}
|
||||
Reference in New Issue
Block a user