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;
|
||||
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; }
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user