@estiva-app/ui 0.65.3 → 0.66.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/RichText.d.ts +13 -1
- package/dist/RichText.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -1
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +198 -7
- package/dist/editor.js.map +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -9
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/registry.json +84 -2
- 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/RichText.mdx +12 -0
- package/src/RichText.stories.tsx +50 -0
- package/src/RichText.test.tsx +56 -0
- package/src/RichText.tsx +81 -9
- package/src/editor.ts +3 -0
- package/src/index.ts +1 -1
- package/src/registry/registry.test.ts +4 -4
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)
|
package/src/RichText.mdx
CHANGED
|
@@ -58,6 +58,14 @@ import { InlineChip, RichText } from '@estiva-app/ui'
|
|
|
58
58
|
sideways.
|
|
59
59
|
- Each block carries its `id` as `data-block-id`, for an app that points at
|
|
60
60
|
one block.
|
|
61
|
+
- A list item with `checked` is a checklist item, drawn with a box where its
|
|
62
|
+
bullet or number was; a ticked one's words, links and code are grey and struck
|
|
63
|
+
through. It still counts in a numbered list. Give `onTick` where the reader
|
|
64
|
+
may change the text and the box ticks with one click, handing you the block
|
|
65
|
+
id and the new state to save; without it the box is a picture, and says done
|
|
66
|
+
or not to a screen reader. Each box is named by its item's words. A quote with
|
|
67
|
+
a `tone` is a callout, on its tone's wash; a tone it does not know is drawn as
|
|
68
|
+
`note` (`calloutLook`).
|
|
61
69
|
- **An editor wears the same look.** Put `richTextClassName()` (or
|
|
62
70
|
`richTextClassName('small')`) on the editor's root, so what is being written
|
|
63
71
|
looks exactly like what is read. It styles the elements inside the box,
|
|
@@ -70,6 +78,10 @@ import { richTextClassName } from '@estiva-app/ui'
|
|
|
70
78
|
const editorAttributes = { class: richTextClassName('default', 'outline-none') }
|
|
71
79
|
```
|
|
72
80
|
|
|
81
|
+
<Canvas of={RichTextStories.ChecklistAndCallouts} />
|
|
82
|
+
|
|
83
|
+
<Canvas of={RichTextStories.NumberedAndNested} />
|
|
84
|
+
|
|
73
85
|
## What it owns
|
|
74
86
|
|
|
75
87
|
Nothing. It only draws. Clicks and keys are yours.
|
package/src/RichText.stories.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
3
|
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
4
|
import { RichText, type RichTextBlock, type RichTextRun } from './RichText'
|
|
@@ -140,3 +141,52 @@ export const CodeInALanguage: Story = {
|
|
|
140
141
|
],
|
|
141
142
|
},
|
|
142
143
|
}
|
|
144
|
+
|
|
145
|
+
const task = (text: string, checked?: boolean): RichTextBlock => ({ type: 'listItem', checked, inline: [t(text)] })
|
|
146
|
+
const callout = (tone: string, text: string): RichTextBlock => ({ type: 'blockquote', tone, inline: [t(text)] })
|
|
147
|
+
|
|
148
|
+
/** A checklist, among a plain list's items, and each callout tone after a plain quote (RIC-19). The boxes tick: `onTick` is given. */
|
|
149
|
+
export const ChecklistAndCallouts: Story = {
|
|
150
|
+
render: (args) => {
|
|
151
|
+
const [ticked, setTicked] = useState<Record<string, boolean>>({ c1: true })
|
|
152
|
+
const list = ['c1', 'c2', 'c3'].map((id, i) => ({ ...task(['Item one', 'Item two', 'Item three'][i], ticked[id] ?? false), id }))
|
|
153
|
+
return (
|
|
154
|
+
<RichText
|
|
155
|
+
{...args}
|
|
156
|
+
onTick={(id, checked) => setTicked((was) => ({ ...was, [id]: checked }))}
|
|
157
|
+
blocks={[
|
|
158
|
+
{ type: 'bulletList', children: [...list, task('A plain item keeps its bullet')] },
|
|
159
|
+
{ type: 'blockquote', inline: [t('A plain quote.')] },
|
|
160
|
+
callout('note', 'Note, the default callout. A tone this part does not know is drawn as this one.'),
|
|
161
|
+
callout('tip', 'Tip.'),
|
|
162
|
+
callout('important', 'Important.'),
|
|
163
|
+
callout('warning', 'Warning.'),
|
|
164
|
+
callout('caution', 'Caution.'),
|
|
165
|
+
]}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ticked = (text: string, checked: boolean | undefined, indent = 0): RichTextBlock => ({ ...task(text, checked), ...(indent ? { indent } : {}) })
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A numbered list counts its checklist items: the plain item after two of them
|
|
175
|
+
* is 3. A sub-item sits under a ticked item, not struck through (Katerina,
|
|
176
|
+
* review of #170), inside the ticked item's words as the editor draws it.
|
|
177
|
+
*/
|
|
178
|
+
export const NumberedAndNested: Story = {
|
|
179
|
+
args: {
|
|
180
|
+
blocks: [
|
|
181
|
+
{ type: 'orderedList', children: [ticked('Item one', true), ticked('Item two', false), ticked('Item three, a plain item', undefined)] },
|
|
182
|
+
{
|
|
183
|
+
type: 'bulletList',
|
|
184
|
+
children: [
|
|
185
|
+
ticked('Item one', true),
|
|
186
|
+
ticked('A sub-item under it', false, 1),
|
|
187
|
+
{ type: 'listItem', checked: true, inline: [t('A ticked item with '), t('[a link](https://example.com)'), t(' and '), t('code', 'code')] },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
}
|
package/src/RichText.test.tsx
CHANGED
|
@@ -146,6 +146,54 @@ describe('RichText', () => {
|
|
|
146
146
|
}
|
|
147
147
|
expect(richTextClassName('default', 'outline-none')).toContain('outline-none')
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
const CHECKLIST: RichTextBlock[] = [
|
|
151
|
+
{
|
|
152
|
+
type: 'bulletList',
|
|
153
|
+
id: 'l1',
|
|
154
|
+
children: [
|
|
155
|
+
{ type: 'listItem', id: 'i1', checked: true, inline: [{ text: 'ship', marks: [] }] },
|
|
156
|
+
{ type: 'listItem', id: 'i2', checked: false, inline: [{ text: 'verify', marks: [] }] },
|
|
157
|
+
{ type: 'listItem', id: 'i3', inline: [{ text: 'plain', marks: [] }] },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{ type: 'blockquote', id: 'q1', tone: 'warning', inline: [{ text: 'careful', marks: [] }] },
|
|
161
|
+
{ type: 'blockquote', id: 'q2', tone: 'danger', inline: [{ text: 'unknown', marks: [] }] },
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
it('draws a checklist item with its box, and a plain item without one (RIC-19)', () => {
|
|
165
|
+
const { container } = render(<RichText blocks={CHECKLIST} />)
|
|
166
|
+
expect([...container.querySelectorAll('li')].map((li) => li.getAttribute('data-checked'))).toEqual(['true', 'false', null])
|
|
167
|
+
// Without onTick the box is a picture: nothing to tab to or click, but a screen reader still hears done or not, by the item's words.
|
|
168
|
+
const boxes = screen.getAllByRole('checkbox')
|
|
169
|
+
expect(boxes.map((b) => [b.getAttribute('aria-label'), b.getAttribute('aria-checked'), b.getAttribute('aria-readonly'), b.tabIndex])).toEqual([
|
|
170
|
+
['ship', 'true', 'true', -1],
|
|
171
|
+
['verify', 'false', 'true', -1],
|
|
172
|
+
])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('ticks an item with one click when the app may change the text, each box named by its item', () => {
|
|
176
|
+
const onTick = vi.fn()
|
|
177
|
+
render(<RichText blocks={CHECKLIST} onTick={onTick} />)
|
|
178
|
+
const boxes = [screen.getByRole('checkbox', { name: 'ship' }), screen.getByRole('checkbox', { name: 'verify' })]
|
|
179
|
+
expect(boxes.map((b) => b.getAttribute('aria-checked'))).toEqual(['true', 'false'])
|
|
180
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(2)
|
|
181
|
+
fireEvent.click(boxes[1])
|
|
182
|
+
expect(onTick).toHaveBeenCalledWith('i2', true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('draws a callout in its tone, and one it does not know as a note', () => {
|
|
186
|
+
const { container } = render(<RichText blocks={CHECKLIST} />)
|
|
187
|
+
expect([...container.querySelectorAll('blockquote')].map((q) => [q.getAttribute('data-tone'), q.getAttribute('data-callout')])).toEqual([
|
|
188
|
+
['warning', 'warning'],
|
|
189
|
+
['danger', 'note'],
|
|
190
|
+
])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('draws an empty tone as a note, as the editor does (review of #170)', () => {
|
|
194
|
+
const { container } = render(<RichText blocks={[{ type: 'blockquote', tone: '', inline: [t('empty')] }]} />)
|
|
195
|
+
expect(container.querySelector('blockquote')!.getAttribute('data-callout')).toBe('note')
|
|
196
|
+
})
|
|
149
197
|
})
|
|
150
198
|
|
|
151
199
|
describe('an indented list item — MAN-13', () => {
|
|
@@ -181,4 +229,12 @@ describe('an indented list item — MAN-13', () => {
|
|
|
181
229
|
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [item('a', 'one'), { ...item('b', 'two'), indent: -1 }, { ...item('c', 'three'), indent: Number.NaN }]}]} />)
|
|
182
230
|
expect([...container.querySelector('ul')!.children].map((li) => li.getAttribute('data-block-id'))).toEqual(['a', 'b', 'c'])
|
|
183
231
|
})
|
|
232
|
+
|
|
233
|
+
it('draws an item indented under a checklist item inside its words, after them (RIC-19)', () => {
|
|
234
|
+
const { container } = render(<RichText blocks={[{ type: 'bulletList', children: [{ ...item('a', 'done'), checked: true }, item('b', 'under it', 1)] }]} />)
|
|
235
|
+
const sub = container.querySelector('[data-block-id="b"]')!
|
|
236
|
+
expect(sub.closest('li[data-checked]')?.getAttribute('data-block-id')).toBe('a')
|
|
237
|
+
expect(sub.parentElement!.parentElement!.matches('li[data-checked="true"] > div')).toBe(true)
|
|
238
|
+
expect(sub.hasAttribute('data-checked')).toBe(false)
|
|
239
|
+
})
|
|
184
240
|
})
|
package/src/RichText.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { Checkbox } from './Checkbox'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
import { Link } from './Link'
|
|
4
5
|
import { codeColours } from './codeColours'
|
|
@@ -51,6 +52,10 @@ export interface RichTextBlock {
|
|
|
51
52
|
start?: number
|
|
52
53
|
/** How far a list item is indented under the items above it. A list's items stay flat; this draws them nested. */
|
|
53
54
|
indent?: number
|
|
55
|
+
/** A checklist item's tick: `true` or `false`, absent on a plain item. */
|
|
56
|
+
checked?: boolean
|
|
57
|
+
/** A callout's tone (`note`, `tip`, `important`, `warning`, `caution`), absent on a plain quote. */
|
|
58
|
+
tone?: string
|
|
54
59
|
attrs?: Record<string, unknown>
|
|
55
60
|
inline?: readonly RichTextRun[]
|
|
56
61
|
children?: readonly RichTextBlock[]
|
|
@@ -79,6 +84,12 @@ export interface RichTextProps {
|
|
|
79
84
|
* `undefined` to leave it to the part, which draws its words.
|
|
80
85
|
*/
|
|
81
86
|
renderBlock?: (block: RichTextBlock) => ReactNode | undefined
|
|
87
|
+
/**
|
|
88
|
+
* Ticks or unticks a checklist item, by its block id: its box is then a
|
|
89
|
+
* control, one click where the text is read. Without it the box is a picture
|
|
90
|
+
* of the state — for someone who may not change the text.
|
|
91
|
+
*/
|
|
92
|
+
onTick?: (blockId: string, checked: boolean) => void
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/**
|
|
@@ -97,6 +108,13 @@ const RICH_TEXT_CLASSES = {
|
|
|
97
108
|
/* The line in the muted text grey, not the border grey: a border grey was 1.3 : 1 in Leaf (Katerina, 9 October). */
|
|
98
109
|
// eslint-disable-next-line token-spacing/no-restricted-classes -- @estiva-escape: the quote line is Peek's, 3px, between the ramp's 2 and 4
|
|
99
110
|
quote: '[&_blockquote>p]:m-0 [&_blockquote]:border-l-[3px] [&_blockquote]:border-text-muted [&_blockquote]:pl-2.5',
|
|
111
|
+
/*
|
|
112
|
+
A checklist item: the box where the bullet was, in the list's gutter, and the words where they were (Katerina, review of #170). The item stays a list item, so a numbered list still counts it; the box sits beside the row and adds nothing to its height, the same in the editor and here.
|
|
113
|
+
A ticked item's words grey and struck through, its links and code too (Katerina); the lists under it are not.
|
|
114
|
+
*/
|
|
115
|
+
checklist: '[&_li[data-checked]]:relative [&_li[data-checked]]:list-none [&_[data-checklist-box]]:absolute [&_[data-checklist-box]]:-left-5 [&_[data-checklist-box]]:top-0.5 [&_[data-checklist-box]]:flex [&_li[data-checked=true]>div>p]:text-text-muted [&_li[data-checked=true]>div>p]:line-through [&_li[data-checked=true]>div>p_a]:text-text-muted [&_li[data-checked=true]>div>p_a]:decoration-text-muted [&_li[data-checked=true]>div>p_code]:border-transparent [&_li[data-checked=true]>div>p_code]:bg-transparent [&_li[data-checked=true]>div>p_code]:text-text-muted',
|
|
116
|
+
/* A callout: the quote's line in its tone's colour, on its tone's wash (RIC-19, to Katerina's review). `data-callout` is the tone as drawn — an unknown one is `note`. */
|
|
117
|
+
callout: '[&_blockquote[data-callout]]:rounded-sm [&_blockquote[data-callout]]:py-2 [&_blockquote[data-callout]]:pr-3 [&_blockquote[data-callout=note]]:border-info-default [&_blockquote[data-callout=note]]:bg-info-muted [&_blockquote[data-callout=tip]]:border-success-default [&_blockquote[data-callout=tip]]:bg-success-muted [&_blockquote[data-callout=important]]:border-accent-primary [&_blockquote[data-callout=important]]:bg-accent-muted [&_blockquote[data-callout=warning]]:border-warning-default [&_blockquote[data-callout=warning]]:bg-warning-muted [&_blockquote[data-callout=caution]]:border-error-default [&_blockquote[data-callout=caution]]:bg-error-muted',
|
|
100
118
|
/* Code in a sentence: the code fill and a thin border, in the heading colour (Katerina, 9 October: option B; the amber of 0.64.0 was too much). */
|
|
101
119
|
// eslint-disable-next-line token-values/no-restricted-classes -- @estiva-escape: code is 0.85 of the text around it, so it scales with a heading and with `small`; a named size is fixed
|
|
102
120
|
code: '[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:bg-bg-code [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:border [&_:not(pre)>code]:border-border-default [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-text-primary',
|
|
@@ -121,8 +139,8 @@ const RICH_TEXT_CLASSES = {
|
|
|
121
139
|
* being written looks the same as what is read. Put it on the editor's root.
|
|
122
140
|
*/
|
|
123
141
|
export function richTextClassName(size: RichTextSize = 'default', className?: string) {
|
|
124
|
-
const { base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
|
|
125
|
-
return cn(base, headings, lists, quote, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
|
|
142
|
+
const { base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks } = RICH_TEXT_CLASSES
|
|
143
|
+
return cn(base, headings, lists, quote, checklist, callout, code, codeBlock, syntax, divider, table, marks, RICH_TEXT_CLASSES[size], className)
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
/**
|
|
@@ -167,6 +185,16 @@ interface Draw {
|
|
|
167
185
|
renderText: (text: string) => ReactNode
|
|
168
186
|
renderReference: (reference: string, run: RichTextRun) => ReactNode
|
|
169
187
|
renderBlock?: (block: RichTextBlock) => ReactNode | undefined
|
|
188
|
+
onTick?: (blockId: string, checked: boolean) => void
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** SPEC §13.3's callout tones — GitHub's alert kinds. */
|
|
192
|
+
const CALLOUT_TONES: ReadonlySet<string> = new Set(['note', 'tip', 'important', 'warning', 'caution'])
|
|
193
|
+
|
|
194
|
+
/** The tone a callout is drawn in: its own, or `note` for one this part does not know (§13.3). */
|
|
195
|
+
export function calloutLook(tone: string): string {
|
|
196
|
+
const named = tone.toLowerCase()
|
|
197
|
+
return CALLOUT_TONES.has(named) ? named : 'note'
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
function Runs({ runs, draw }: { runs?: readonly RichTextRun[]; draw: Draw }) {
|
|
@@ -239,6 +267,43 @@ function Table({ block, draw }: { block: RichTextBlock; draw: Draw }) {
|
|
|
239
267
|
)
|
|
240
268
|
}
|
|
241
269
|
|
|
270
|
+
/** An item's words, for its box's name: a box says which item it ticks (Katerina, review of #170). */
|
|
271
|
+
const wordsOf = (item: RichTextBlock) => (item.inline ?? []).map((run) => run.text).join('').trim()
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One list item. A checklist item takes the editor's shape (`Checklist`): the
|
|
275
|
+
* box, then the words in a `div`, and `below` — the items indented under it —
|
|
276
|
+
* inside that `div` after them, as the editor draws them.
|
|
277
|
+
*/
|
|
278
|
+
function ListItem({ item, draw, below }: { item: RichTextBlock; draw: Draw; below?: ReactNode }) {
|
|
279
|
+
if (typeof item.checked !== 'boolean') {
|
|
280
|
+
return (
|
|
281
|
+
<li data-block-id={item.id}>
|
|
282
|
+
<Runs runs={item.inline} draw={draw} />
|
|
283
|
+
{below}
|
|
284
|
+
</li>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
const { onTick } = draw
|
|
288
|
+
const id = item.id
|
|
289
|
+
const name = wordsOf(item) || 'Item'
|
|
290
|
+
const control = onTick && id ? (next: boolean) => onTick(id, next) : undefined
|
|
291
|
+
return (
|
|
292
|
+
<li data-block-id={id} data-checked={String(item.checked)}>
|
|
293
|
+
{/* Without `onTick` the box is a picture, so the box around it says done or not: a strike-through is not read out. */}
|
|
294
|
+
<span data-checklist-box="" {...(control ? {} : { role: 'checkbox', 'aria-checked': item.checked, 'aria-readonly': true, 'aria-label': name })}>
|
|
295
|
+
<Checkbox checked={item.checked} onChange={control} aria-label={name} />
|
|
296
|
+
</span>
|
|
297
|
+
<div>
|
|
298
|
+
<p>
|
|
299
|
+
<Runs runs={item.inline} draw={draw} />
|
|
300
|
+
</p>
|
|
301
|
+
{below}
|
|
302
|
+
</div>
|
|
303
|
+
</li>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
242
307
|
interface NestedItem {
|
|
243
308
|
item: RichTextBlock
|
|
244
309
|
key: number
|
|
@@ -281,11 +346,13 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
|
|
|
281
346
|
const ordered = block.type === 'orderedList'
|
|
282
347
|
const items = (nodes: readonly NestedItem[]): ReactNode[] =>
|
|
283
348
|
nodes.map(({ item, key, below }) => (
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
{
|
|
287
|
-
{
|
|
288
|
-
|
|
349
|
+
<ListItem
|
|
350
|
+
key={item.id ?? key}
|
|
351
|
+
item={item}
|
|
352
|
+
draw={draw}
|
|
353
|
+
// The list an indented item sits in is drawn, not stored: it has no id, and the type of the list around it.
|
|
354
|
+
below={below.length > 0 && (ordered ? <ol>{items(below)}</ol> : <ul>{items(below)}</ul>)}
|
|
355
|
+
/>
|
|
289
356
|
))
|
|
290
357
|
const top = items(nestItems(block.children ?? []))
|
|
291
358
|
return ordered
|
|
@@ -293,7 +360,11 @@ function Block({ block, draw }: { block: RichTextBlock; draw: Draw }): ReactNode
|
|
|
293
360
|
: <ul data-block-id={block.id}>{top}</ul>
|
|
294
361
|
}
|
|
295
362
|
case 'blockquote':
|
|
296
|
-
return
|
|
363
|
+
return (
|
|
364
|
+
<blockquote data-block-id={block.id} data-tone={block.tone} data-callout={typeof block.tone === 'string' ? calloutLook(block.tone) : undefined}>
|
|
365
|
+
{runs}
|
|
366
|
+
</blockquote>
|
|
367
|
+
)
|
|
297
368
|
case 'codeBlock':
|
|
298
369
|
// Literal: no marks, no links, no chips. A long line wraps; nothing scrolls sideways.
|
|
299
370
|
// In a language highlight.js knows, each word takes its colour; in any other, the text as it is.
|
|
@@ -329,9 +400,10 @@ export function RichText({
|
|
|
329
400
|
renderText = (text) => text,
|
|
330
401
|
renderReference = (_reference, run) => run.text,
|
|
331
402
|
renderBlock,
|
|
403
|
+
onTick,
|
|
332
404
|
}: RichTextProps) {
|
|
333
405
|
if (blocks.length === 0) return null
|
|
334
|
-
const draw: Draw = { renderText, renderReference, renderBlock }
|
|
406
|
+
const draw: Draw = { renderText, renderReference, renderBlock, onTick }
|
|
335
407
|
return (
|
|
336
408
|
<div data-rich-text className={richTextClassName(size)}>
|
|
337
409
|
{blocks.map((block, i) => <Block key={block.id ?? i} block={block} draw={draw} />)}
|
package/src/editor.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -52,7 +52,7 @@ export {
|
|
|
52
52
|
} from './InlineChip'
|
|
53
53
|
export { Lightbox, type LightboxProps } from './Lightbox'
|
|
54
54
|
export { Link, type LinkProps, type LinkVariant } from './Link'
|
|
55
|
-
export { RichText, richTextClassName, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
|
|
55
|
+
export { RichText, richTextClassName, calloutLook, type RichTextProps, type RichTextBlock, type RichTextRun, type RichTextSize } from './RichText'
|
|
56
56
|
export { IconButton, type IconButtonProps, type IconButtonVariant } from './IconButton'
|
|
57
57
|
export { Kbd, type KbdProps } from './Kbd'
|
|
58
58
|
export { KindIcon, thingKindOf, type KindIconProps, type ThingKind } from './KindIcon'
|
|
@@ -26,7 +26,7 @@ import { SCHEMA_VERSION, validateRegistry, type Registry, type RegistryEntry } f
|
|
|
26
26
|
const root = process.cwd()
|
|
27
27
|
const registry = buildRegistry({ root })
|
|
28
28
|
/** Editor extensions: named like components by the catalogue, and not React components. */
|
|
29
|
-
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours']
|
|
29
|
+
const EDITOR_EXTENSIONS = ['KeptSelection', 'BlockId', 'UnknownBlock', 'ReferenceNode', 'AttachmentNode', 'PersonMention', 'UrgentPersonMention', 'CaptionedReference', 'ReferenceTrigger', 'SlashCommands', 'PastedImages', 'CodeBlockColours', 'Checklist', 'Callout']
|
|
30
30
|
const entry = (name: string): RegistryEntry => {
|
|
31
31
|
const found = registry.entries.find((candidate) => candidate.name === name)
|
|
32
32
|
if (!found) throw new Error(`no entry for ${name}`)
|
|
@@ -48,20 +48,20 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 144 names over 76 files: 109 components, 34 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, MembersPill and MembersDialog, moved from Peek (SHI-31), on 6 October, ShowMore on 7 October, AppProvider on 8 October, and KindIcon, thingKindOf and PastedImages with its two helpers, moved from Peek, on 8 October; CodeBlockColours, the coloured code block of the editor, on 9 October; Checklist, Callout, isChecklistTick and calloutLook (RIC-19) on 13 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's, BlockNodes' and
|
|
55
55
|
// ComposerTriggers' pages.
|
|
56
56
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
57
|
-
expect(kinds).toEqual({ component:
|
|
57
|
+
expect(kinds).toEqual({ component: 109, helper: 34, hook: 1 })
|
|
58
58
|
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(76)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
62
62
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
63
63
|
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(72)
|
|
64
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
64
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(72)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
67
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|