Files
Momento/memento-note/components/tiptap-columns-extension.tsx
Antigravity ee70e74bf5
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m39s
CI / Deploy production (on server) (push) Successful in 22s
fix: 5 bugs critiques de l'éditeur (Phase 1 audit)
1. replaceAll (Find & Replace) — une seule transaction ProseMirror
   au lieu d'un forEach cassé. Tous les matchs sont maintenant remplacés.

2. Link Preview unwrap — deleteNode() au lieu de clearer les attrs
   qui laissaient un nœud fantôme invisible dans le document.

3. Conversion Markdown → richtext — breaks: true dans marked.parse()
   Les simple newlines sont maintenant convertis en <br>.
   + préserve les blocs custom (toggle, callout, math, columns,
   outline, link-preview) en commentaires HTML lors de l'export MD.

4. emitNoteChange exercices — shape corrigée (type:'created' attend
   un objet Note, pas noteId/notebookId séparés).

5. Raccourcis clavier sans conflit :
   Cmd+Shift+C → Cmd+Alt+C (callout, avant: copier)
   Cmd+Shift+O → Cmd+Alt+O (outline, avant: historique/signets)
   Cmd+Shift+L → Cmd+Alt+L (colonnes, avant: lock screen macOS)
2026-06-20 15:48:18 +00:00

160 lines
4.3 KiB
TypeScript

'use client'
import { Node, mergeAttributes, findParentNode } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { 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-Alt-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()
}