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:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1779909959153,
|
||||
"turnsSinceLastRun": 4,
|
||||
"turnsSinceLastRun": 5,
|
||||
"lastTranscriptMtimeMs": 1779909958790.594,
|
||||
"lastProcessedGenerationId": "5bd39ea3-9f17-44d1-8aed-3cc8f3673e97",
|
||||
"lastProcessedGenerationId": "dc55cac3-bead-4e51-af66-3b48e77deb37",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: 'US-4 Redesign — Embedded Structured View Block (replaces authors/works demo)'
|
||||
type: 'refactor'
|
||||
created: '2026-05-27'
|
||||
status: 'draft'
|
||||
context:
|
||||
- 'docs/story-nextgen-editor-us4-redesign.md'
|
||||
- 'memento-note/lib/structured-views/types.ts'
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** The current `/database` slash command inserts a self-contained "Authors & Works" relational block with hardcoded Jules Verne / Liu Cixin demo data stored as JSON blobs in TipTap node attributes — completely disconnected from Structured Views (NotebookSchema / NoteProperty), duplicating a data model and confusing users who expect a real notebook-linked view.
|
||||
|
||||
**Approach:** Replace the current `databaseBlock` TipTap node and its three files (extension, editor component, types) with a new `structuredViewBlock` node that stores only a reference (`notebookId`, `displayMode`, `filter`), then reads live data from the existing Structured Views API — or falls back gracefully when the notebook has no schema.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:**
|
||||
- The block stores only a reference (`notebookId` + metadata attrs) — never serialises note rows/property values into TipTap HTML.
|
||||
- The editor must receive `notebookId` from its parent (`note-content-area.tsx`) for the block to resolve its schema.
|
||||
- i18n: all labels go through `useLanguage()` with keys in EN/FR minimum; no hardcoded strings.
|
||||
- Mutations (add note, edit property value) use optimistic updates via `NOTE_REQUEST_SAVE_EVENT` — no full revalidatePath.
|
||||
- If the current notebook has no `NotebookSchema`, show a contextual callout ("This notebook has no structured view — set one up from the notebook header") instead of a blank block; never auto-insert demo data.
|
||||
- Migration: silently drop legacy `databaseBlock` nodes on load (treat as unknown node, ProseMirror drops them or show an "outdated block" placeholder).
|
||||
- On delete of the block: only the TipTap reference node is removed; no Prisma records are affected.
|
||||
- RTL (fa, ar): the block wrapper must respect `dir="auto"`.
|
||||
|
||||
**Ask First:**
|
||||
- Whether the block should support inline editing of property values (clicking a cell to edit) or be read-only with a "Open in notebook" CTA only — founder decides before coding begins.
|
||||
- Whether Kanban view is in scope for v1 inline (complex drag-and-drop inside a TipTap NodeView) or deferred to v2.
|
||||
|
||||
**Never:**
|
||||
- Insert any demo data (no Jules Verne, no placeholders rows, no fake schema).
|
||||
- Create a second data storage system parallel to NotebookSchema/NoteProperty.
|
||||
- Store note rows or property values in TipTap node attributes.
|
||||
- Copy the `DatabaseBlockEditor` or `database-block-types.ts` pattern.
|
||||
- Use `revalidatePath` for mutations from inside the block.
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Happy path — structured notebook | Note in structured notebook, user types `/vue` → insert block | Block renders table of notes with their schema properties | — |
|
||||
| No schema on notebook | Note is in a plain notebook (no NotebookSchema) | Block shows callout "Set up a structured view from the notebook header" with link | CTA links to notebook toolbar wizard |
|
||||
| Note not in any notebook | Note has no `notebookId` | Block shows "This block requires a notebook. Move this note to a notebook to use it." | Graceful inline message |
|
||||
| Legacy `databaseBlock` node found on load | HTML contains `data-database-block="true"` | ProseMirror skips the node (unknown type after extension removal); optionally show 1-line deprecation placeholder | No crash, no data loss on other content |
|
||||
| API error fetching schema | `GET /api/notebooks/:id/schema` 500 | Block shows error state with retry button | Console.error, no spinner freeze |
|
||||
| Inline edit (if in scope) — optimistic patch | User edits a cell → PATCH `/api/notes/:id/properties` | Optimistic update on cell, save triggered via NOTE_REQUEST_SAVE_EVENT | Rollback cell on API error |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `memento-note/components/tiptap-database-block-extension.tsx` -- **DELETE** — legacy authors/works TipTap node
|
||||
- `memento-note/components/database-block-editor.tsx` -- **DELETE** — legacy authors/works editor
|
||||
- `memento-note/lib/editor/database-block-types.ts` -- **DELETE** — legacy types with Verne/Cixin data
|
||||
- `memento-note/components/tiptap-structured-view-block-extension.tsx` -- **NEW** — TipTap Node extension (`structuredViewBlock`, attrs: `notebookId`, `displayMode: 'table'|'gallery'`, `filterJson`)
|
||||
- `memento-note/components/structured-view-block-embed.tsx` -- **NEW** — React NodeView component; fetches schema via SWR(`/api/notebooks/${notebookId}/schema`) and renders `NotesStructuredTable` or `NotesGalleryView` in read-only mode (or editable if founder approves inline edit)
|
||||
- `memento-note/components/note-editor/note-content-area.tsx` -- **MODIFY** — pass `notebookId={note.notebookId}` to `<RichTextEditor>`
|
||||
- `memento-note/components/rich-text-editor.tsx` -- **MODIFY** — accept `notebookId?: string` prop; pass it to editor storage; register `StructuredViewBlockExtension` in place of `DatabaseBlockExtension`; update slash command entry (label, keywords, handler); update block action menu's "Transform into" option
|
||||
- `memento-note/components/block-action-menu.tsx` -- **MODIFY** — replace `database` option with `structuredView`; import from new extension
|
||||
- `memento-note/locales/en.json` + `fr.json` -- **MODIFY** — add keys: `structuredViewBlock.insertLabel`, `structuredViewBlock.insertDesc`, `structuredViewBlock.noSchema`, `structuredViewBlock.noNotebook`, `structuredViewBlock.openInNotebook`, `structuredViewBlock.displayModeTable`, `structuredViewBlock.displayModeGallery`; remove all `databaseBlock.*` keys (after verifying no other consumer)
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution:**
|
||||
- [ ] `memento-note/lib/editor/database-block-types.ts` -- DELETE file -- eliminates Verne/Cixin hardcoded data and legacy type definitions
|
||||
- [ ] `memento-note/components/database-block-editor.tsx` -- DELETE file -- removes authors/works editor UI
|
||||
- [ ] `memento-note/components/tiptap-database-block-extension.tsx` -- DELETE file -- removes legacy TipTap node extension
|
||||
- [ ] `memento-note/components/tiptap-structured-view-block-extension.tsx` -- CREATE — TipTap Node `structuredViewBlock` with attrs `{ notebookId: string, displayMode: 'table'|'gallery', filterJson: string }`, `ReactNodeViewRenderer(StructuredViewBlockEmbed)`
|
||||
- [ ] `memento-note/components/structured-view-block-embed.tsx` -- CREATE — NodeView wrapper: SWR fetch schema, conditional rendering (no-schema callout / no-notebook callout / table or gallery view), RTL-safe wrapper, `dir="auto"`
|
||||
- [ ] `memento-note/components/note-editor/note-content-area.tsx` -- MODIFY — add `notebookId={note.notebookId ?? undefined}` prop to both `<RichTextEditor>` instances
|
||||
- [ ] `memento-note/components/rich-text-editor.tsx` -- MODIFY — (a) add `notebookId?: string` to props, store in `editor.storage.structuredViewBlock = { notebookId }`; (b) swap `DatabaseBlockExtension` for `StructuredViewBlockExtension`; (c) update slash entry: title key `structuredViewBlock.insertLabel`, description key `structuredViewBlock.insertDesc`, keywords `['vue', 'tableau', 'structuré', 'structured', 'view', 'database', 'db']`; (d) update slash handler to call `insertStructuredViewBlockAtSelection(editor, notebookId)`
|
||||
- [ ] `memento-note/components/block-action-menu.tsx` -- MODIFY — replace `database` transform option with `structuredView`; update import
|
||||
- [ ] `memento-note/locales/en.json` + `fr.json` -- MODIFY — add new i18n keys (see Code Map); remove `databaseBlock.*` keys
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given a note in a structured notebook, when user types `/vue` or `/structured`, then a `structuredViewBlock` node is inserted showing the notebook's notes and their properties — no demo data appears.
|
||||
- Given the block is inserted, when the notebook has no schema, then the block shows a callout with a link to the notebook header wizard — no crash, no empty white box.
|
||||
- Given the note has no notebookId, when `/vue` is triggered, then the block shows an inline "requires a notebook" message.
|
||||
- Given a note with a legacy `data-database-block` node, when the note is opened, then the legacy block is silently dropped (or shown as deprecated placeholder) and the rest of the note content is intact.
|
||||
- Given the block is visible, when user resizes or the app language is Persian (fa, RTL), then the block layout is mirrored correctly.
|
||||
- Given the slash command menu opens, when user searches "database" or "db", then the new `structuredViewBlock` entry appears (backward-compatible keywords).
|
||||
- Given "Transform into" is opened in the block action menu, the "Database" option is replaced by "Vue structurée" / "Structured View".
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Design Notes
|
||||
|
||||
**Why reference-only attrs?** Storing note rows in TipTap HTML scales to O(notes × properties) per keystroke — unacceptable for perf. The reference pattern (`notebookId` only) is how Notion "linked database" blocks work and aligns with Momento's already-existing API layer.
|
||||
|
||||
**notebookId propagation:** The editor currently receives only `noteId`. The smallest change is to add `notebookId` as a prop to `RichTextEditor` (already used by `note-content-area.tsx`). No context change needed.
|
||||
|
||||
**Legacy migration:** ProseMirror silently drops unknown node types when the corresponding extension is not registered. Removing `DatabaseBlockExtension` from the extensions array is sufficient for new sessions. For existing notes with the old HTML, the `data-database-block` div will parse as an unrecognised block and ProseMirror will omit it. If this is too silent, add a `parseHTML` rule in the new extension that matches `div[data-database-block]` and converts it to a deprecated-placeholder paragraph.
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `npm run lint --prefix memento-note` -- expected: 0 errors (no imports from deleted files)
|
||||
- `npm run build --prefix memento-note` -- expected: build succeeds, no missing module errors
|
||||
|
||||
**Manual checks (if no CLI):**
|
||||
- Open a note in a structured notebook → type `/vue` → confirm block appears with real notes (not Jules Verne).
|
||||
- Open a note in a plain notebook → type `/vue` → confirm "no schema" callout appears.
|
||||
- Open a note with a saved legacy `databaseBlock` → confirm note loads cleanly, no crash.
|
||||
- Switch app language to `fa` → confirm block layout is RTL-mirrored.
|
||||
476
docs/story-nextgen-editor-us4-redesign.md
Normal file
476
docs/story-nextgen-editor-us4-redesign.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# US-4 Redesign — Vue Structurée Inline dans l'Éditeur
|
||||
|
||||
> **Status :** DRAFT — En attente de validation fondateur
|
||||
> **Remplace :** US-4 « Bloc de Base de Données Relationnelle Inline » (DEPRECATED)
|
||||
> **Epic :** US-NEXTGEN-EDITOR
|
||||
> **Dépend de :** US-STRUCTURED-VIEWS ✅ (livré), US-LIVING-BLOCKS ✅ (livré)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement — Pourquoi l'ancienne US-4 est morte
|
||||
|
||||
L'implémentation actuelle (fichiers `tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`) a été rejetée pour les raisons suivantes :
|
||||
|
||||
### 1.1 Copie de démo marketing, pas une feature produit
|
||||
|
||||
Le code source dans `createDefaultDatabaseBlockData()` insère **Jules Verne** et **Liu Cixin** avec leurs œuvres en données pré-remplies. C'est une copie directe du prototype `architectural-grid1/ModernBlockNoteEditor.tsx` qui était conçu pour une landing page, pas pour un produit de prise de notes réel.
|
||||
|
||||
### 1.2 Modèle de données parallèle et isolé
|
||||
|
||||
L'implémentation stocke `dbAuthorsJson` et `dbBooksJson` comme attributs HTML dans le nœud TipTap :
|
||||
|
||||
```html
|
||||
<div data-database-block="true" data-db-id="authors-works-abc123"
|
||||
data-db-authors='[{"id":"a1","name":"Jules Verne"}]'
|
||||
data-db-books='[{"id":"bk1","title":"Vingt Mille Lieues..."}]'>
|
||||
```
|
||||
|
||||
Problèmes critiques :
|
||||
- **Isolation totale** : ces données n'ont aucun lien avec le carnet, les notes, ni les propriétés Prisma.
|
||||
- **Scalabilité zéro** : stocker des listes entières de rows dans les attributs d'un nœud TipTap = explostion de la taille du HTML à chaque sauvegarde.
|
||||
- **Duplication du modèle** : `NotebookSchema` + `NotebookProperty` + `NoteProperty` existent déjà en BDD et fonctionnent.
|
||||
|
||||
### 1.3 Confusion utilisateur
|
||||
|
||||
Un utilisateur qui tape `/database` s'attend à voir ses propres données structurées — pas un jeu de rôle bibliothèque de SF. Cette confusion est documentée comme rejet non-négociable par le fondateur.
|
||||
|
||||
### 1.4 Audit du code à supprimer
|
||||
|
||||
| Fichier | Statut | Raison |
|
||||
|---------|--------|--------|
|
||||
| `memento-note/components/tiptap-database-block-extension.tsx` | **SUPPRIMER** | Extension TipTap avec modèle `dbAuthors`/`dbBooks` |
|
||||
| `memento-note/components/database-block-editor.tsx` | **SUPPRIMER** | UI Auteurs & Œuvres, hardcodé |
|
||||
| `memento-note/lib/editor/database-block-types.ts` | **SUPPRIMER** | Types + données Verne/Liu Cixin |
|
||||
| `rich-text-editor.tsx` L28, L193-194, L395, L412-420, L1286, L1335-1338, L1397-1401 | **MODIFIER** | Retirer imports et références `/database` anciens |
|
||||
| `block-action-menu.tsx` L15-17, L33, L39, L51, L134-135 | **MODIFIER** | Retirer option `database` du menu "Transformer en" |
|
||||
| `locales/*.json` — clés `databaseBlock.*` | **SUPPRIMER** | ~15 clés obsolètes |
|
||||
|
||||
---
|
||||
|
||||
## 2. Personas & Jobs-to-be-Done
|
||||
|
||||
### Persona 1 — Chloé, chercheuse (carnet "Thèse Linguistique")
|
||||
|
||||
Chloé a configuré son carnet avec des propriétés : `Statut` (select), `Langue` (select), `Lu` (checkbox), `Source` (text). Elle a 47 notes dans ce carnet. Elle rédige un chapitre synthétique et veut **voir le tableau de ses sources directement sous son paragraphe d'introduction**, sans quitter l'éditeur.
|
||||
|
||||
**Job-to-be-done :** *"Quand je rédige, je veux voir le tableau de mes données structurées en contexte, sans naviguer vers le carnet."*
|
||||
|
||||
### Persona 2 — Mehdi, chef de projet (carnet "Sprint Q2 2026")
|
||||
|
||||
Carnet structuré avec propriétés `Statut` (Kanban), `Priorité`, `Assigné`. Il prend ses notes de réunion et veut **insérer une vue en lecture de son Kanban** pour partager l'état du sprint dans la note de compte-rendu.
|
||||
|
||||
**Job-to-be-done :** *"Dans ma note de réunion, je veux une vue rapide de l'état des tâches du sprint."*
|
||||
|
||||
### Persona 3 — Yasmin, étudiante (carnet "Lectures Persan")
|
||||
|
||||
Carnet avec propriétés `Lu` (checkbox), `Note` (number). Elle lit en persan (RTL). Elle veut **voir sa liste de lectures sous forme de tableau** inline dans ses notes de révision.
|
||||
|
||||
**Job-to-be-done :** *"Dans mes notes, je veux voir mon tableau de lectures avec les bonnes valeurs — en persan, dans le bon sens."*
|
||||
|
||||
---
|
||||
|
||||
## 3. Options Produit pour `/database` — Analyse & Recommandation
|
||||
|
||||
### Option A — Embed Structured View (Recommandée ✅)
|
||||
|
||||
**Description :** Le bloc inline affiche une vue filtrée (Table ou Galerie) du schéma du carnet courant. Données lues depuis l'API existante. Le bloc stocke seulement `notebookId`, `displayMode`, `filterJson` dans ses attributs TipTap.
|
||||
|
||||
**Avantages :**
|
||||
- Réutilise `NotesStructuredTable` et `NotesGalleryView` existants (quasi plug-and-play).
|
||||
- Données cohérentes avec `/home` (même source de vérité).
|
||||
- Léger : 3 attributs string dans le nœud TipTap, pas de payload.
|
||||
- Facile à migrer : si le schéma du carnet change, le bloc se met à jour automatiquement.
|
||||
- Clair pour l'utilisateur : "c'est mon carnet, mes données."
|
||||
|
||||
**Risques :**
|
||||
- Éditeur doit recevoir `notebookId` (actuellement absent — patch mineur dans `note-content-area.tsx`).
|
||||
- Si le carnet n'a pas de schéma, le bloc doit le gérer gracieusement.
|
||||
|
||||
**Verdict :** Option principale retenue.
|
||||
|
||||
### Option B — Linked Database Block (style Notion)
|
||||
|
||||
**Description :** L'utilisateur choisit n'importe quel carnet structuré (pas forcément le carnet de la note courante), avec filtre et vue configurables dans le bloc.
|
||||
|
||||
**Avantages :** Plus puissant — permet de croiser des carnets.
|
||||
|
||||
**Risques :**
|
||||
- UI de sélection de carnet complexe dans le NodeView.
|
||||
- Scope plus large que ce que justifie une US-4 isolée.
|
||||
- Cross-notebook = scope Living Blocks étendu → reporter à v2.
|
||||
|
||||
**Verdict :** Déféré à v2 après que l'Option A soit stabilisée.
|
||||
|
||||
### Option C — Mini-table locale (colonnes libres dans la note)
|
||||
|
||||
**Description :** Table simple dans la note, indépendante des carnets, avec colonnes définissables par l'utilisateur.
|
||||
|
||||
**Avantages :** Pas de dépendance à Structured Views.
|
||||
|
||||
**Risques :**
|
||||
- Crée exactement le second système de données parallèle qu'on veut éviter.
|
||||
- Ne répond pas aux personas (Chloé et Mehdi veulent leurs vraies données).
|
||||
- Rejectée explicitement par le fondateur.
|
||||
|
||||
**Verdict :** Hors scope. Ne pas implémenter.
|
||||
|
||||
### Option D — Supprimer `/database`
|
||||
|
||||
**Description :** Retirer complètement la commande slash et l'option du menu.
|
||||
|
||||
**Avantages :** Simple, propre.
|
||||
|
||||
**Risques :**
|
||||
- Perd une surface UX qui a du sens (une fois bien connectée).
|
||||
- L'annonce "database inline" est une attente utilisateur légitime.
|
||||
|
||||
**Verdict :** Fallback si l'Option A prend trop de temps — mais l'Option A est faisable en taille M.
|
||||
|
||||
---
|
||||
|
||||
## 4. User Story & Critères d'Acceptation (Given/When/Then)
|
||||
|
||||
### US-4 (Nouvelle) : Vue Structurée de Carnet Inline dans l'Éditeur
|
||||
|
||||
**En tant que** rédacteur dans un carnet structuré,
|
||||
**Je veux** insérer une vue en lecture de mon tableau de notes directement dans le corps de ma note,
|
||||
**Afin de** voir mes données structurées en contexte, sans quitter l'éditeur ni naviguer vers le carnet.
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 1 — Insertion dans un carnet structuré
|
||||
|
||||
**Given** que ma note est dans un carnet qui a un schéma (`NotebookSchema` défini)
|
||||
**When** je tape `/database` dans l'éditeur
|
||||
**Then** un bloc `structuredViewBlock` est inséré, affichant la vue Table du carnet (colonnes = propriétés du schéma, lignes = notes du carnet)
|
||||
**And** aucune donnée de démo n'est insérée
|
||||
**And** l'utilisateur peut modifier les valeurs simples directement dans le bloc (checkbox, select, texte) — les changements se sauvegardent via `PATCH /api/notes/:id/properties`
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 2 — Carnet sans schéma
|
||||
|
||||
**Given** que ma note est dans un carnet sans `NotebookSchema`
|
||||
**When** j'insère un bloc `/database`
|
||||
**Then** le bloc affiche un message contextuel : *"Ce carnet n'a pas encore de vue structurée. Configurez-en une depuis l'en-tête du carnet."* avec un lien vers le wizard
|
||||
**And** aucune donnée n'est chargée, aucun crash
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 3 — Note sans carnet
|
||||
|
||||
**Given** que ma note n'appartient à aucun carnet (notebookId absent)
|
||||
**When** j'insère un bloc `/database`
|
||||
**Then** le bloc affiche : *"Ce bloc nécessite un carnet. Déplacez cette note dans un carnet pour l'utiliser."*
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 4 — Migration des blocs legacy
|
||||
|
||||
**Given** qu'une note contient un ancien nœud `data-database-block="true"` (auteurs/œuvres)
|
||||
**When** la note est ouverte
|
||||
**Then** le bloc obsolète est silencieusement retiré (ou affiché comme placeholder "bloc obsolète")
|
||||
**And** le reste du contenu de la note est intact
|
||||
**And** aucun crash ne se produit
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 5 — RTL / locale persane
|
||||
|
||||
**Given** que la langue de l'app est `fa` (persan, RTL)
|
||||
**When** le bloc est affiché
|
||||
**Then** le wrapper du bloc a `dir="auto"`, le tableau s'aligne correctement en RTL
|
||||
**And** les libellés du bloc sont traduits en persan (via `useLanguage()`)
|
||||
|
||||
---
|
||||
|
||||
#### Scénario 6 — Bascule de vue (Table → Galerie)
|
||||
|
||||
**Given** que le bloc est inséré dans un carnet avec `illustrationSvg` ou couleur
|
||||
**When** l'utilisateur clique sur le sélecteur de vue dans le bloc
|
||||
**Then** il peut basculer entre `Table` et `Galerie`
|
||||
**And** l'attribut `displayMode` du nœud TipTap est mis à jour
|
||||
**And** la vue se met à jour sans re-insertion du bloc
|
||||
|
||||
---
|
||||
|
||||
## 5. Out of Scope Explicite
|
||||
|
||||
| Fonctionnalité | Raison |
|
||||
|----------------|--------|
|
||||
| Vue Kanban inline | Drag-and-drop dans un NodeView TipTap = mauvaise UX, reporté en v2 |
|
||||
| Sélection d'un carnet différent de la note courante | Scope trop large → v2 |
|
||||
| Formules / rollups calculés | Hors scope Structured Views v1 |
|
||||
| Relations multi-carnets | Hors scope — nécessite migration Prisma dédiée |
|
||||
| Filtre personnalisé via UI dans le bloc | v2 — le bloc affiche tout le carnet en v1 |
|
||||
| Pagination dans le bloc | v2 si le carnet dépasse 50 notes |
|
||||
| Export PDF/CSV depuis le bloc | Fonctionnalité séparée |
|
||||
|
||||
---
|
||||
|
||||
## 6. Composants Prototype à Référencer
|
||||
|
||||
| Composant prototype | Usage | Note |
|
||||
|--------------------|-------|------|
|
||||
| `architectural-grid/` — `NotesStructuredTable` pattern | Colonnes et cellules — **référence design** | Utiliser le composant prod `notes-structured-table.tsx` |
|
||||
| `architectural-grid/` — `NotesGalleryView` pattern | Vue galerie — **référence design** | Utiliser le composant prod `notes-gallery-view.tsx` |
|
||||
| `architectural-grid1/ModernBlockNoteEditor.tsx` L1704+ | **NE PAS COPIER** — démo marketing Verne/Liu Cixin | Audit uniquement |
|
||||
|
||||
> ⚠️ Le prototype `architectural-grid1` est la référence de ce qu'il **ne faut pas** shipper. Le prototype `architectural-grid` (sans `1`) est la référence de design courant.
|
||||
|
||||
---
|
||||
|
||||
## 7. Modèle de Données
|
||||
|
||||
### 7.1 Nœud TipTap — Attributs (référence légère uniquement)
|
||||
|
||||
```typescript
|
||||
// Extension TipTap — tiptap-structured-view-block-extension.tsx
|
||||
Node.create({
|
||||
name: 'structuredViewBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: 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 }),
|
||||
},
|
||||
filterJson: {
|
||||
default: '{}',
|
||||
parseHTML: (el) => el.getAttribute('data-sv-filter') || '{}',
|
||||
renderHTML: (attrs) => ({ 'data-sv-filter': attrs.filterJson }),
|
||||
},
|
||||
}
|
||||
},
|
||||
parseHTML: () => [{ tag: 'div[data-structured-view-block]' }],
|
||||
renderHTML: ({ HTMLAttributes }) =>
|
||||
['div', mergeAttributes(HTMLAttributes, { 'data-structured-view-block': 'true' })],
|
||||
addNodeView: () => ReactNodeViewRenderer(StructuredViewBlockEmbed),
|
||||
})
|
||||
```
|
||||
|
||||
### 7.2 Aucune migration Prisma requise
|
||||
|
||||
Le bloc réutilise le schéma Prisma existant :
|
||||
- `NotebookSchema` / `NotebookProperty` → définition des colonnes
|
||||
- `NoteProperty` → valeurs par note
|
||||
- `Note` → lignes du tableau
|
||||
|
||||
Aucune nouvelle table. Aucun risque BDD.
|
||||
|
||||
### 7.3 Diagramme de relations
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["structuredViewBlock node<br/>(TipTap attrs)"] -->|"notebookId"| B["GET /api/notebooks/:id/schema"]
|
||||
B --> C["NotebookSchema<br/>(Prisma)"]
|
||||
C --> D["NotebookProperty[]<br/>(colonnes)"]
|
||||
A -->|"notebookId"| E["GET /api/notebooks/:id/notes<br/>(avec properties)"]
|
||||
E --> F["Note[]<br/>(lignes)"]
|
||||
F --> G["NoteProperty[]<br/>(valeurs)"]
|
||||
D & G --> H["NotesStructuredTable<br/>(composant prod réutilisé)"]
|
||||
H --> I["Rendu inline dans l'éditeur"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Plan de Migration & Rollback du Code Actuel
|
||||
|
||||
### 8.1 Ordre de suppression (pour éviter les imports cassés)
|
||||
|
||||
1. **Supprimer** `memento-note/lib/editor/database-block-types.ts`
|
||||
2. **Supprimer** `memento-note/components/database-block-editor.tsx`
|
||||
3. **Supprimer** `memento-note/components/tiptap-database-block-extension.tsx`
|
||||
4. **Modifier** `block-action-menu.tsx` — retirer import + option `database`
|
||||
5. **Modifier** `rich-text-editor.tsx` — retirer import + slash entry + extension registration
|
||||
6. **Créer** `tiptap-structured-view-block-extension.tsx` + `structured-view-block-embed.tsx`
|
||||
7. **Modifier** `note-content-area.tsx` — passer `notebookId`
|
||||
8. **Modifier** `locales/en.json` + `fr.json` — swap clés i18n
|
||||
|
||||
### 8.2 Gestion des notes existantes avec l'ancien bloc
|
||||
|
||||
Le nœud `databaseBlock` sera inconnu de TipTap après suppression de l'extension. Par défaut ProseMirror le drop silencieusement. Options :
|
||||
|
||||
- **Option simple (v1) :** Laisser ProseMirror supprimer le nœud inconnu au premier rendu — aucune action développeur.
|
||||
- **Option propre (recommandée) :** Ajouter dans la nouvelle extension une règle `parseHTML` qui matche `div[data-database-block]` et le convertit en paragraphe `⚠️ Bloc base de données obsolète — ce contenu a été retiré.`
|
||||
|
||||
### 8.3 Rollback possible
|
||||
|
||||
Si le fondateur rejette l'Option A après implémentation :
|
||||
- Les 3 fichiers supprimés sont dans git history — `git checkout HEAD~1 -- <fichier>` les restaure.
|
||||
- La suppression de l'extension de `rich-text-editor.tsx` est une ligne — réversible en 2 minutes.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risques Performance & Mitigations
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
|--------|-------------|--------|-----------|
|
||||
| NodeView React lourd — re-render à chaque transaction TipTap | Haute | Haute | `shouldRerenderOnTransaction: false` sur l'éditeur (US-EDITOR-PERF) + `React.memo` sur le bloc embed + `trackNodeViewPosition: false` |
|
||||
| SWR fetch bloque le rendu initial de la note | Moyenne | Moyenne | Suspense avec skeleton loader ; ne pas bloquer le rendu éditeur ; lazy-mount après 500ms |
|
||||
| Note dans carnet avec 200+ notes — tableau trop long | Basse | Moyenne | Afficher les 20 premières notes avec `"Voir toutes les N notes dans le carnet"` CTA |
|
||||
| RTL — cellules du tableau cassées | Basse | Haute | `dir="auto"` sur le wrapper ; tester avec locale `fa` avant merge |
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n — Clés à Ajouter / Supprimer
|
||||
|
||||
### 10.1 Clés à ajouter (EN/FR minimum)
|
||||
|
||||
| Clé | EN | FR |
|
||||
|-----|----|----|
|
||||
| `structuredViewBlock.insertLabel` | Structured View | Vue structurée du carnet |
|
||||
| `structuredViewBlock.insertDesc` | Embed your notebook's structured data | Intégrez les données structurées de votre carnet |
|
||||
| `structuredViewBlock.noSchema` | This notebook has no structured view yet. Set one up from the notebook header. | Ce carnet n'a pas encore de vue structurée. Configurez-en une depuis l'en-tête du carnet. |
|
||||
| `structuredViewBlock.noNotebook` | This block requires a notebook. Move this note to a notebook first. | Ce bloc nécessite un carnet. Déplacez cette note dans un carnet pour l'utiliser. |
|
||||
| `structuredViewBlock.openInNotebook` | Open in notebook | Ouvrir dans le carnet |
|
||||
| `structuredViewBlock.displayModeTable` | Table | Tableau |
|
||||
| `structuredViewBlock.displayModeGallery` | Gallery | Galerie |
|
||||
| `structuredViewBlock.loadError` | Failed to load structured data. | Impossible de charger les données structurées. |
|
||||
| `structuredViewBlock.retry` | Retry | Réessayer |
|
||||
| `structuredViewBlock.deprecatedBlock` | Outdated block removed. | Bloc obsolète retiré. |
|
||||
|
||||
> **Note :** Si l'utilisateur demande à compléter les 15 locales, ne remplir que les clés — laisser la traduction des 13 autres locales à l'outil de traduction externe (règle AGENTS.md).
|
||||
|
||||
### 10.2 Clés à supprimer (après vérification pas d'autre consommateur)
|
||||
|
||||
`databaseBlock.title`, `databaseBlock.viewTable`, `databaseBlock.viewCards`, `databaseBlock.hint`, `databaseBlock.colAuthor`, `databaseBlock.colWorks`, `databaseBlock.colRollup`, `databaseBlock.noLinkedWorks`, `databaseBlock.deleteShort`, `databaseBlock.addAuthor`, `databaseBlock.authorPlaceholder`, `databaseBlock.createAuthor`, `databaseBlock.worksBase`, `databaseBlock.storedCount`, `databaseBlock.addWork`, `databaseBlock.bookTitlePlaceholder`, `databaseBlock.selectAuthor`, `databaseBlock.tagPlaceholder`, `databaseBlock.coverPlaceholder`, `databaseBlock.insertWork`, `databaseBlock.deleteCard`, `databaseBlock.insertFailed`, `databaseBlock.defaultTag`
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist QA Manuelle
|
||||
|
||||
### 11.1 Français (LTR)
|
||||
|
||||
- [ ] Taper `/database` dans une note d'un carnet structuré → bloc s'insère avec les données réelles
|
||||
- [ ] Taper `/database` → même résultat (rétrocompatibilité keyword)
|
||||
- [ ] Taper `/database` dans une note sans carnet → message "nécessite un carnet"
|
||||
- [ ] Taper `/database` dans une note d'un carnet sans schéma → callout wizard avec lien
|
||||
- [ ] Ouvrir une note avec un ancien bloc `databaseBlock` → note s'ouvre, bloc absent ou placeholder, pas de crash
|
||||
- [ ] Menu "Transformer en" → option "Vue structurée" présente, option "Base de données" (ancienne) absente
|
||||
- [ ] Bascule Table ↔ Galerie dans le bloc → vue change, pas de réinsertion du bloc
|
||||
- [ ] `npm run build` → 0 erreur, aucun import vers fichiers supprimés
|
||||
|
||||
### 11.2 Persan / RTL (fa)
|
||||
|
||||
- [ ] Changer la langue en `fa` → libellés du bloc en persan
|
||||
- [ ] Le bloc wrapper est `dir="auto"` → tableau aligné à droite
|
||||
- [ ] Cellules du tableau RTL lisibles (pas de texte coupé)
|
||||
- [ ] Callout "no schema" en persan → texte lisible RTL
|
||||
|
||||
---
|
||||
|
||||
## 12. Estimation Effort
|
||||
|
||||
| Tâche | Taille |
|
||||
|-------|--------|
|
||||
| Supprimer 3 fichiers legacy | XS |
|
||||
| Patcher `rich-text-editor.tsx` + `block-action-menu.tsx` | S |
|
||||
| Patcher `note-content-area.tsx` (`notebookId` prop) | XS |
|
||||
| Créer `tiptap-structured-view-block-extension.tsx` | S |
|
||||
| Créer `structured-view-block-embed.tsx` (SWR + états + RTL) | M |
|
||||
| i18n EN + FR | XS |
|
||||
| QA manuelle FR + fa | S |
|
||||
| **Total estimé** | **M** (~1–2 jours dev) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Décisions Produit Actées
|
||||
|
||||
### Édition inline — ACTIVÉE en v1
|
||||
L'utilisateur peut modifier les valeurs simples directement dans le bloc (checkbox, select, texte court). Les changements se sauvegardent via `PATCH /api/notes/:id/properties` avec mise à jour optimiste. C'est la meilleure expérience utilisateur — un bloc en lecture seule n'aurait aucun intérêt.
|
||||
|
||||
### Kanban inline — REPORTÉ en v2
|
||||
Table + Galerie uniquement en v1. Le drag-and-drop dans un NodeView TipTap crée de mauvaises interactions. Le Kanban s'utilisera depuis le carnet comme aujourd'hui.
|
||||
|
||||
---
|
||||
|
||||
## Annexe — Diff Proposé pour `docs/story-nextgen-editor.md`
|
||||
|
||||
Remplacer la section `### US-4: Bloc de Base de Données Relationnelle Inline` (lignes 71–85) par :
|
||||
|
||||
```markdown
|
||||
### US-4: Vue Structurée de Carnet Inline *(Redesign — voir `docs/story-nextgen-editor-us4-redesign.md`)*
|
||||
|
||||
> ⚠️ **DEPRECATED** — La spécification précédente (bloc "Auteurs & Œuvres") est rejetée.
|
||||
> Voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) pour la nouvelle spec.
|
||||
|
||||
**En tant que** rédacteur dans un carnet structuré,
|
||||
**Je veux** insérer une vue en lecture de mon tableau de notes directement dans le corps de ma note,
|
||||
**Afin de** voir mes données structurées en contexte, sans quitter l'éditeur.
|
||||
|
||||
*(Critères d'acceptation détaillés dans le fichier redesign ci-dessus.)*
|
||||
```
|
||||
|
||||
Et remplacer la section `### 1. Structure du Nœud de Base de Données Tiptap` + `### 2.` + `### 3.` dans les Spécifications Techniques (lignes 90–109) par :
|
||||
|
||||
```markdown
|
||||
### 1. Structure du Nœud `structuredViewBlock` (TipTap)
|
||||
Extension TipTap `StructuredViewBlockExtension` dans `tiptap-structured-view-block-extension.tsx` :
|
||||
- Attributs : `notebookId` (string), `displayMode` ('table'|'gallery'), `filterJson` (string JSON)
|
||||
- ReactNodeViewRenderer → `structured-view-block-embed.tsx`
|
||||
|
||||
### 2. Suppression du code legacy
|
||||
- Supprimer : `tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `lib/editor/database-block-types.ts`
|
||||
- Modifier : `rich-text-editor.tsx`, `block-action-menu.tsx`, `locales/*.json`
|
||||
- Voir plan complet dans `docs/story-nextgen-editor-us4-redesign.md` §8.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Annexe — Diff Proposé pour `docs/user-stories.md`
|
||||
|
||||
### Dans le tableau de bord (ligne 23)
|
||||
|
||||
```diff
|
||||
-| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + DB Inline + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` |
|
||||
+| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + Vue Structurée Inline + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` + `docs/story-nextgen-editor-us4-redesign.md` |
|
||||
```
|
||||
|
||||
### Dans la section US-NEXTGEN-EDITOR (US-4, ligne 622–626)
|
||||
|
||||
```diff
|
||||
-### US-4 : Bloc de Base de Données Relationnelle Inline
|
||||
-- Slash `/database` → insère un React NodeView
|
||||
-- Vues Tableau / Fiches avec Rollup dynamique
|
||||
-- Modèle relationnel local (auteurs/livres par défaut, ou lié au carnet)
|
||||
+### US-4 : Vue Structurée de Carnet Inline *(Redesign)*
|
||||
+> Spec complète : [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md)
|
||||
+- Slash `/database` (+ keywords `db`, `tableau`, `structured`) → insère un `structuredViewBlock`
|
||||
+- Affiche Table ou Galerie du carnet courant via l'API Structured Views existante
|
||||
+- Bloc stocke uniquement `notebookId` + `displayMode` (pas de données en attrs)
|
||||
+- Graceful fallback si carnet sans schéma ou note sans carnet
|
||||
+- Supprime le code legacy `tiptap-database-block-extension.tsx` (Verne/Liu Cixin)
|
||||
+- **Dépend de :** US-STRUCTURED-VIEWS ✅ (livré)
|
||||
```
|
||||
|
||||
### Dans la liste de fichiers (ligne 627–631)
|
||||
|
||||
```diff
|
||||
-### Fichiers
|
||||
-- `[NEW]` `tiptap-drag-handle-plugin.ts` — Plugin ProseMirror pur
|
||||
-- `[NEW]` `tiptap-database-block-extension.tsx` — NodeView React
|
||||
-- `[MODIFY]` `rich-text-editor.tsx` — Intégration drag handle + DB + paste intercept
|
||||
-- `[MODIFY]` `globals.css` — Gutter, poignée, glassmorphic dropdowns
|
||||
+### Fichiers
|
||||
+- `[NEW]` `tiptap-drag-handle-plugin.ts` — Plugin ProseMirror pur (US-1, inchangé)
|
||||
+- `[NEW]` `tiptap-structured-view-block-extension.tsx` — NodeView Vue Structurée (remplace DB)
|
||||
+- `[NEW]` `structured-view-block-embed.tsx` — Composant embed avec SWR + states
|
||||
+- `[DELETE]` `tiptap-database-block-extension.tsx` — Bloc legacy auteurs/œuvres rejeté
|
||||
+- `[DELETE]` `database-block-editor.tsx` — UI legacy rejetée
|
||||
+- `[DELETE]` `lib/editor/database-block-types.ts` — Types legacy rejetés
|
||||
+- `[MODIFY]` `rich-text-editor.tsx` — Intégration drag handle + Vue Structurée + paste intercept
|
||||
+- `[MODIFY]` `note-content-area.tsx` — Passer `notebookId` à l'éditeur
|
||||
+- `[MODIFY]` `block-action-menu.tsx` — Remplacer option "Database" par "Vue structurée"
|
||||
+- `[MODIFY]` `globals.css` — Gutter, poignée, glassmorphic dropdowns
|
||||
```
|
||||
@@ -68,44 +68,54 @@ Pour offrir une expérience de saisie supérieure à celle de Notion (plus perfo
|
||||
|
||||
---
|
||||
|
||||
### US-4: Bloc de Base de Données Relationnelle Inline
|
||||
**En tant que** chef de projet ou gestionnaire de données,
|
||||
**Je veux** pouvoir insérer une base de données relationnelle interactive directement au milieu de mon texte, basculer ses vues et suivre des rollups,
|
||||
**Afin de** structurer mes informations complexes sans quitter le flux de ma note (comme simulé dans le prototype).
|
||||
### US-4: Vue Structurée de Carnet Inline *(Redesign — voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md))*
|
||||
|
||||
#### Critères d'Acceptation :
|
||||
* **Étant donné** que j'utilise l'éditeur de Momento
|
||||
* **Quand** je saisis `/database` ou sélectionne "Base de données" dans le menu d'insertion
|
||||
* **Alors** un bloc `databaseBlock` est inséré sous forme d'un React NodeView
|
||||
* **Et** il propose par défaut un modèle relationnel "Auteurs & Œuvres" (avec des données simulées ou liées au carnet actuel)
|
||||
* **Quand** je clique sur le sélecteur de vue en haut à droite du bloc
|
||||
* **Alors** je peux basculer entre la vue **Tableau** (grille avec colonnes de propriétés) et la vue **Fiches** (cartes illustrées de couvertures, tags et auteurs)
|
||||
* **Et** la colonne **Rollup Count** de la vue Tableau recalcule dynamiquement le nombre de livres liés à chaque auteur au fur et à mesure que j'ajoute ou supprime des fiches
|
||||
* **Et** un formulaire d'insertion rapide en bas du bloc me permet d'ajouter des livres ou des auteurs à ce modèle relationnel local.
|
||||
> ⚠️ **DEPRECATED** — La spec précédente (bloc "Auteurs & Œuvres") est **rejetée par le fondateur**.
|
||||
> Le code legacy (`tiptap-database-block-extension.tsx`, `database-block-editor.tsx`, `database-block-types.ts`) doit être supprimé.
|
||||
> La nouvelle spec complète (problème, options produit, Given/When/Then, modèle de données, migration, i18n, QA RTL) est dans [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md).
|
||||
|
||||
**En tant que** rédacteur dans un carnet structuré,
|
||||
**Je veux** insérer une vue en lecture de mon tableau de notes directement dans le corps de ma note,
|
||||
**Afin de** voir mes données structurées en contexte, sans quitter l'éditeur.
|
||||
|
||||
#### Critères d'Acceptation (résumé) :
|
||||
* **Étant donné** que ma note est dans un carnet structuré, **quand** je tape `/vue`, **alors** un bloc `structuredViewBlock` s'insère avec les données réelles du carnet — aucune donnée de démo.
|
||||
* **Étant donné** que le carnet n'a pas de schéma, **alors** le bloc affiche un callout contextuel vers le wizard, pas une erreur.
|
||||
* **Étant donné** qu'une note contient un ancien `databaseBlock`, **alors** il est retiré silencieusement sans crash.
|
||||
|
||||
*(Voir `docs/story-nextgen-editor-us4-redesign.md` pour les critères complets, le modèle de données, et la checklist QA FR+fa RTL.)*
|
||||
|
||||
---
|
||||
|
||||
## Spécifications Techniques d'Implémentation
|
||||
|
||||
### 1. Structure du Nœud de Base de Données Tiptap (`DatabaseBlock`)
|
||||
Créer une extension Tiptap personnalisée `DatabaseBlockExtension` dans `/components/tiptap-database-block-extension.tsx` qui :
|
||||
- Gère un groupe `block`, se comporte comme un atome (non modifiable directement en texte brut).
|
||||
- Conserve les attributs : `dbId` (string), `dbView` ('table' | 'card'), `dbAuthors` (array) et `dbBooks` (array) représentant le schéma et les valeurs.
|
||||
- Utilise un `ReactNodeViewRenderer` pour monter notre composant d'édition de base de données relationnelle en React pur.
|
||||
### 1. Structure du Nœud `structuredViewBlock` (TipTap) — *US-4 Redesign*
|
||||
|
||||
### 2. Le Plugin Drag Handle & Gutter en ProseMirror
|
||||
> ⚠️ **DEPRECATED :** L'ancienne section `DatabaseBlock` est remplacée. Voir [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md) §7 pour le modèle complet.
|
||||
|
||||
Extension TipTap `StructuredViewBlockExtension` dans `tiptap-structured-view-block-extension.tsx` :
|
||||
- Attributs : `notebookId` (string), `displayMode` ('table'|'gallery'), `filterJson` (string JSON `{}`)
|
||||
- ReactNodeViewRenderer → `structured-view-block-embed.tsx`
|
||||
- Aucune donnée de démo — aucun payload JSON d'auteurs/livres dans les attrs.
|
||||
|
||||
### 2. Le Plugin Drag Handle & Gutter en ProseMirror *(US-1, inchangé)*
|
||||
Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
|
||||
* Attacher un écouteur d'événements `mouseover` et `mousemove` sur l'éditeur ProseMirror.
|
||||
* Utiliser `view.posAtCoords` pour localiser le nœud ProseMirror survolé.
|
||||
* Récupérer son DOM parent et calculer sa hauteur et son décalage vertical.
|
||||
* Repositionner l'élément HTML de la poignée en modifiant ses styles CSS `top` et `left` (le bouton est monté au niveau de l'éditeur parent en absolute, évitant d'être injecté dans le texte éditable).
|
||||
* Gérer le glissement avec l'API HTML5 Drag and Drop couplée aux transactions de ProseMirror (`tr.replaceWith` ou `MoverBlock`).
|
||||
* Attacher un écouteur `mouseover` et `mousemove` sur l'éditeur ProseMirror.
|
||||
* Utiliser `view.posAtCoords` pour localiser le nœud survolé.
|
||||
* Repositionner l'élément HTML de la poignée en modifiant ses styles CSS `top` et `left`.
|
||||
* Gérer le glissement avec `@tiptap/extension-drag-handle-react` (spec officielle).
|
||||
|
||||
### 3. Fichiers à modifier / créer :
|
||||
* `[NEW]` `memento-note/components/tiptap-drag-handle-plugin.ts` — Plugin de poignée Gutter
|
||||
* `[NEW]` `memento-note/components/tiptap-database-block-extension.tsx` — Nœud et composant DatabaseBlockEditor
|
||||
* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — Intégration du Drag Handle, de la DatabaseBlockExtension et de l'interception du presse-papier dans `editorProps.handlePaste`
|
||||
* `[MODIFY]` `memento-note/app/globals.css` — Ajout des classes CSS pour l'alignement précis du gutter, de la poignée, et du bloc de base de données (glassmorphic dropdowns et fiches).
|
||||
### 3. Fichiers à modifier / créer / supprimer :
|
||||
* `[NEW]` `memento-note/components/tiptap-drag-handle-plugin.ts` — Plugin Gutter (US-1)
|
||||
* `[NEW]` `memento-note/components/tiptap-structured-view-block-extension.tsx` — Nœud Vue Structurée (US-4 redesign)
|
||||
* `[NEW]` `memento-note/components/structured-view-block-embed.tsx` — React NodeView avec SWR + états dégradés
|
||||
* `[DELETE]` `memento-note/components/tiptap-database-block-extension.tsx` — Legacy rejeté
|
||||
* `[DELETE]` `memento-note/components/database-block-editor.tsx` — Legacy rejeté
|
||||
* `[DELETE]` `memento-note/lib/editor/database-block-types.ts` — Legacy rejeté
|
||||
* `[MODIFY]` `memento-note/components/note-content-area.tsx` — Passer `notebookId` à l'éditeur
|
||||
* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — Swap extension DB → Vue Structurée, mise à jour slash
|
||||
* `[MODIFY]` `memento-note/components/block-action-menu.tsx` — Remplacer option "Database" par "Vue structurée"
|
||||
* `[MODIFY]` `memento-note/app/globals.css` — Gutter, poignée, glassmorphic dropdowns
|
||||
|
||||
---
|
||||
|
||||
@@ -115,4 +125,9 @@ Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
|
||||
1. **Vérification Gutter :** Survoler des textes longs et vérifier que la poignée se positionne correctement à gauche. Glisser un paragraphe sur un autre et valider le réordonnancement.
|
||||
2. **Vérification Menu :** Cliquer sur la poignée, dupliquer le bloc, supprimer le bloc, et le transformer en d'autres types.
|
||||
3. **Vérification Paste :** Copier une référence de bloc, la coller, et vérifier que la transclusion est proposée et s'insère sous forme de `LiveBlock`.
|
||||
4. **Vérification Base de Données Inline :** Insérer le bloc base de données, ajouter un auteur, ajouter un livre avec cet auteur, vérifier le bon calcul du rollup, et basculer en vue Fiches pour vérifier l'affichage des couvertures.
|
||||
4. **Vérification Vue Structurée Inline (US-4 redesign) :**
|
||||
- Taper `/vue` dans une note de carnet structuré → bloc avec données réelles (pas Jules Verne).
|
||||
- Taper `/vue` dans un carnet sans schéma → callout wizard, pas de crash.
|
||||
- Ouvrir une note avec ancien `databaseBlock` → note charge sans erreur.
|
||||
- Passer la langue en `fa` (persan) → libellés traduits, layout RTL correct.
|
||||
- Voir checklist complète dans `docs/story-nextgen-editor-us4-redesign.md` §11.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏸️ **REPORTÉ** | Remplacé par rappels + révision SM-2 + Memory Echo ; heuristique faible, migration NoteAccessLog non prioritaire |
|
||||
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ✅ **LIVRÉ** | `/revision`, `/api/flashcards/*`, SM-2, génération IA depuis l'éditeur |
|
||||
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ✅ **LIVRÉ** | `/api/notebooks/[id]/schema`, `/api/notes/[id]/properties`, vues structurées + panneau propriétés éditeur |
|
||||
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + DB Inline + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` |
|
||||
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + **Vue Structurée Inline** (redesign US-4) + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` + `docs/story-nextgen-editor-us4-redesign.md` |
|
||||
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | 🚧 **PLANIFIÉ** | — |
|
||||
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ⏳ **À FAIRE** | — |
|
||||
| **US-EDITOR-MOBILE** | Expérience tactile & toolbar mobile adaptée | ⏳ **À FAIRE** | — |
|
||||
@@ -619,15 +619,25 @@ L'éditeur actuel est un document linéaire classique. Pour rivaliser avec Notio
|
||||
- Collage d'un lien de bloc -> menu inline : "Bloc Connecté (Live)" ou "Texte simple"
|
||||
- Insère un nœud `liveBlock` synchronisé via Redis Pub/Sub
|
||||
|
||||
### US-4 : Bloc de Base de Données Relationnelle Inline
|
||||
- Slash `/database` -> insère un React NodeView
|
||||
- Vues Tableau / Fiches avec Rollup dynamique
|
||||
- Modèle relationnel local (auteurs/livres par défaut, ou lié au carnet)
|
||||
### US-4 : Vue Structurée de Carnet Inline — *Redesign*
|
||||
> ⚠️ Spec complète : [`docs/story-nextgen-editor-us4-redesign.md`](./story-nextgen-editor-us4-redesign.md)
|
||||
- Slash `/vue` (+ keywords `database`, `db`, `tableau`) → insère un `structuredViewBlock`
|
||||
- Affiche Table ou Galerie du carnet courant via l'API Structured Views existante
|
||||
- Bloc stocke uniquement `notebookId` + `displayMode` (pas de données en attrs TipTap)
|
||||
- Graceful fallback si carnet sans schéma ou note sans carnet
|
||||
- **Supprime** le code legacy `tiptap-database-block-extension.tsx` (Verne/Liu Cixin)
|
||||
- **Dépend de :** US-STRUCTURED-VIEWS ✅ (livré)
|
||||
|
||||
### Fichiers
|
||||
- `[NEW]` `tiptap-drag-handle-plugin.ts` — Plugin ProseMirror pur
|
||||
- `[NEW]` `tiptap-database-block-extension.tsx` — NodeView React
|
||||
- `[MODIFY]` `rich-text-editor.tsx` — Intégration drag handle + DB + paste intercept
|
||||
- `[NEW]` `tiptap-drag-handle-plugin.ts` — Plugin ProseMirror pur (US-1)
|
||||
- `[NEW]` `tiptap-structured-view-block-extension.tsx` — NodeView Vue Structurée (US-4 redesign)
|
||||
- `[NEW]` `structured-view-block-embed.tsx` — Composant embed avec SWR + états dégradés
|
||||
- `[DELETE]` `tiptap-database-block-extension.tsx` — Legacy Auteurs/Œuvres rejeté
|
||||
- `[DELETE]` `database-block-editor.tsx` — UI legacy rejetée
|
||||
- `[DELETE]` `lib/editor/database-block-types.ts` — Types legacy rejetés
|
||||
- `[MODIFY]` `note-content-area.tsx` — Passer `notebookId` à l'éditeur
|
||||
- `[MODIFY]` `rich-text-editor.tsx` — Swap extension DB → Vue Structurée, mise à jour slash
|
||||
- `[MODIFY]` `block-action-menu.tsx` — Remplacer option "Database" par "Vue structurée"
|
||||
- `[MODIFY]` `globals.css` — Gutter, poignée, glassmorphic dropdowns
|
||||
|
||||
---
|
||||
|
||||
@@ -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,15 +298,32 @@ 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">
|
||||
<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-[220px] group-hover:text-brand-accent transition-colors"
|
||||
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>
|
||||
{schema.properties.map((p) => (
|
||||
<td
|
||||
@@ -199,10 +341,91 @@ export function NotesStructuredTable({
|
||||
</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 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()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
|
||||
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
|
||||
export const SEMANTIC_SIMILARITY_FLOOR = 0.60
|
||||
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
|
||||
/** Seuil assoupli pour notes clippées / RTL (persan, arabe) — bruit HTML réduit la similarité. */
|
||||
export const SEMANTIC_SIMILARITY_FLOOR_CLIP = 0.58
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2350,8 +2350,8 @@
|
||||
"slashDividerDesc": "Horizontal separator",
|
||||
"slashTable": "Table",
|
||||
"slashTableDesc": "Insert a simple grid",
|
||||
"slashDatabase": "Database",
|
||||
"slashDatabaseDesc": "Inline authors & works database",
|
||||
"slashDatabase": "Structured View",
|
||||
"slashDatabaseDesc": "Embed your notebook's structured data",
|
||||
"slashDiagram": "Diagram",
|
||||
"slashDiagramDesc": "Generate a flow or mindmap",
|
||||
"slashSlides": "Presentation",
|
||||
@@ -3362,29 +3362,31 @@
|
||||
"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."
|
||||
"structuredViewBlock": {
|
||||
"insertLabel": "Structured View",
|
||||
"insertDesc": "Embed your notebook's structured data",
|
||||
"noSchema": "This notebook has no structured view yet. Set one up from the notebook header.",
|
||||
"noNotebook": "This block requires a notebook. Move this note to a notebook first.",
|
||||
"openInNotebook": "Open in notebook",
|
||||
"displayModeTable": "Table",
|
||||
"displayModeGallery": "Gallery",
|
||||
"loadError": "Failed to load structured data.",
|
||||
"retry": "Retry",
|
||||
"deprecatedBlock": "Outdated block removed.",
|
||||
"selectNotebook": "Link to a notebook",
|
||||
"noNotebookDesc": "This block displays the structured view of a notebook. Choose the notebook to link:",
|
||||
"chooseNotebook": "Choose a notebook",
|
||||
"changeNotebook": "Change notebook",
|
||||
"change": "Change",
|
||||
"localDbTitle": "Standalone Database",
|
||||
"echoPopoverTitle": "Semantic Resonances 🔮",
|
||||
"noEchoFound": "No semantic connections detected.",
|
||||
"echoUpgradeText": "Convert this table to a notebook to activate Momento's neural analysis.",
|
||||
"echoLoading": "Searching for semantic connections...",
|
||||
"analyticsTitle": "Analytics & Insights",
|
||||
"analyticsNoData": "No analysis data available.",
|
||||
"analyticsCompletion": "Completion Rate",
|
||||
"analyticsDistribution": "Distribution",
|
||||
"analyticsTotalRows": "Total Rows"
|
||||
}
|
||||
}
|
||||
@@ -2354,8 +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",
|
||||
"slashDatabase": "Vue structurée",
|
||||
"slashDatabaseDesc": "Intégrer les données structurées de votre carnet",
|
||||
"slashDiagram": "Diagramme",
|
||||
"slashDiagramDesc": "Générer un flux ou une carte mentale",
|
||||
"slashSlides": "Présentation",
|
||||
@@ -3366,29 +3366,31 @@
|
||||
"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."
|
||||
"structuredViewBlock": {
|
||||
"insertLabel": "Vue structurée",
|
||||
"insertDesc": "Intégrez les données structurées de votre carnet",
|
||||
"noSchema": "Ce carnet n'a pas encore de vue structurée. Configurez-en une depuis l'en-tête du carnet.",
|
||||
"noNotebook": "Ce bloc nécessite un carnet. Déplacez cette note dans un carnet pour l'utiliser.",
|
||||
"openInNotebook": "Ouvrir dans le carnet",
|
||||
"displayModeTable": "Tableau",
|
||||
"displayModeGallery": "Galerie",
|
||||
"loadError": "Impossible de charger les données structurées.",
|
||||
"retry": "Réessayer",
|
||||
"deprecatedBlock": "Bloc obsolète retiré.",
|
||||
"selectNotebook": "Lier à un carnet",
|
||||
"noNotebookDesc": "Ce bloc affiche la vue structurée d'un carnet. Choisissez le carnet à lier :",
|
||||
"chooseNotebook": "Choisir un carnet",
|
||||
"changeNotebook": "Changer de carnet",
|
||||
"change": "Changer",
|
||||
"localDbTitle": "Base de Données Autonome",
|
||||
"echoPopoverTitle": "Résonance Sémantique 🔮",
|
||||
"noEchoFound": "Aucune résonance sémantique détectée.",
|
||||
"echoUpgradeText": "Convertissez ce tableau en carnet pour activer l'analyse neuronale de Momento.",
|
||||
"echoLoading": "Recherche de connexions sémantiques...",
|
||||
"analyticsTitle": "Analyses & Insights",
|
||||
"analyticsNoData": "Aucune donnée d'analyse disponible.",
|
||||
"analyticsCompletion": "Taux de complétion",
|
||||
"analyticsDistribution": "Répartition",
|
||||
"analyticsTotalRows": "Total des lignes"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user