@estiva-app/ui 0.65.3 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BlockNodes.d.ts +33 -0
- package/dist/BlockNodes.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +11 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/EditorTableControls.d.ts +38 -0
- package/dist/EditorTableControls.d.ts.map +1 -0
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/RichText.d.ts +13 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.d.ts +2 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +363 -18
- package/dist/editor.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +71 -23
- package/dist/index.js.map +3 -3
- package/package.json +2 -1
- package/registry.json +209 -4
- package/src/BlockNodes.mdx +32 -1
- package/src/BlockNodes.stories.tsx +29 -1
- package/src/BlockNodes.test.tsx +141 -2
- package/src/BlockNodes.tsx +227 -4
- package/src/ComposerTriggers.test.tsx +23 -0
- package/src/ComposerTriggers.tsx +61 -5
- package/src/EditorTableControls.mdx +78 -0
- package/src/EditorTableControls.stories.tsx +63 -0
- package/src/EditorTableControls.test.tsx +288 -0
- package/src/EditorTableControls.tsx +255 -0
- package/src/NavItem.mdx +10 -0
- package/src/NavItem.stories.tsx +23 -1
- package/src/NavItem.test.tsx +36 -0
- package/src/NavItem.tsx +40 -16
- package/src/RichText.mdx +12 -0
- package/src/RichText.stories.tsx +50 -0
- package/src/RichText.test.tsx +56 -0
- package/src/RichText.tsx +83 -10
- package/src/editor.ts +4 -0
- package/src/index.ts +1 -1
- package/src/registry/registry.test.ts +7 -7
- package/src/registry/siblings.test.ts +2 -2
- package/stories/Choosing.mdx +1 -0
package/src/BlockNodes.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { ComponentType } from 'react'
|
|
2
|
-
import { Extension, Node } from '@tiptap/core'
|
|
3
|
-
import type
|
|
4
|
-
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'
|
|
2
|
+
import { Extension, InputRule, Node, type Editor } from '@tiptap/core'
|
|
3
|
+
import { DOMSerializer, type Node as ProseMirrorNode, type Slice } from '@tiptap/pm/model'
|
|
4
|
+
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
|
|
5
5
|
import { Mapping, ReplaceAroundStep } from '@tiptap/pm/transform'
|
|
6
|
-
import {
|
|
6
|
+
import type { EditorView, NodeView } from '@tiptap/pm/view'
|
|
7
|
+
import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type ReactNodeViewProps } from '@tiptap/react'
|
|
8
|
+
import { Checkbox, type CheckboxProps } from './Checkbox'
|
|
9
|
+
import { calloutLook } from './RichText'
|
|
7
10
|
|
|
8
11
|
/*
|
|
9
12
|
* The four nodes an editor of a block document needs that a stock schema does
|
|
@@ -529,3 +532,223 @@ export const AttachmentNode = Node.create<AttachmentNodeOptions>({
|
|
|
529
532
|
))
|
|
530
533
|
},
|
|
531
534
|
})
|
|
535
|
+
|
|
536
|
+
const TICK = 'checklistTick'
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Whether a change is a click on a checklist item's box. The click does not
|
|
540
|
+
* focus the editor, so no blur follows it: an editor that saves on blur saves
|
|
541
|
+
* a tick straight away instead (RIC-19, Miky 2026-10-13: one click ticks).
|
|
542
|
+
*/
|
|
543
|
+
export function isChecklistTick(transaction: Transaction): boolean {
|
|
544
|
+
return transaction.getMeta(TICK) === true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** A list or quote command by name: StarterKit's declare them, and this package builds without StarterKit. */
|
|
548
|
+
type Chain = ReturnType<Editor['chain']>
|
|
549
|
+
function named(chain: Chain, command: string, ...args: unknown[]): Chain {
|
|
550
|
+
const own = (chain as unknown as Record<string, ((...a: unknown[]) => Chain) | undefined>)[command]
|
|
551
|
+
return own ? own(...args) : chain
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The list item the caret's paragraph is the first line of, if it is. */
|
|
555
|
+
function itemAtCaret(state: EditorState): { node: ProseMirrorNode; pos: number } | null {
|
|
556
|
+
const { $from } = state.selection
|
|
557
|
+
if ($from.depth < 2) return null
|
|
558
|
+
const item = $from.node(-1)
|
|
559
|
+
if (item.type.name !== 'listItem' || $from.index(-1) !== 0) return null
|
|
560
|
+
return { node: item, pos: $from.before(-1) }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const sameAttrs = (a: ProseMirrorNode, b: ProseMirrorNode) =>
|
|
564
|
+
Object.keys({ ...a.attrs, ...b.attrs }).every((key) => a.attrs[key] === b.attrs[key])
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A list item, drawn with a box before it when it is a checklist item. The box
|
|
568
|
+
* is the package's `Checkbox`, as `RichText` draws it, and the item's own
|
|
569
|
+
* text and inner lists follow it in a `div`. Any change to the item's
|
|
570
|
+
* attributes draws it again, which is what a tick is.
|
|
571
|
+
*
|
|
572
|
+
* The box is named by the item's words, and follows them as they are typed.
|
|
573
|
+
* Whether it ticks follows the editor's `editable`, read again on every
|
|
574
|
+
* update — `setEditable` sends one — not only when the item was drawn
|
|
575
|
+
* (Katerina, review of #170). Not editable, it is a picture, and the box around
|
|
576
|
+
* it says done or not to a screen reader, as `RichText`'s does.
|
|
577
|
+
*/
|
|
578
|
+
function listItemView(editor: Editor, node: ProseMirrorNode, view: EditorView, getPos: () => number | undefined): NodeView {
|
|
579
|
+
const toDOM = node.type.spec.toDOM
|
|
580
|
+
const { dom, contentDOM } = toDOM ? DOMSerializer.renderSpec(document, toDOM(node)) : { dom: document.createElement('li'), contentDOM: undefined }
|
|
581
|
+
const checked = node.attrs.checked
|
|
582
|
+
if (typeof checked !== 'boolean') return { dom, contentDOM: contentDOM ?? dom, update: (next) => next.type === node.type && sameAttrs(next, node) }
|
|
583
|
+
|
|
584
|
+
const li = dom as HTMLElement
|
|
585
|
+
const box = document.createElement('span')
|
|
586
|
+
box.contentEditable = 'false'
|
|
587
|
+
box.setAttribute('data-checklist-box', '')
|
|
588
|
+
const body = document.createElement('div')
|
|
589
|
+
li.replaceChildren(box, body)
|
|
590
|
+
const tick = (next: boolean) => {
|
|
591
|
+
const pos = getPos()
|
|
592
|
+
if (pos === undefined || !view.editable) return
|
|
593
|
+
view.dispatch(view.state.tr.setNodeAttribute(pos, 'checked', next).setMeta(TICK, true))
|
|
594
|
+
}
|
|
595
|
+
let words = node.firstChild?.textContent.trim() || 'Item'
|
|
596
|
+
const props = (): CheckboxProps => ({ checked, onChange: view.editable ? tick : undefined, 'aria-label': words })
|
|
597
|
+
const renderer = new ReactRenderer<unknown, CheckboxProps>(Checkbox, { editor, props: props(), as: 'span' })
|
|
598
|
+
box.appendChild(renderer.element)
|
|
599
|
+
// What the box last drew: an update that changes neither leaves it alone, so typing does not draw every box again.
|
|
600
|
+
let drawn = ''
|
|
601
|
+
const sync = () => {
|
|
602
|
+
const now = `${view.editable}:${words}`
|
|
603
|
+
if (now === drawn) return
|
|
604
|
+
drawn = now
|
|
605
|
+
renderer.updateProps(props())
|
|
606
|
+
for (const [name, value] of Object.entries({ role: 'checkbox', 'aria-checked': String(checked), 'aria-readonly': 'true', 'aria-label': words })) {
|
|
607
|
+
if (view.editable) box.removeAttribute(name)
|
|
608
|
+
else box.setAttribute(name, value)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
sync()
|
|
612
|
+
editor.on('update', sync)
|
|
613
|
+
return {
|
|
614
|
+
dom: li,
|
|
615
|
+
contentDOM: body,
|
|
616
|
+
update: (next) => {
|
|
617
|
+
if (next.type !== node.type || !sameAttrs(next, node)) return false
|
|
618
|
+
words = next.firstChild?.textContent.trim() || 'Item'
|
|
619
|
+
sync()
|
|
620
|
+
return true
|
|
621
|
+
},
|
|
622
|
+
stopEvent: (event) => box.contains(event.target as globalThis.Node),
|
|
623
|
+
ignoreMutation: (mutation) => box.contains(mutation.target),
|
|
624
|
+
destroy: () => {
|
|
625
|
+
editor.off('update', sync)
|
|
626
|
+
renderer.destroy()
|
|
627
|
+
},
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* A checklist item — SPEC §13.3's `checked` on a list item (RIC-19): `true`
|
|
633
|
+
* ticked, `false` not, absent a plain item. Add it beside `BlockId`.
|
|
634
|
+
*
|
|
635
|
+
* - **The box ticks with one click**, where the description is read, and the
|
|
636
|
+
* change says so (`isChecklistTick`), so the app saves it at once.
|
|
637
|
+
* - **`[ ] ` or `[] ` at the start of a line** makes it, `[x] ` ticked; in a
|
|
638
|
+
* list it turns that item into one.
|
|
639
|
+
* - **Enter** starts the next item unticked: a new item never takes the tick
|
|
640
|
+
* of the one it was split from. At the start of an item, the text moves down
|
|
641
|
+
* with its tick and the empty item above is unticked. On an empty item Enter
|
|
642
|
+
* leaves the list, as in any list.
|
|
643
|
+
* - **Backspace** at the start of a checklist item takes its box away first;
|
|
644
|
+
* the next one joins it to the item above, as in any list.
|
|
645
|
+
*
|
|
646
|
+
* An editor without it drops `checked` on save and keeps the item and its text.
|
|
647
|
+
*/
|
|
648
|
+
export const Checklist = Extension.create({
|
|
649
|
+
name: 'checklist',
|
|
650
|
+
// Before the list item's own Enter and Backspace.
|
|
651
|
+
priority: 101,
|
|
652
|
+
addGlobalAttributes() {
|
|
653
|
+
return [
|
|
654
|
+
{
|
|
655
|
+
types: ['listItem'],
|
|
656
|
+
attributes: {
|
|
657
|
+
checked: {
|
|
658
|
+
default: null,
|
|
659
|
+
keepOnSplit: false,
|
|
660
|
+
parseHTML: (element: HTMLElement) => {
|
|
661
|
+
const value = element.getAttribute('data-checked')
|
|
662
|
+
return value === 'true' ? true : value === 'false' ? false : null
|
|
663
|
+
},
|
|
664
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
665
|
+
typeof attributes.checked === 'boolean' ? { 'data-checked': String(attributes.checked) } : {},
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
]
|
|
670
|
+
},
|
|
671
|
+
addInputRules() {
|
|
672
|
+
return [
|
|
673
|
+
new InputRule({
|
|
674
|
+
find: /^\[([ xX]?)\]\s$/,
|
|
675
|
+
handler: ({ state, range, match, chain }) => {
|
|
676
|
+
const checked = match[1].toLowerCase() === 'x'
|
|
677
|
+
const inList = itemAtCaret(state) !== null
|
|
678
|
+
// Without a bullet list to make, the typed text stays as it was typed.
|
|
679
|
+
if (!inList && !(chain() as unknown as Record<string, unknown>).toggleBulletList) return null
|
|
680
|
+
const deleted = chain().deleteRange(range)
|
|
681
|
+
const listed = inList ? deleted : named(deleted, 'toggleBulletList')
|
|
682
|
+
listed.updateAttributes('listItem', { checked }).run()
|
|
683
|
+
},
|
|
684
|
+
}),
|
|
685
|
+
]
|
|
686
|
+
},
|
|
687
|
+
addKeyboardShortcuts() {
|
|
688
|
+
return {
|
|
689
|
+
Enter: () => {
|
|
690
|
+
const { state } = this.editor
|
|
691
|
+
const item = itemAtCaret(state)
|
|
692
|
+
const { $from, empty } = state.selection
|
|
693
|
+
if (!item || typeof item.node.attrs.checked !== 'boolean') return false
|
|
694
|
+
// Over a selection: the selection goes and the next item is unticked, as for a caret in the text.
|
|
695
|
+
if (!empty) return this.editor.chain().deleteSelection().splitListItem('listItem', { checked: false }).run()
|
|
696
|
+
// An empty item: the list item's own Enter, which leaves the list.
|
|
697
|
+
if ($from.parent.content.size === 0) return false
|
|
698
|
+
if ($from.parentOffset > 0) return this.editor.commands.splitListItem('listItem', { checked: false })
|
|
699
|
+
return this.editor
|
|
700
|
+
.chain()
|
|
701
|
+
.splitListItem('listItem', { checked: item.node.attrs.checked })
|
|
702
|
+
.command(({ tr }) => {
|
|
703
|
+
tr.setNodeAttribute(tr.mapping.map(item.pos), 'checked', false)
|
|
704
|
+
return true
|
|
705
|
+
})
|
|
706
|
+
.run()
|
|
707
|
+
},
|
|
708
|
+
Backspace: () => {
|
|
709
|
+
const { state } = this.editor
|
|
710
|
+
const item = itemAtCaret(state)
|
|
711
|
+
const { $from, empty } = state.selection
|
|
712
|
+
if (!item || !empty || $from.parentOffset > 0 || typeof item.node.attrs.checked !== 'boolean') return false
|
|
713
|
+
this.editor.view.dispatch(state.tr.setNodeAttribute(item.pos, 'checked', null))
|
|
714
|
+
return true
|
|
715
|
+
},
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
addProseMirrorPlugins() {
|
|
719
|
+
const editor = this.editor
|
|
720
|
+
return [
|
|
721
|
+
new Plugin({
|
|
722
|
+
key: new PluginKey('checklist'),
|
|
723
|
+
props: { nodeViews: { listItem: (node, view, getPos) => listItemView(editor, node, view, getPos) } },
|
|
724
|
+
}),
|
|
725
|
+
]
|
|
726
|
+
},
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* A callout — SPEC §13.3's `tone` on a quote (RIC-19): a quote drawn to stand
|
|
731
|
+
* out, in one of GitHub's alert kinds (`note`, `tip`, `important`, `warning`,
|
|
732
|
+
* `caution`). Its look is `RichText`'s, by `data-tone`; a tone it does not
|
|
733
|
+
* know looks like `note`, and the editor keeps it as written. Add it beside
|
|
734
|
+
* `BlockId`.
|
|
735
|
+
*/
|
|
736
|
+
export const Callout = Extension.create({
|
|
737
|
+
name: 'callout',
|
|
738
|
+
addGlobalAttributes() {
|
|
739
|
+
return [
|
|
740
|
+
{
|
|
741
|
+
types: ['blockquote'],
|
|
742
|
+
attributes: {
|
|
743
|
+
tone: {
|
|
744
|
+
default: null,
|
|
745
|
+
parseHTML: (element: HTMLElement) => element.getAttribute('data-tone'),
|
|
746
|
+
// As `RichText` draws it: the tone as written, and the look it is drawn in.
|
|
747
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
748
|
+
typeof attributes.tone === 'string' ? { 'data-tone': attributes.tone, 'data-callout': calloutLook(attributes.tone) } : {},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
},
|
|
754
|
+
})
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type SlashSection,
|
|
28
28
|
} from './ComposerTriggers'
|
|
29
29
|
import { isSuggestionActive, isSuggestionOpen } from './suggestionPopup'
|
|
30
|
+
import { Callout, Checklist } from './BlockNodes'
|
|
30
31
|
|
|
31
32
|
const KEY = 'b'.repeat(64)
|
|
32
33
|
const PEOPLE: MentionPerson[] = [
|
|
@@ -549,3 +550,25 @@ describe('/', () => {
|
|
|
549
550
|
expect(hint(formatSection())).toBe('`')
|
|
550
551
|
})
|
|
551
552
|
})
|
|
553
|
+
|
|
554
|
+
describe('/ in a block editor — RIC-19', () => {
|
|
555
|
+
function BlockComposer({ blocks }: { blocks: boolean }) {
|
|
556
|
+
const made = useEditor({
|
|
557
|
+
extensions: [StarterKit, ...(blocks ? [Checklist, Callout] : []), SlashCommands.configure({ sections: [formatSection('block')] })],
|
|
558
|
+
})
|
|
559
|
+
editor = made
|
|
560
|
+
return <EditorContent editor={made} />
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
it('offers Checklist, Note and Warning where the editor has Checklist and Callout', async () => {
|
|
564
|
+
render(<BlockComposer blocks />)
|
|
565
|
+
await type('/')
|
|
566
|
+
expect(options()?.slice(-3)).toEqual(['Checklist[]', 'Note', 'Warning'])
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('offers none of them where it has not: they would make a plain bullet and a plain quote (Katerina, review of #170)', async () => {
|
|
570
|
+
render(<BlockComposer blocks={false} />)
|
|
571
|
+
await type('/')
|
|
572
|
+
expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code```', 'Bulleted list-', 'Numbered list1.'])
|
|
573
|
+
})
|
|
574
|
+
})
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -433,14 +433,23 @@ export interface SlashCommand {
|
|
|
433
433
|
keywords?: readonly string[]
|
|
434
434
|
/** Runs once the `/query` that summoned the list has been removed. */
|
|
435
435
|
run: (editor: Editor) => void
|
|
436
|
+
/**
|
|
437
|
+
* The extensions (by name) the row needs: an editor without one of them is
|
|
438
|
+
* not offered the row, so no row makes something the editor cannot hold.
|
|
439
|
+
*/
|
|
440
|
+
needs?: readonly string[]
|
|
436
441
|
}
|
|
437
442
|
|
|
443
|
+
/** Whether an editor has every extension a row needs. */
|
|
444
|
+
const offers = (editor: Editor, command: SlashCommand) =>
|
|
445
|
+
!command.needs || command.needs.every((name) => editor.extensionManager.extensions.some((extension) => extension.name === name))
|
|
446
|
+
|
|
438
447
|
export interface SlashSection {
|
|
439
448
|
label: string
|
|
440
449
|
commands: readonly SlashCommand[]
|
|
441
450
|
}
|
|
442
451
|
|
|
443
|
-
export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code'
|
|
452
|
+
export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code' | 'checklist' | 'note' | 'warning'
|
|
444
453
|
|
|
445
454
|
export interface FormatDef {
|
|
446
455
|
id: FormatId
|
|
@@ -466,6 +475,19 @@ export const FORMATS: readonly FormatDef[] = [
|
|
|
466
475
|
{ id: 'orderedList', label: 'Numbered list', trigger: '1.' },
|
|
467
476
|
]
|
|
468
477
|
|
|
478
|
+
/**
|
|
479
|
+
* What only a document holds — a checklist item and a callout (RIC-19) — so
|
|
480
|
+
* only a block editor's `/` offers them (`formatSection('block')`): a
|
|
481
|
+
* message's text has no spelling for either. They need the `Checklist` and
|
|
482
|
+
* `Callout` extensions. Note and Warning are the two callouts offered (Miky,
|
|
483
|
+
* 2026-10-13); the other tones are read and kept, not offered.
|
|
484
|
+
*/
|
|
485
|
+
const DOCUMENT_FORMATS: readonly FormatDef[] = [
|
|
486
|
+
{ id: 'checklist', label: 'Checklist', trigger: '[]' },
|
|
487
|
+
{ id: 'note', label: 'Note' },
|
|
488
|
+
{ id: 'warning', label: 'Warning' },
|
|
489
|
+
]
|
|
490
|
+
|
|
469
491
|
/** Code as a mark inside the line, or as a block of its own. */
|
|
470
492
|
export type CodeFormat = 'inline' | 'block'
|
|
471
493
|
|
|
@@ -501,19 +523,50 @@ export function applyFormat(editor: Editor, id: FormatId, code: CodeFormat = 'in
|
|
|
501
523
|
return run('toggleOrderedList')
|
|
502
524
|
case 'code':
|
|
503
525
|
return run(code === 'block' ? 'toggleCodeBlock' : 'toggleCode')
|
|
526
|
+
case 'checklist': {
|
|
527
|
+
// In a list, that item becomes one; elsewhere, the line becomes a list of one.
|
|
528
|
+
if (!editor.isActive('listItem') && !run('toggleBulletList')) return false
|
|
529
|
+
// An item already a checklist item keeps its tick.
|
|
530
|
+
if (typeof editor.getAttributes('listItem').checked === 'boolean') return true
|
|
531
|
+
return editor.chain().updateAttributes('listItem', { checked: false }).run()
|
|
532
|
+
}
|
|
533
|
+
case 'note':
|
|
534
|
+
case 'warning': {
|
|
535
|
+
if (!editor.isActive('blockquote') && !run('toggleBlockquote')) return false
|
|
536
|
+
return editor.chain().updateAttributes('blockquote', { tone: id }).run()
|
|
537
|
+
}
|
|
504
538
|
default:
|
|
505
539
|
return false
|
|
506
540
|
}
|
|
507
541
|
}
|
|
508
542
|
|
|
509
|
-
/**
|
|
543
|
+
/** Words a row is also found by, beyond its id. */
|
|
544
|
+
const KEYWORDS: Partial<Record<FormatId, string[]>> = {
|
|
545
|
+
checklist: ['todo', 'task'],
|
|
546
|
+
note: ['callout'],
|
|
547
|
+
warning: ['callout'],
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** The extension a row's block needs: without it the row would make a plain bullet or a plain quote (Katerina, review of #170). */
|
|
551
|
+
const NEEDS: Partial<Record<FormatId, string[]>> = {
|
|
552
|
+
checklist: ['checklist'],
|
|
553
|
+
note: ['callout'],
|
|
554
|
+
warning: ['callout'],
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The Format section every app's `/` opens with. A block editor's also offers
|
|
559
|
+
* a checklist and the callouts — only where the editor has the `Checklist` and
|
|
560
|
+
* `Callout` extensions (`SlashCommand.needs`).
|
|
561
|
+
*/
|
|
510
562
|
export function formatSection(code: CodeFormat = 'inline'): SlashSection {
|
|
511
563
|
return {
|
|
512
564
|
label: 'Format',
|
|
513
|
-
commands: FORMATS.map((format) => ({
|
|
565
|
+
commands: (code === 'block' ? [...FORMATS, ...DOCUMENT_FORMATS] : FORMATS).map((format) => ({
|
|
514
566
|
key: `format-${format.id}`,
|
|
515
567
|
row: { label: format.label, shortcut: (code === 'block' && format.blockTrigger) || format.trigger },
|
|
516
|
-
keywords: [format.id],
|
|
568
|
+
keywords: [format.id, ...(KEYWORDS[format.id] ?? [])],
|
|
569
|
+
...(NEEDS[format.id] ? { needs: NEEDS[format.id] } : {}),
|
|
517
570
|
run: (editor) => {
|
|
518
571
|
applyFormat(editor, format.id, code)
|
|
519
572
|
},
|
|
@@ -565,7 +618,10 @@ export const SlashCommands = Extension.create<SlashCommandsOptions>({
|
|
|
565
618
|
char: '/',
|
|
566
619
|
startOfLine: false,
|
|
567
620
|
pluginKey: new PluginKey('slashCommands'),
|
|
568
|
-
items: ({ query
|
|
621
|
+
items: ({ query, editor }) =>
|
|
622
|
+
filterSlashSections(sections, query).flatMap((section, at) =>
|
|
623
|
+
section.commands.filter((command) => offers(editor, command)).map((command) => ({ ...command, section: at })),
|
|
624
|
+
),
|
|
569
625
|
command: ({ editor, range, props }) => {
|
|
570
626
|
editor.chain().focus().deleteRange(range).run()
|
|
571
627
|
props.run(editor)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as EditorTableControlsStories from './EditorTableControls.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={EditorTableControlsStories} />
|
|
5
|
+
|
|
6
|
+
# EditorTableControls
|
|
7
|
+
|
|
8
|
+
A table's own controls in a Tiptap editor: point at a cell for a handle on its
|
|
9
|
+
row and on its column, and a ➕ bar under the table and right of it.
|
|
10
|
+
|
|
11
|
+
<Canvas of={EditorTableControlsStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **An editor whose documents hold tables** — with TableKit
|
|
16
|
+
(`@tiptap/extension-table`) in its extensions. Point at a cell: the row's
|
|
17
|
+
handle sits on the table's left edge, the column's on its top edge; each
|
|
18
|
+
opens Insert (above and below, or left and right), Delete, and Delete
|
|
19
|
+
table. A header row offers nothing above it, and deleting it makes the next
|
|
20
|
+
row the header, so a table always opens with its header. From the last row, a ➕ under the table adds a row after it; from the
|
|
21
|
+
last column, a ➕ right of the table adds a column after it.
|
|
22
|
+
- **Not editable**: nothing is drawn.
|
|
23
|
+
|
|
24
|
+
## When not
|
|
25
|
+
|
|
26
|
+
- Moving, duplicating or deleting the table as one block → **EditorBlockHandle**,
|
|
27
|
+
whose handle stays the table's, in the gutter. Wrap this inside it.
|
|
28
|
+
- A table being read, with no editor → **RichText** draws it; there is nothing
|
|
29
|
+
to change.
|
|
30
|
+
|
|
31
|
+
## How
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
35
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
36
|
+
import { TableKit } from '@tiptap/extension-table'
|
|
37
|
+
import { BlockId, EditorBlockHandle, EditorTableControls } from '@estiva-app/ui/editor'
|
|
38
|
+
|
|
39
|
+
const editor = useEditor({ extensions: [StarterKit, TableKit.configure({ table: { resizable: false } }), BlockId] })
|
|
40
|
+
|
|
41
|
+
<EditorBlockHandle editor={editor}>
|
|
42
|
+
<EditorTableControls editor={editor}>
|
|
43
|
+
<EditorContent editor={editor} />
|
|
44
|
+
</EditorTableControls>
|
|
45
|
+
</EditorBlockHandle>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- **Every control leaves the editor focused**, in the cell it acted on, as the
|
|
49
|
+
block menu does. An editor that saves when the person leaves it saves these
|
|
50
|
+
the same way.
|
|
51
|
+
- A table cannot be left with no row or no column: **deleting its only row, or
|
|
52
|
+
its only column, deletes the table**.
|
|
53
|
+
- The column ➕ hangs 16px right of the table; give the editor that room, as
|
|
54
|
+
the block handle has its 28px on the left. The row ➕ lies 16px over the top
|
|
55
|
+
of the block under the table, only while the pointer is on the last row.
|
|
56
|
+
- `appendTableRow`, `appendTableColumn`, `deleteTableRow` and
|
|
57
|
+
`deleteTableColumn` are the controls' own actions, for a caller that offers
|
|
58
|
+
them elsewhere (a shortcut, a test).
|
|
59
|
+
|
|
60
|
+
## What it owns
|
|
61
|
+
|
|
62
|
+
It does these by itself. Write none of them around it.
|
|
63
|
+
|
|
64
|
+
| It does | So you never write | Refused when written by hand |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| Runs TableKit's own commands with the caret in the cell pointed at | a selection before `addRowAfter` | |
|
|
67
|
+
| Gives new rows and cells fresh ids, the old ones keeping theirs (`BlockId` decides) | an id for a new cell | |
|
|
68
|
+
| Turns a delete that would leave the table empty into a delete of the table | a check of the table's size | |
|
|
69
|
+
|
|
70
|
+
## Keys
|
|
71
|
+
|
|
72
|
+
| Input | What happens |
|
|
73
|
+
|---|---|
|
|
74
|
+
| Tab, in the last cell | adds a row and moves into it — TableKit's own |
|
|
75
|
+
| Tab / Shift+Tab, in a cell | to the next / previous cell — TableKit's own |
|
|
76
|
+
|
|
77
|
+
The handles and the ➕ bars are for the pointer: there is no hover on a touch
|
|
78
|
+
screen, and nothing here draws them there yet.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
4
|
+
import { TableKit } from '@tiptap/extension-table'
|
|
5
|
+
import { BlockId } from './BlockNodes'
|
|
6
|
+
import { EditorBlockHandle, blockDropCursor } from './EditorBlockHandle'
|
|
7
|
+
import { EditorTableControls } from './EditorTableControls'
|
|
8
|
+
import { richTextClassName } from './RichText'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A table's controls in an editor. Point at a cell: a handle on its row's left
|
|
12
|
+
* edge and on its column's top edge, and a ➕ bar under the table and right of
|
|
13
|
+
* it.
|
|
14
|
+
*/
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'Primitives/EditorTableControls',
|
|
17
|
+
// The block handle sits 28px left of the content, and the column ➕ 16px right of the table.
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
const text = (t: string) => [{ type: 'paragraph', content: [{ type: 'text', text: t }] }]
|
|
25
|
+
const row = (type: 'tableHeader' | 'tableCell', ...cells: string[]) => ({ type: 'tableRow', content: cells.map((c) => ({ type, content: text(c) })) })
|
|
26
|
+
|
|
27
|
+
const DOCUMENT = {
|
|
28
|
+
type: 'doc',
|
|
29
|
+
content: [
|
|
30
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Point at a cell for its row and column handles.' }] },
|
|
31
|
+
{
|
|
32
|
+
type: 'table',
|
|
33
|
+
content: [row('tableHeader', 'Label', 'Label', 'Label'), row('tableCell', 'Item one', 'Value', 'Value'), row('tableCell', 'Item two', 'Value', 'Value')],
|
|
34
|
+
},
|
|
35
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'The bar under the table adds a row; the one right of it, a column.' }] },
|
|
36
|
+
],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Editing({ editable }: { editable: boolean }) {
|
|
40
|
+
const editor = useEditor({
|
|
41
|
+
extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), TableKit.configure({ table: { resizable: false } }), BlockId],
|
|
42
|
+
content: DOCUMENT,
|
|
43
|
+
editable,
|
|
44
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
45
|
+
})
|
|
46
|
+
return (
|
|
47
|
+
<EditorBlockHandle editor={editor} className="ml-8 max-w-[560px]">
|
|
48
|
+
<EditorTableControls editor={editor}>
|
|
49
|
+
<EditorContent editor={editor} />
|
|
50
|
+
</EditorTableControls>
|
|
51
|
+
</EditorBlockHandle>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Point at a cell; press a handle for Insert and Delete, or a ➕ bar to add a row or a column at the end. */
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
render: () => <Editing editable />,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An editor that is not editable: no controls. */
|
|
61
|
+
export const NotEditable: Story = {
|
|
62
|
+
render: () => <Editing editable={false} />,
|
|
63
|
+
}
|