feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent],
content,
})
}
describe('Indenting a paragraph writes data-indent on the paragraph', () => {
it('increases the indent level up to the maximum', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
for (let i = 0; i < 10; i += 1) {
editor.commands.indent()
}
expect(editor.getHTML()).toContain('data-indent="4"')
expect(editor.getHTML()).not.toContain('data-indent="5"')
editor.destroy()
})
})
describe('Outdenting a paragraph decreases the indent level', () => {
it('stops at zero and drops the attribute', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
editor.commands.outdent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => {
const editor = createEditor(
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent()
expect(indented).toBe(true)
expect(editor.getHTML()).toContain('<p>First</p><ul>')
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Outdenting a heading decreases its indent level too', () => {
it('applies data-indent to headings', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
+27 -9
View File
@@ -5,38 +5,56 @@ const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe(
'15 minutes ago',
)
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe(
'1 minute ago',
)
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe(
'1 minute ago',
)
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe(
'2 hours ago',
)
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe(
'1 hour ago',
)
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe(
'Yesterday',
)
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe(
'3 days ago',
)
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe(
'18 Aug 2026',
)
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe(
'10 Aug 2026',
)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { Extension } from '@tiptap/core'
export const INDENT_MAX = 4
export const INDENT_TYPES = ['paragraph', 'heading']
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleIndent: {
indent: () => ReturnType
outdent: () => ReturnType
}
}
}
function currentIndentableNode(state: {
selection: { $from: { parent: unknown } }
}) {
return state.selection.$from.parent as {
type: { name: string }
attrs: { indent?: number }
}
}
export const ArticleIndent = Extension.create({
name: 'articleIndent',
addGlobalAttributes() {
return [
{
types: INDENT_TYPES,
attributes: {
indent: {
default: 0,
parseHTML: (element) =>
Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => {
if (!attributes.indent) {
return {}
}
return { 'data-indent': String(attributes.indent) }
},
},
},
},
]
},
addCommands() {
return {
indent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.sinkListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.min(
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
outdent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.liftListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
}
},
})