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

@@ -3001,3 +3001,36 @@ html.font-system * {
box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.3);
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,
CheckSquare, Quote, CodeXml, Database,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
ChevronsRightLeft, MessageSquareWarning, ListTree,
ChevronsRightLeft, MessageSquareWarning, ListTree, Columns3,
Plus, Minus,
} from 'lucide-react'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension'
import { turnIntoCalloutBlock } from '@/components/tiptap-callout-extension'
import { insertOutlineBlock } from '@/components/tiptap-outline-extension'
import { insertColumnsBlock } from '@/components/tiptap-columns-extension'
interface BlockActionMenuProps {
editor: Editor
@@ -391,6 +393,12 @@ export function BlockActionMenu({
<span>{t('richTextEditor.slashOutline')}</span>
</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" />
{/* 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 { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-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 { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -68,7 +69,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@@ -231,6 +232,10 @@ const slashCommands: SlashItem[] = [
title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$',
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> {
@@ -480,6 +485,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
LinkPreviewExtension,
MathEquationExtension,
InlineMathExtension,
ColumnsExtension,
ColumnNode,
ClipArticleExtension,
RtlPreserveExtension,
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[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[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'),
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",
"mathAi": "Write with AI",
"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",
"calloutUnwrap": "Disable callout",
"calloutInfo": "Information",

View File

@@ -2449,6 +2449,12 @@
"mathCancel": "Annuler",
"mathAi": "Écrire avec l'IA",
"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é",
"calloutUnwrap": "Désactiver l'encadré",
"calloutInfo": "Information",