feat: colonnes multi-colonnes (layout côte à côte style Notion)
- Architecture nested nodes: columns container → column children - CSS seamless (pas de bordures, fin séparateur vertical entre colonnes) - isolating: true sur les deux nœuds (curseur reste dans sa colonne) - Commands addColumnBefore/addColumnAfter/deleteColumn - Slash menu + drag handle + raccourci Mod+Shift+L - i18n FR/EN complet
This commit is contained in:
@@ -3000,4 +3000,37 @@ html.font-system * {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.3);
|
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.3);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-column layout — seamless like Notion, no boxes */
|
||||||
|
.tiptap-columns {
|
||||||
|
display: flex !important;
|
||||||
|
gap: 24px !important;
|
||||||
|
margin: 8px 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-column {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: none !important;
|
||||||
|
border-left: 1px solid color-mix(in oklab, var(--foreground) 8%, transparent) !important;
|
||||||
|
padding-left: 24px !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-column:first-child {
|
||||||
|
border-left: none !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-column > *:first-child { margin-top: 0; }
|
||||||
|
.tiptap-column > *:last-child { margin-bottom: 0; }
|
||||||
|
.tiptap-column p { margin: 4px 0; }
|
||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
Heading1, Heading2, Heading3, List, ListOrdered,
|
Heading1, Heading2, Heading3, List, ListOrdered,
|
||||||
CheckSquare, Quote, CodeXml, Database,
|
CheckSquare, Quote, CodeXml, Database,
|
||||||
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
|
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
|
||||||
ChevronsRightLeft, MessageSquareWarning, ListTree,
|
ChevronsRightLeft, MessageSquareWarning, ListTree, Columns3,
|
||||||
|
Plus, Minus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
|
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
|
||||||
import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension'
|
import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension'
|
||||||
import { turnIntoCalloutBlock } from '@/components/tiptap-callout-extension'
|
import { turnIntoCalloutBlock } from '@/components/tiptap-callout-extension'
|
||||||
import { insertOutlineBlock } from '@/components/tiptap-outline-extension'
|
import { insertOutlineBlock } from '@/components/tiptap-outline-extension'
|
||||||
|
import { insertColumnsBlock } from '@/components/tiptap-columns-extension'
|
||||||
|
|
||||||
interface BlockActionMenuProps {
|
interface BlockActionMenuProps {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
@@ -391,6 +393,12 @@ export function BlockActionMenu({
|
|||||||
<span>{t('richTextEditor.slashOutline')}</span>
|
<span>{t('richTextEditor.slashOutline')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Colonnes */}
|
||||||
|
<button type="button" className="block-action-item" onClick={() => { insertColumnsBlock(editor, 2); onClose() }}>
|
||||||
|
<Columns3 size={16} />
|
||||||
|
<span>{t('richTextEditor.slashColumns')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="block-action-separator" />
|
<div className="block-action-separator" />
|
||||||
|
|
||||||
{/* Création de diagramme */}
|
{/* Création de diagramme */}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension
|
|||||||
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
|
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
|
||||||
import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
|
import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
|
||||||
import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension'
|
import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension'
|
||||||
|
import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension'
|
||||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||||
@@ -68,7 +69,7 @@ import {
|
|||||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare
|
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -231,6 +232,10 @@ const slashCommands: SlashItem[] = [
|
|||||||
title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$',
|
title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$',
|
||||||
command: (e) => { insertMathEquation(e) },
|
command: (e) => { insertMathEquation(e) },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Columns', description: 'Side-by-side layout', icon: Columns3, category: 'Basic blocks', shortcut: '/cols',
|
||||||
|
command: (e) => { insertColumnsBlock(e, 2) },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
|
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
|
||||||
@@ -480,6 +485,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
LinkPreviewExtension,
|
LinkPreviewExtension,
|
||||||
MathEquationExtension,
|
MathEquationExtension,
|
||||||
InlineMathExtension,
|
InlineMathExtension,
|
||||||
|
ColumnsExtension,
|
||||||
|
ColumnNode,
|
||||||
ClipArticleExtension,
|
ClipArticleExtension,
|
||||||
RtlPreserveExtension,
|
RtlPreserveExtension,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
@@ -1703,6 +1710,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
|||||||
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
|
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
|
||||||
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
||||||
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
||||||
|
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
|
||||||
{
|
{
|
||||||
title: t('richTextEditor.slashNoteLink'),
|
title: t('richTextEditor.slashNoteLink'),
|
||||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||||
|
|||||||
159
memento-note/components/tiptap-columns-extension.tsx
Normal file
159
memento-note/components/tiptap-columns-extension.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Node, mergeAttributes, findParentNode } from '@tiptap/core'
|
||||||
|
import { TextSelection } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
export const ColumnNode = Node.create({
|
||||||
|
name: 'column',
|
||||||
|
content: 'block+',
|
||||||
|
isolating: true,
|
||||||
|
defining: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
index: {
|
||||||
|
default: 0,
|
||||||
|
parseHTML: (el) => Number(el.getAttribute('index')) || 0,
|
||||||
|
renderHTML: (attrs) => ({ index: attrs.index }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'div[data-type="column"]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-type': 'column',
|
||||||
|
class: 'tiptap-column',
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ColumnsExtension = Node.create({
|
||||||
|
name: 'columns',
|
||||||
|
group: 'block',
|
||||||
|
content: 'column{1,}',
|
||||||
|
isolating: true,
|
||||||
|
defining: true,
|
||||||
|
allowGapCursor: false,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
cols: {
|
||||||
|
default: 2,
|
||||||
|
parseHTML: (el) => Number(el.getAttribute('cols')) || 2,
|
||||||
|
renderHTML: (attrs) => ({ cols: attrs.cols }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'div[data-type="columns"]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-type': 'columns',
|
||||||
|
class: 'tiptap-columns',
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
addColumnAfter: () => ({ state, dispatch }: { state: EditorState; dispatch?: (tr: Transaction) => void }) => {
|
||||||
|
return addOrDeleteCol(state, dispatch, 'addAfter')
|
||||||
|
},
|
||||||
|
addColumnBefore: () => ({ state, dispatch }: { state: EditorState; dispatch?: (tr: Transaction) => void }) => {
|
||||||
|
return addOrDeleteCol(state, dispatch, 'addBefore')
|
||||||
|
},
|
||||||
|
deleteColumn: () => ({ state, dispatch }: { state: EditorState; dispatch?: (tr: Transaction) => void }) => {
|
||||||
|
return addOrDeleteCol(state, dispatch, 'delete')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Mod-Shift-L': () => this.editor.commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: { cols: 2 },
|
||||||
|
content: [
|
||||||
|
{ type: 'column', attrs: { index: 0 }, content: [{ type: 'paragraph' }] },
|
||||||
|
{ type: 'column', attrs: { index: 1 }, content: [{ type: 'paragraph' }] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function addOrDeleteCol(state: EditorState, dispatch?: (tr: Transaction) => void, type?: 'addBefore' | 'addAfter' | 'delete'): boolean {
|
||||||
|
const maybeColumns = findParentNode((node: PMNode) => node.type.name === 'columns')(state.selection)
|
||||||
|
const maybeColumn = findParentNode((node: PMNode) => node.type.name === 'column')(state.selection)
|
||||||
|
|
||||||
|
if (!maybeColumns || !maybeColumn) return false
|
||||||
|
|
||||||
|
const cols = maybeColumns.node
|
||||||
|
const colIndex = maybeColumn.node.attrs.index
|
||||||
|
const colsJSON = cols.toJSON()
|
||||||
|
|
||||||
|
let nextIndex = colIndex
|
||||||
|
|
||||||
|
if (type === 'delete') {
|
||||||
|
if (colsJSON.content.length <= 1) return false
|
||||||
|
nextIndex = Math.max(0, colIndex - 1)
|
||||||
|
colsJSON.content.splice(colIndex, 1)
|
||||||
|
} else {
|
||||||
|
nextIndex = type === 'addBefore' ? colIndex : colIndex + 1
|
||||||
|
colsJSON.content.splice(nextIndex, 0, {
|
||||||
|
type: 'column',
|
||||||
|
attrs: { index: colIndex },
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
colsJSON.attrs.cols = colsJSON.content.length
|
||||||
|
colsJSON.content.forEach((colJSON: any, index: number) => {
|
||||||
|
colJSON.attrs.index = index
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextCols = PMNode.fromJSON(state.schema, colsJSON)
|
||||||
|
|
||||||
|
let nextSelectPos = maybeColumns.pos + 1
|
||||||
|
nextCols.content.forEach((col: PMNode, _pos: number, index: number) => {
|
||||||
|
if (index < nextIndex) nextSelectPos += col.nodeSize
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
const tr = state.tr
|
||||||
|
tr.replaceWith(maybeColumns.pos, maybeColumns.pos + maybeColumns.node.nodeSize, nextCols)
|
||||||
|
tr.setSelection(TextSelection.near(tr.doc.resolve(nextSelectPos + 1)))
|
||||||
|
dispatch(tr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertColumnsBlock(editor: any, columns: number = 2) {
|
||||||
|
const content = Array.from({ length: columns }, (_, i) => ({
|
||||||
|
type: 'column',
|
||||||
|
attrs: { index: i },
|
||||||
|
content: [{ type: 'paragraph' }],
|
||||||
|
}))
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'columns',
|
||||||
|
attrs: { cols: columns },
|
||||||
|
content,
|
||||||
|
}).run()
|
||||||
|
}
|
||||||
@@ -2445,6 +2445,12 @@
|
|||||||
"mathCancel": "Cancel",
|
"mathCancel": "Cancel",
|
||||||
"mathAi": "Write with AI",
|
"mathAi": "Write with AI",
|
||||||
"mathAiPlaceholder": "Describe the equation in words... (e.g: quadratic formula)",
|
"mathAiPlaceholder": "Describe the equation in words... (e.g: quadratic formula)",
|
||||||
|
"slashColumns": "Columns",
|
||||||
|
"slashColumnsDesc": "Put content side by side",
|
||||||
|
"columnsRemove": "Remove a column",
|
||||||
|
"columnsAdd": "Add a column",
|
||||||
|
"columnsDelete": "Delete columns",
|
||||||
|
"columnsLabel": "columns",
|
||||||
"calloutDelete": "Delete callout",
|
"calloutDelete": "Delete callout",
|
||||||
"calloutUnwrap": "Disable callout",
|
"calloutUnwrap": "Disable callout",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
|
|||||||
@@ -2449,6 +2449,12 @@
|
|||||||
"mathCancel": "Annuler",
|
"mathCancel": "Annuler",
|
||||||
"mathAi": "Écrire avec l'IA",
|
"mathAi": "Écrire avec l'IA",
|
||||||
"mathAiPlaceholder": "Décris l'équation en mots... (ex: formule quadratique)",
|
"mathAiPlaceholder": "Décris l'équation en mots... (ex: formule quadratique)",
|
||||||
|
"slashColumns": "Colonnes",
|
||||||
|
"slashColumnsDesc": "Mettre du contenu côte à côte",
|
||||||
|
"columnsRemove": "Retirer une colonne",
|
||||||
|
"columnsAdd": "Ajouter une colonne",
|
||||||
|
"columnsDelete": "Supprimer les colonnes",
|
||||||
|
"columnsLabel": "colonnes",
|
||||||
"calloutDelete": "Supprimer l'encadré",
|
"calloutDelete": "Supprimer l'encadré",
|
||||||
"calloutUnwrap": "Désactiver l'encadré",
|
"calloutUnwrap": "Désactiver l'encadré",
|
||||||
"calloutInfo": "Information",
|
"calloutInfo": "Information",
|
||||||
|
|||||||
Reference in New Issue
Block a user