@estiva-app/ui 0.50.4 → 0.52.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/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/ComposerTriggers.d.ts +169 -0
- package/dist/ComposerTriggers.d.ts.map +1 -0
- package/dist/NavItem.d.ts +23 -3
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +49 -0
- package/dist/NavTree.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +7 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +313 -12
- package/dist/editor.js.map +4 -4
- package/dist/eslint/index.js +1 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +332 -212
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +7 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +6 -1
- package/registry.json +426 -5
- package/src/CollapsibleSection.mdx +6 -0
- package/src/CollapsibleSection.stories.tsx +5 -0
- package/src/CollapsibleSection.tsx +12 -4
- package/src/ComposerTriggers.mdx +108 -0
- package/src/ComposerTriggers.stories.tsx +79 -0
- package/src/ComposerTriggers.test.tsx +207 -0
- package/src/ComposerTriggers.tsx +507 -0
- package/src/NavItem.mdx +30 -0
- package/src/NavItem.stories.tsx +34 -0
- package/src/NavItem.test.tsx +82 -0
- package/src/NavItem.tsx +123 -25
- package/src/NavTree.mdx +83 -0
- package/src/NavTree.stories.tsx +84 -0
- package/src/NavTree.test.tsx +54 -0
- package/src/NavTree.tsx +112 -0
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +4 -1
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +10 -2
- package/src/editor.ts +29 -0
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +8 -0
- package/src/registry/registry.test.ts +9 -8
- package/stories/Choosing.mdx +2 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ComposerTriggersStories from './ComposerTriggers.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ComposerTriggersStories} />
|
|
5
|
+
|
|
6
|
+
# ComposerTriggers
|
|
7
|
+
|
|
8
|
+
The four keys a composer shares across apps: `@` and `!@` to mention a person,
|
|
9
|
+
`[` to reference something, `/` to format the line or insert something. Each
|
|
10
|
+
opens a `SuggestionMenu` at the caret; a pick inserts a chip.
|
|
11
|
+
|
|
12
|
+
<Canvas of={ComposerTriggersStories.Default} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **`PersonMention` and `UrgentPersonMention`** — any editor whose text names
|
|
17
|
+
people. The urgent one is the same chip in the urgent tone.
|
|
18
|
+
- **`CaptionedReference` and `ReferenceTrigger`** — referencing something the
|
|
19
|
+
app can list, drawn as who wrote it and its opening words.
|
|
20
|
+
- **`SlashCommands`** — `formatSection()` first, then only the rows the app can
|
|
21
|
+
perform. `formatSection('block')` makes Code a block, for an editor of a
|
|
22
|
+
block document.
|
|
23
|
+
|
|
24
|
+
## When not
|
|
25
|
+
|
|
26
|
+
- Formatting text already written → **SelectionToolbar**. Bold, italic and
|
|
27
|
+
underline are not in `/` for that reason.
|
|
28
|
+
- A list that is not opened by a key typed in an editor → **CommandPalette** or
|
|
29
|
+
**Menu**.
|
|
30
|
+
- Drawing a sent body → **RichText**. These are for editing only.
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
36
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
37
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
38
|
+
import {
|
|
39
|
+
CaptionedReference,
|
|
40
|
+
PersonMention,
|
|
41
|
+
ReferenceTrigger,
|
|
42
|
+
SlashCommands,
|
|
43
|
+
UrgentPersonMention,
|
|
44
|
+
formatSection,
|
|
45
|
+
typeTrigger,
|
|
46
|
+
urgentPubkeys,
|
|
47
|
+
type CaptionedItem,
|
|
48
|
+
type MentionPerson,
|
|
49
|
+
} from '@estiva-app/ui/editor'
|
|
50
|
+
|
|
51
|
+
const people = (): MentionPerson[] => [{ id: 'one', name: 'Person one', pubkey: null }]
|
|
52
|
+
const items = (): CaptionedItem[] => [{ id: 'one', label: 'Person one', snippet: 'Item one', uri: 'nostr:item' }]
|
|
53
|
+
|
|
54
|
+
const editor = useEditor({
|
|
55
|
+
extensions: [
|
|
56
|
+
StarterKit,
|
|
57
|
+
PersonMention.configure({ people }),
|
|
58
|
+
UrgentPersonMention.configure({ people }),
|
|
59
|
+
CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
|
|
60
|
+
ReferenceTrigger.configure({ items, ariaLabel: 'Items', sectionLabel: 'Items', icon: <IconSquareRounded size={16} stroke={1.5} /> }),
|
|
61
|
+
SlashCommands.configure({
|
|
62
|
+
sections: [formatSection(), { label: 'Insert', commands: [{ key: 'insert-@', row: { label: 'Mention', shortcut: '@' }, run: typeTrigger('@') }] }],
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const urgent = editor ? urgentPubkeys(editor) : undefined
|
|
68
|
+
|
|
69
|
+
<EditorContent editor={editor} />
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **The body is written by `@estiva-app/conversation`, not here.** A mention
|
|
73
|
+
chip carries `pubkey` and `label` for its `mentionText`; a reference chip
|
|
74
|
+
carries the `uri` the app built with that package, written back verbatim. The
|
|
75
|
+
urgency is not in the text: hand `urgentPubkeys(editor)` to `urgentTagsFor`.
|
|
76
|
+
- **The lists ask each time the query changes.** `people` and `items` are
|
|
77
|
+
functions, so they read the app's current data. `@` shows the first six
|
|
78
|
+
names holding the query; `[` the last six whose label or `search` (default:
|
|
79
|
+
`snippet`) holds it.
|
|
80
|
+
- **An app's own node name:** `CaptionedReference.extend({ name: 'yourName' })`
|
|
81
|
+
and `ReferenceTrigger.configure({ nodeName: 'yourName' })`. The chip's HTML
|
|
82
|
+
attribute follows the name (`data-your-name`).
|
|
83
|
+
- **A row that opens another list** runs `typeTrigger('@')`: it types the key
|
|
84
|
+
once the `/query` is gone.
|
|
85
|
+
- An open list is reported by `isSuggestionActive()`, so an editor that sends
|
|
86
|
+
on Enter does not send on the Enter that picked a row.
|
|
87
|
+
|
|
88
|
+
<Canvas of={ComposerTriggersStories.CodeAsABlock} />
|
|
89
|
+
|
|
90
|
+
## What it owns
|
|
91
|
+
|
|
92
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
93
|
+
|
|
94
|
+
## Keys
|
|
95
|
+
|
|
96
|
+
The lists are `SuggestionMenu`s, and their keys are its own. What the body
|
|
97
|
+
says, the tags it earns and sending stay the app's.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
| Key | Does |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| `@` after a space or at the line's start | Opens the people list |
|
|
103
|
+
| `!@` | Opens the urgent list |
|
|
104
|
+
| `[` | Opens the reference list |
|
|
105
|
+
| `/` after a space or at the line's start | Opens Format and the app's sections |
|
|
106
|
+
| ↑ ↓ | Move the highlight, wrapping at the ends |
|
|
107
|
+
| Enter | Inserts the highlighted row, or runs it |
|
|
108
|
+
| Escape | Closes the list |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
5
|
+
import { richTextClassName } from './RichText'
|
|
6
|
+
import {
|
|
7
|
+
CaptionedReference,
|
|
8
|
+
PersonMention,
|
|
9
|
+
ReferenceTrigger,
|
|
10
|
+
SlashCommands,
|
|
11
|
+
UrgentPersonMention,
|
|
12
|
+
formatSection,
|
|
13
|
+
typeTrigger,
|
|
14
|
+
type CaptionedItem,
|
|
15
|
+
type MentionPerson,
|
|
16
|
+
type SlashSection,
|
|
17
|
+
} from './ComposerTriggers'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The four composer triggers: `@` and `!@` offer a person, `[` offers
|
|
21
|
+
* something to reference, and `/` formats the line or inserts something.
|
|
22
|
+
* Type one of them in the box.
|
|
23
|
+
*/
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'Primitives/ComposerTriggers',
|
|
26
|
+
parameters: { layout: 'padded' },
|
|
27
|
+
} satisfies Meta
|
|
28
|
+
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj
|
|
31
|
+
|
|
32
|
+
const PEOPLE: MentionPerson[] = ['one', 'two', 'three', 'four', 'five', 'six', 'seven'].map((n, i) => ({
|
|
33
|
+
id: `person-${n}`,
|
|
34
|
+
name: `Person ${n}`,
|
|
35
|
+
description: i % 2 === 0 ? 'Label' : undefined,
|
|
36
|
+
pubkey: null,
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
const ITEMS: CaptionedItem[] = ['one', 'two', 'three'].map((n) => ({
|
|
40
|
+
id: `item-${n}`,
|
|
41
|
+
label: 'Person one',
|
|
42
|
+
snippet: `Item ${n}`,
|
|
43
|
+
uri: `nostr:item-${n}`,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
const INSERT: SlashSection = {
|
|
47
|
+
label: 'Insert',
|
|
48
|
+
commands: [
|
|
49
|
+
{ key: 'insert-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@'], run: typeTrigger('@') },
|
|
50
|
+
{ key: 'insert-[', row: { label: 'Reference', shortcut: '[' }, keywords: ['['], run: typeTrigger('[') },
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Composer({ code }: { code: 'inline' | 'block' }) {
|
|
55
|
+
const editor = useEditor({
|
|
56
|
+
extensions: [
|
|
57
|
+
StarterKit,
|
|
58
|
+
PersonMention.configure({ people: () => PEOPLE }),
|
|
59
|
+
UrgentPersonMention.configure({ people: () => PEOPLE }),
|
|
60
|
+
CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
|
|
61
|
+
ReferenceTrigger.configure({
|
|
62
|
+
items: () => ITEMS,
|
|
63
|
+
ariaLabel: 'Items',
|
|
64
|
+
sectionLabel: 'Items',
|
|
65
|
+
icon: <IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />,
|
|
66
|
+
}),
|
|
67
|
+
SlashCommands.configure({ sections: [formatSection(code), INSERT] }),
|
|
68
|
+
],
|
|
69
|
+
content: '<p>Type @, !@, [ or / after a space: </p>',
|
|
70
|
+
editorProps: { attributes: { class: richTextClassName('default', 'min-h-[80px] w-[480px] rounded-md border border-border-default p-3 outline-none') } },
|
|
71
|
+
})
|
|
72
|
+
return <EditorContent editor={editor} />
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Code as a mark inside the line. */
|
|
76
|
+
export const Default: Story = { render: () => <Composer code="inline" /> }
|
|
77
|
+
|
|
78
|
+
/** Code as a block of its own, for an editor of a block document. */
|
|
79
|
+
export const CodeAsABlock: Story = { render: () => <Composer code="block" /> }
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The composer triggers (CON-27, moved from Peek's `mention.tsx` and
|
|
4
|
+
* `slashCommands.tsx`): each opens its list at the caret, and a pick inserts
|
|
5
|
+
* the chip the body is written from.
|
|
6
|
+
*/
|
|
7
|
+
import { act } from 'react'
|
|
8
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { render, screen, cleanup, within } from '@testing-library/react'
|
|
10
|
+
import type { Editor } from '@tiptap/core'
|
|
11
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
12
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
13
|
+
import {
|
|
14
|
+
CaptionedReference,
|
|
15
|
+
PersonMention,
|
|
16
|
+
ReferenceTrigger,
|
|
17
|
+
SlashCommands,
|
|
18
|
+
UrgentPersonMention,
|
|
19
|
+
filterPeople,
|
|
20
|
+
filterReferences,
|
|
21
|
+
filterSlashSections,
|
|
22
|
+
formatSection,
|
|
23
|
+
typeTrigger,
|
|
24
|
+
urgentPubkeys,
|
|
25
|
+
type CaptionedItem,
|
|
26
|
+
type MentionPerson,
|
|
27
|
+
type SlashSection,
|
|
28
|
+
} from './ComposerTriggers'
|
|
29
|
+
|
|
30
|
+
const KEY = 'b'.repeat(64)
|
|
31
|
+
const PEOPLE: MentionPerson[] = [
|
|
32
|
+
{ id: 'ada', name: 'Ada Lovelace', description: 'Engineering', pubkey: KEY },
|
|
33
|
+
{ id: 'bea', name: 'Bea', pubkey: null },
|
|
34
|
+
...['Cy', 'Di', 'Ed', 'Flo', 'Gus'].map((name) => ({ id: name.toLowerCase(), name })),
|
|
35
|
+
]
|
|
36
|
+
const ITEMS: CaptionedItem[] = Array.from({ length: 8 }, (_, i) => ({
|
|
37
|
+
id: `${i}`.repeat(64),
|
|
38
|
+
label: i === 7 ? 'Bea' : 'Ada',
|
|
39
|
+
snippet: `line ${i}`,
|
|
40
|
+
uri: `nostr:nevent1example${i}`,
|
|
41
|
+
search: `**line** ${i}`,
|
|
42
|
+
}))
|
|
43
|
+
const APP_SECTION: SlashSection = {
|
|
44
|
+
label: 'Shortcuts',
|
|
45
|
+
commands: [{ key: 'shortcut-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@', 'Mention a person'], run: typeTrigger('@') }],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// An app's own name for the chip, as Peek's `messageMention`.
|
|
49
|
+
const Chip = CaptionedReference.extend({ name: 'messageMention' })
|
|
50
|
+
|
|
51
|
+
let editor: Editor | null = null
|
|
52
|
+
|
|
53
|
+
function Composer() {
|
|
54
|
+
const made = useEditor({
|
|
55
|
+
extensions: [
|
|
56
|
+
StarterKit,
|
|
57
|
+
PersonMention.configure({ people: () => PEOPLE }),
|
|
58
|
+
UrgentPersonMention.configure({ people: () => PEOPLE }),
|
|
59
|
+
Chip,
|
|
60
|
+
ReferenceTrigger.configure({ items: () => ITEMS, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' }),
|
|
61
|
+
SlashCommands.configure({ sections: [formatSection(), APP_SECTION] }),
|
|
62
|
+
],
|
|
63
|
+
})
|
|
64
|
+
editor = made
|
|
65
|
+
return <EditorContent editor={made} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function type(text: string) {
|
|
69
|
+
await act(async () => {
|
|
70
|
+
editor!.chain().focus().insertContent(text).run()
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function press(key: string) {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
editor!.view.someProp('handleKeyDown', (handle) => handle(editor!.view, new KeyboardEvent('keydown', { key })))
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The highlighted row also draws its Enter hint; the label is what is compared.
|
|
81
|
+
const options = () => within(screen.getByRole('listbox')).getAllByRole('option').map((one) => one.textContent?.replace('↩ Enter', ''))
|
|
82
|
+
const nodes = () => {
|
|
83
|
+
const found: { type: string; attrs: Record<string, unknown> }[] = []
|
|
84
|
+
editor!.state.doc.descendants((node) => {
|
|
85
|
+
if (node.isInline && !node.isText) found.push({ type: node.type.name, attrs: node.attrs })
|
|
86
|
+
})
|
|
87
|
+
return found
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it.
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
Element.prototype.scrollIntoView = vi.fn()
|
|
93
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
editor?.destroy()
|
|
98
|
+
editor = null
|
|
99
|
+
cleanup()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('@ and !@', () => {
|
|
103
|
+
it('lists the first six people, narrowed by name, and a pick inserts a chip with their key', async () => {
|
|
104
|
+
render(<Composer />)
|
|
105
|
+
await type('@')
|
|
106
|
+
expect(screen.getByRole('listbox', { name: 'People' })).toBeTruthy()
|
|
107
|
+
expect(options()).toHaveLength(6)
|
|
108
|
+
await type('ada')
|
|
109
|
+
expect(options()).toHaveLength(1)
|
|
110
|
+
await press('Enter')
|
|
111
|
+
expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }])
|
|
112
|
+
expect(editor!.getHTML()).toContain('data-mention="true"')
|
|
113
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('opens the urgent list on !@, and the document reports whose key it names', async () => {
|
|
117
|
+
render(<Composer />)
|
|
118
|
+
await type('!@')
|
|
119
|
+
expect(screen.getByRole('listbox', { name: 'Urgent mention' })).toBeTruthy()
|
|
120
|
+
await press('Enter')
|
|
121
|
+
expect(nodes()[0]).toEqual({ type: 'urgentMention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } })
|
|
122
|
+
expect(urgentPubkeys(editor!)).toEqual([KEY])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('keeps a person with no key, who has nothing to carry urgently', () => {
|
|
126
|
+
expect(filterPeople(PEOPLE, 'BE')).toEqual([PEOPLE[1]])
|
|
127
|
+
expect(filterPeople(PEOPLE, '')).toHaveLength(6)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('[', () => {
|
|
132
|
+
it('lists the last six references and inserts the app-named chip with its uri', async () => {
|
|
133
|
+
render(<Composer />)
|
|
134
|
+
await type('[')
|
|
135
|
+
expect(screen.getByRole('listbox', { name: 'Things' })).toBeTruthy()
|
|
136
|
+
expect(options()).toEqual(['Ada: line 2', 'Ada: line 3', 'Ada: line 4', 'Ada: line 5', 'Ada: line 6', 'Bea: line 7'])
|
|
137
|
+
await press('Enter')
|
|
138
|
+
expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[2].id, label: 'Ada', snippet: 'line 2', uri: ITEMS[2].uri } }])
|
|
139
|
+
expect(editor!.getHTML()).toContain('data-message-mention="true"')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('matches who said it, or the text it searches', () => {
|
|
143
|
+
expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id])
|
|
144
|
+
expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id])
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('a chip through HTML (copy and paste)', () => {
|
|
149
|
+
it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
|
|
150
|
+
render(<Composer />)
|
|
151
|
+
await act(async () => {
|
|
152
|
+
editor!.commands.setContent([
|
|
153
|
+
{
|
|
154
|
+
type: 'paragraph',
|
|
155
|
+
content: [
|
|
156
|
+
{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
|
|
157
|
+
{ type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
])
|
|
161
|
+
})
|
|
162
|
+
const html = editor!.getHTML()
|
|
163
|
+
await act(async () => {
|
|
164
|
+
editor!.commands.setContent(html)
|
|
165
|
+
})
|
|
166
|
+
expect(nodes()).toEqual([
|
|
167
|
+
{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
|
|
168
|
+
{ type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
|
|
169
|
+
])
|
|
170
|
+
await act(async () => {
|
|
171
|
+
editor!.commands.setContent(
|
|
172
|
+
'<p><span data-urgent-mention="true" data-id="x" data-label="Ada" data-pubkey="not-a-key">@Ada</span>' +
|
|
173
|
+
'<span data-message-mention="true" data-id="y" data-label="Ada" data-uri="javascript:alert(1)">Ada</span></p>',
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
const [person, reference] = nodes()
|
|
177
|
+
expect([person.type, person.attrs.label, person.attrs.pubkey]).toEqual(['urgentMention', 'Ada', null])
|
|
178
|
+
expect([reference.type, reference.attrs.uri]).toEqual(['messageMention', null])
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('/', () => {
|
|
183
|
+
it('opens Format first, then the app’s sections, and runs the chosen row', async () => {
|
|
184
|
+
render(<Composer />)
|
|
185
|
+
await type('/')
|
|
186
|
+
expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
|
|
187
|
+
expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code`', 'Bulleted list-', 'Numbered list1.', 'Mention@'])
|
|
188
|
+
await type('head')
|
|
189
|
+
await press('Enter')
|
|
190
|
+
expect(editor!.getJSON().content?.[0]).toEqual({ type: 'heading', attrs: { level: 1 } })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('narrows on a label, an id or a keyword, and keeps an emptied section', () => {
|
|
194
|
+
const sections = [formatSection(), APP_SECTION]
|
|
195
|
+
const labels = (q: string) => filterSlashSections(sections, q).map((s) => s.commands.map((c) => c.row.label))
|
|
196
|
+
expect(labels('list')).toEqual([['Bulleted list', 'Numbered list'], []])
|
|
197
|
+
expect(labels('mention a person')).toEqual([[], ['Mention']])
|
|
198
|
+
expect(labels('HEAD')).toEqual([['Heading', 'Subheading'], []])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('makes code a block when the app asks for one', async () => {
|
|
202
|
+
render(<Composer />)
|
|
203
|
+
const code = formatSection('block').commands.find((c) => c.key === 'format-code')!
|
|
204
|
+
await act(async () => code.run(editor!))
|
|
205
|
+
expect(editor!.getJSON().content?.[0].type).toBe('codeBlock')
|
|
206
|
+
})
|
|
207
|
+
})
|