feat: colonnes multi-colonnes (layout côte à côte style Notion)
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m26s
CI / Deploy production (on server) (push) Has been skipped

- 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:
Antigravity
2026-06-14 18:49:15 +00:00
parent d5b409c1ac
commit 7fedfa8f50
6 changed files with 223 additions and 3 deletions

View File

@@ -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; }

View File

@@ -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 */}

View File

@@ -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'),

View 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()
}

View File

@@ -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",

View File

@@ -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",