@advui/editor 0.5.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.
Files changed (28) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +35 -0
  3. package/dist/components/rich-text-editor/RichTextContent.d.ts +10 -0
  4. package/dist/components/rich-text-editor/RichTextContent.d.ts.map +1 -0
  5. package/dist/components/rich-text-editor/RichTextContent.js +49 -0
  6. package/dist/components/rich-text-editor/RichTextContent.js.map +1 -0
  7. package/dist/components/rich-text-editor/RichTextEditor.d.ts +44 -0
  8. package/dist/components/rich-text-editor/RichTextEditor.d.ts.map +1 -0
  9. package/dist/components/rich-text-editor/RichTextEditor.js +144 -0
  10. package/dist/components/rich-text-editor/RichTextEditor.js.map +1 -0
  11. package/dist/components/rich-text-editor/index.d.ts +4 -0
  12. package/dist/components/rich-text-editor/index.d.ts.map +1 -0
  13. package/dist/components/rich-text-editor/index.js +4 -0
  14. package/dist/components/rich-text-editor/index.js.map +1 -0
  15. package/dist/components/rich-text-editor/markdown.d.ts +56 -0
  16. package/dist/components/rich-text-editor/markdown.d.ts.map +1 -0
  17. package/dist/components/rich-text-editor/markdown.js +203 -0
  18. package/dist/components/rich-text-editor/markdown.js.map +1 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -0
  23. package/package.json +85 -0
  24. package/src/components/rich-text-editor/RichTextContent.tsx +119 -0
  25. package/src/components/rich-text-editor/RichTextEditor.tsx +330 -0
  26. package/src/components/rich-text-editor/index.ts +14 -0
  27. package/src/components/rich-text-editor/markdown.ts +244 -0
  28. package/src/index.ts +2 -0
@@ -0,0 +1,330 @@
1
+ import {
2
+ BoldIcon,
3
+ CodeIcon,
4
+ HeadingIcon,
5
+ ItalicIcon,
6
+ LinkIcon,
7
+ ListIcon,
8
+ ListOrderedIcon,
9
+ QuoteIcon,
10
+ StrikethroughIcon,
11
+ } from '@advui/icons'
12
+ import { type ReactElement, useId, useLayoutEffect, useRef, useState } from 'react'
13
+ import { type GetProps, type TamaguiElement, View, XStack, isWeb } from 'tamagui'
14
+ import {
15
+ Button,
16
+ IconButton,
17
+ Text,
18
+ Textarea,
19
+ fieldBoxStyle,
20
+ useControllableState,
21
+ } from '@advui/core'
22
+ import { RichTextContent } from './RichTextContent'
23
+ import { type RichTextTool, type TextSelection, applyFormat, shortcuts } from './markdown'
24
+
25
+ export interface RichTextEditorLabels {
26
+ toolbar: string
27
+ tools: Record<RichTextTool, string>
28
+ preview: string
29
+ /** Shown in the preview when there is nothing to show. */
30
+ empty: string
31
+ /** Character count with `maxLength`: "120 of 500 characters". */
32
+ count: (length: number, max: number) => string
33
+ }
34
+
35
+ const defaultLabels: RichTextEditorLabels = {
36
+ toolbar: 'Formatting',
37
+ tools: {
38
+ bold: 'Bold',
39
+ italic: 'Italic',
40
+ strikethrough: 'Strikethrough',
41
+ code: 'Code',
42
+ link: 'Link',
43
+ heading: 'Heading',
44
+ bulletList: 'Bulleted list',
45
+ orderedList: 'Numbered list',
46
+ quote: 'Quote',
47
+ },
48
+ preview: 'Preview',
49
+ empty: 'Nothing to preview.',
50
+ count: (length, max) => `${length} of ${max} characters`,
51
+ }
52
+
53
+ const icons: Record<RichTextTool, ReactElement> = {
54
+ bold: <BoldIcon />,
55
+ italic: <ItalicIcon />,
56
+ strikethrough: <StrikethroughIcon />,
57
+ code: <CodeIcon />,
58
+ link: <LinkIcon />,
59
+ heading: <HeadingIcon />,
60
+ bulletList: <ListIcon />,
61
+ orderedList: <ListOrderedIcon />,
62
+ quote: <QuoteIcon />,
63
+ }
64
+
65
+ const shortcutHints: Partial<Record<RichTextTool, string>> = { bold: 'B', italic: 'I', link: 'K' }
66
+
67
+ export const defaultRichTextTools: RichTextTool[] = [
68
+ 'bold',
69
+ 'italic',
70
+ 'strikethrough',
71
+ 'heading',
72
+ 'bulletList',
73
+ 'orderedList',
74
+ 'quote',
75
+ 'link',
76
+ 'code',
77
+ ]
78
+
79
+ export interface RichTextEditorProps {
80
+ /** Markdown (controlled). */
81
+ value?: string
82
+ defaultValue?: string
83
+ onValueChange?: (value: string) => void
84
+ placeholder?: string
85
+ /** Toolbar buttons, in order. Default: all nine. */
86
+ tools?: RichTextTool[]
87
+ /** Minimum height of the text area. Default: `$32`. */
88
+ minHeight?: GetProps<typeof View>['minHeight']
89
+ maxLength?: number
90
+ disabled?: boolean
91
+ invalid?: boolean
92
+ id?: string
93
+ 'aria-label'?: string
94
+ 'aria-labelledby'?: string
95
+ 'aria-describedby'?: string
96
+ 'aria-required'?: boolean
97
+ /** Native: help read after the name (Form Field sets it). */
98
+ accessibilityHint?: string
99
+ /** Toolbar, tool and preview names, for translation. */
100
+ labels?: Partial<Omit<RichTextEditorLabels, 'tools'>> & {
101
+ tools?: Partial<RichTextEditorLabels['tools']>
102
+ }
103
+ }
104
+
105
+ type Editable = TamaguiElement & {
106
+ selectionStart?: number
107
+ selectionEnd?: number
108
+ setSelectionRange?: (start: number, end: number) => void
109
+ setSelection?: (start: number, end: number) => void
110
+ focus?: () => void
111
+ }
112
+
113
+ /**
114
+ * A Markdown editor with a formatting toolbar and a preview. It stores plain
115
+ * Markdown, so the text is safe to save and shows the same on every platform
116
+ * with Rich Text Content.
117
+ */
118
+ export function RichTextEditor({
119
+ value: valueProp,
120
+ defaultValue = '',
121
+ onValueChange,
122
+ placeholder,
123
+ tools = defaultRichTextTools,
124
+ minHeight = '$32',
125
+ maxLength,
126
+ disabled = false,
127
+ invalid = false,
128
+ id,
129
+ 'aria-label': ariaLabel,
130
+ 'aria-labelledby': ariaLabelledBy,
131
+ 'aria-describedby': ariaDescribedBy,
132
+ 'aria-required': ariaRequired,
133
+ accessibilityHint,
134
+ labels: labelsProp,
135
+ }: RichTextEditorProps) {
136
+ const labels = {
137
+ ...defaultLabels,
138
+ ...labelsProp,
139
+ tools: { ...defaultLabels.tools, ...labelsProp?.tools },
140
+ }
141
+ const [value, setValue] = useControllableState({
142
+ value: valueProp,
143
+ defaultValue,
144
+ onChange: onValueChange,
145
+ })
146
+ const [preview, setPreview] = useState(false)
147
+ const [activeTool, setActiveTool] = useState(0)
148
+ const field = useRef<Editable | null>(null)
149
+ // Native reports the caret as it moves; web reads it from the textarea.
150
+ const selection = useRef<TextSelection>({ start: value.length, end: value.length })
151
+ const pending = useRef<TextSelection | null>(null)
152
+ const generatedId = useId()
153
+ const fieldId = id ?? `${generatedId}-field`
154
+ const countId = `${generatedId}-count`
155
+
156
+ // Put the selection where the tool left it, once the new text is in.
157
+ useLayoutEffect(() => {
158
+ const next = pending.current
159
+ const node = field.current
160
+ if (!next || !node) return
161
+ pending.current = null
162
+ node.focus?.()
163
+ if (node.setSelectionRange) node.setSelectionRange(next.start, next.end)
164
+ else node.setSelection?.(next.start, next.end)
165
+ selection.current = next
166
+ })
167
+
168
+ const apply = (tool: RichTextTool) => {
169
+ const node = field.current
170
+ const current =
171
+ isWeb && node?.selectionStart != null
172
+ ? { start: node.selectionStart, end: node.selectionEnd ?? node.selectionStart }
173
+ : selection.current
174
+ const result = applyFormat(value, current, tool)
175
+ if (maxLength != null && result.value.length > maxLength) return
176
+ pending.current = result.selection
177
+ setValue(result.value)
178
+ }
179
+
180
+ const onFieldKeyDown = (event: {
181
+ key: string
182
+ metaKey?: boolean
183
+ ctrlKey?: boolean
184
+ shiftKey?: boolean
185
+ altKey?: boolean
186
+ preventDefault: () => void
187
+ }) => {
188
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
189
+ const tool = shortcuts[event.key.toLowerCase()]
190
+ if (tool && tools.includes(tool)) {
191
+ event.preventDefault()
192
+ apply(tool)
193
+ }
194
+ }
195
+
196
+ // One tab stop for the toolbar; the arrow keys move along it (WAI-ARIA toolbar).
197
+ const onToolbarKeyDown = (event: { key: string; preventDefault: () => void }) => {
198
+ const last = tools.length - 1
199
+ const moves: Record<string, number> = {
200
+ ArrowRight: activeTool >= last ? 0 : activeTool + 1,
201
+ ArrowLeft: activeTool <= 0 ? last : activeTool - 1,
202
+ Home: 0,
203
+ End: last,
204
+ }
205
+ if (!(event.key in moves)) return
206
+ event.preventDefault()
207
+ const next = moves[event.key]!
208
+ setActiveTool(next)
209
+ document.getElementById(`${generatedId}-tool-${next}`)?.focus()
210
+ }
211
+
212
+ const toolsDisabled = disabled || preview
213
+
214
+ return (
215
+ <View
216
+ {...fieldBoxStyle}
217
+ {...(invalid && { borderColor: '$error' })}
218
+ opacity={disabled ? 0.5 : 1}
219
+ overflow="hidden"
220
+ width="100%"
221
+ >
222
+ <XStack
223
+ alignItems="center"
224
+ justifyContent="space-between"
225
+ gap="$2"
226
+ padding="$1"
227
+ borderBottomWidth={1}
228
+ borderColor="$border"
229
+ >
230
+ <XStack
231
+ flexWrap="wrap"
232
+ flex={1}
233
+ {...(isWeb && {
234
+ role: 'toolbar',
235
+ 'aria-label': labels.toolbar,
236
+ 'aria-controls': fieldId,
237
+ onKeyDown: onToolbarKeyDown,
238
+ })}
239
+ >
240
+ {tools.map((tool, i) => (
241
+ <IconButton
242
+ key={tool}
243
+ id={`${generatedId}-tool-${i}`}
244
+ size="sm"
245
+ icon={icons[tool]}
246
+ aria-label={labels.tools[tool]}
247
+ {...(isWeb && {
248
+ tabIndex: i === activeTool ? 0 : -1,
249
+ title: shortcutHints[tool]
250
+ ? `${labels.tools[tool]} (Ctrl+${shortcutHints[tool]})`
251
+ : labels.tools[tool],
252
+ // Keep the text selected while pressing a tool.
253
+ onMouseDown: (event: { preventDefault: () => void }) => event.preventDefault(),
254
+ onFocus: () => setActiveTool(i),
255
+ })}
256
+ disabled={toolsDisabled}
257
+ onPress={() => apply(tool)}
258
+ />
259
+ ))}
260
+ </XStack>
261
+ <Button
262
+ size="sm"
263
+ variant="ghost"
264
+ aria-pressed={preview}
265
+ disabled={disabled}
266
+ onPress={() => setPreview(!preview)}
267
+ {...(preview && { backgroundColor: '$accent' })}
268
+ >
269
+ {labels.preview}
270
+ </Button>
271
+ </XStack>
272
+ {preview ? (
273
+ <View
274
+ minHeight={minHeight}
275
+ padding="$3"
276
+ {...(isWeb && { role: 'region', 'aria-label': labels.preview })}
277
+ >
278
+ {value.trim() ? (
279
+ <RichTextContent>{value}</RichTextContent>
280
+ ) : (
281
+ <Text size="sm" tone="muted">
282
+ {labels.empty}
283
+ </Text>
284
+ )}
285
+ </View>
286
+ ) : (
287
+ <Textarea
288
+ ref={field as never}
289
+ id={fieldId}
290
+ value={value}
291
+ onChangeText={setValue}
292
+ placeholder={placeholder}
293
+ disabled={disabled}
294
+ invalid={invalid}
295
+ maxLength={maxLength}
296
+ aria-label={ariaLabel}
297
+ aria-labelledby={ariaLabelledBy}
298
+ aria-required={ariaRequired}
299
+ {...(accessibilityHint && { accessibilityHint })}
300
+ aria-describedby={
301
+ [ariaDescribedBy, maxLength != null ? countId : null].filter(Boolean).join(' ') ||
302
+ undefined
303
+ }
304
+ minHeight={minHeight}
305
+ // The frame draws the border; the text area sits flush inside it.
306
+ borderWidth={0}
307
+ borderRadius={0}
308
+ backgroundColor="transparent"
309
+ focusVisibleStyle={{ outlineWidth: 0, outlineStyle: 'none' }}
310
+ onSelectionChange={(event: { nativeEvent: { selection: TextSelection } }) => {
311
+ selection.current = event.nativeEvent.selection
312
+ }}
313
+ {...(isWeb && { onKeyDown: onFieldKeyDown })}
314
+ />
315
+ )}
316
+ {maxLength != null ? (
317
+ <Text
318
+ id={countId}
319
+ size="xs"
320
+ tone="muted"
321
+ textAlign="right"
322
+ paddingHorizontal="$3"
323
+ paddingBottom="$2"
324
+ >
325
+ {labels.count(value.length, maxLength)}
326
+ </Text>
327
+ ) : null}
328
+ </View>
329
+ )
330
+ }
@@ -0,0 +1,14 @@
1
+ export { RichTextContent, type RichTextContentProps } from './RichTextContent'
2
+ export {
3
+ RichTextEditor,
4
+ defaultRichTextTools,
5
+ type RichTextEditorLabels,
6
+ type RichTextEditorProps,
7
+ } from './RichTextEditor'
8
+ export {
9
+ applyFormat,
10
+ parseMarkdown,
11
+ type FormatResult,
12
+ type RichTextTool,
13
+ type TextSelection,
14
+ } from './markdown'
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The Markdown subset Rich Text Editor writes and Rich Text Content reads:
3
+ * **bold**, _italic_, ~~strike~~, `code`, [links](https://…), `#` headings,
4
+ * `-` and `1.` lists, and `>` quotes.
5
+ */
6
+
7
+ export type RichTextTool =
8
+ | 'bold'
9
+ | 'italic'
10
+ | 'strikethrough'
11
+ | 'code'
12
+ | 'link'
13
+ | 'heading'
14
+ | 'bulletList'
15
+ | 'orderedList'
16
+ | 'quote'
17
+
18
+ export interface TextSelection {
19
+ start: number
20
+ end: number
21
+ }
22
+
23
+ export interface FormatResult {
24
+ value: string
25
+ selection: TextSelection
26
+ }
27
+
28
+ const marks = { bold: '**', italic: '_', strikethrough: '~~', code: '`' } as const
29
+
30
+ const linePatterns = {
31
+ heading: /^#{1,6} /,
32
+ bulletList: /^[-*] /,
33
+ orderedList: /^\d+\. /,
34
+ quote: /^> /,
35
+ } as const
36
+
37
+ /** Lists replace each other; a heading replaces a list. Quotes nest around anything. */
38
+ const replaces: Record<keyof typeof linePatterns, RegExp[]> = {
39
+ heading: [linePatterns.heading, linePatterns.bulletList, linePatterns.orderedList],
40
+ bulletList: [linePatterns.bulletList, linePatterns.orderedList, linePatterns.heading],
41
+ orderedList: [linePatterns.orderedList, linePatterns.bulletList, linePatterns.heading],
42
+ quote: [linePatterns.quote],
43
+ }
44
+
45
+ /**
46
+ * Applies a toolbar tool to the text around the selection. Pressing a tool
47
+ * again removes it. Returns the new text and what to select next.
48
+ */
49
+ export function applyFormat(
50
+ value: string,
51
+ selection: TextSelection,
52
+ tool: RichTextTool,
53
+ placeholder = tool === 'link' ? 'link text' : 'text',
54
+ ): FormatResult {
55
+ const start = Math.max(0, Math.min(selection.start, selection.end, value.length))
56
+ const end = Math.min(value.length, Math.max(selection.start, selection.end))
57
+ const selected = value.slice(start, end)
58
+
59
+ if (tool in marks) {
60
+ const mark = marks[tool as keyof typeof marks]
61
+ const n = mark.length
62
+ // The marks sit just outside the selection: remove them.
63
+ if (value.slice(start - n, start) === mark && value.slice(end, end + n) === mark) {
64
+ return {
65
+ value: value.slice(0, start - n) + selected + value.slice(end + n),
66
+ selection: { start: start - n, end: end - n },
67
+ }
68
+ }
69
+ // The selection includes the marks: remove them.
70
+ if (selected.length >= 2 * n && selected.startsWith(mark) && selected.endsWith(mark)) {
71
+ const inner = selected.slice(n, -n)
72
+ return {
73
+ value: value.slice(0, start) + inner + value.slice(end),
74
+ selection: { start, end: start + inner.length },
75
+ }
76
+ }
77
+ const text = selected || placeholder
78
+ return {
79
+ value: value.slice(0, start) + mark + text + mark + value.slice(end),
80
+ selection: { start: start + n, end: start + n + text.length },
81
+ }
82
+ }
83
+
84
+ if (tool === 'link') {
85
+ const url = 'https://'
86
+ const text = selected || placeholder
87
+ const inserted = `[${text}](${url})`
88
+ // With text selected the address is next to type; otherwise the text is.
89
+ const focus = selected
90
+ ? { start: start + text.length + 3, end: start + text.length + 3 + url.length }
91
+ : { start: start + 1, end: start + 1 + text.length }
92
+ return { value: value.slice(0, start) + inserted + value.slice(end), selection: focus }
93
+ }
94
+
95
+ // Line tools work on every line the selection touches.
96
+ const kind = tool as keyof typeof linePatterns
97
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1
98
+ const endsAtBreak = end > start && value[end - 1] === '\n'
99
+ let lineEnd = value.indexOf('\n', endsAtBreak ? end - 1 : end)
100
+ if (lineEnd === -1) lineEnd = value.length
101
+ const lines = value.slice(lineStart, lineEnd).split('\n')
102
+ const filled = lines.filter((line) => line.trim() !== '')
103
+ const remove = filled.length > 0 && filled.every((line) => linePatterns[kind].test(line))
104
+ let number = 0
105
+ const next = lines.map((line) => {
106
+ if (remove) return line.replace(linePatterns[kind], '')
107
+ if (line.trim() === '' && lines.length > 1) return line
108
+ const bare = replaces[kind].reduce((text, pattern) => text.replace(pattern, ''), line)
109
+ number += 1
110
+ const prefix =
111
+ kind === 'heading'
112
+ ? '## '
113
+ : kind === 'bulletList'
114
+ ? '- '
115
+ : kind === 'orderedList'
116
+ ? `${number}. `
117
+ : '> '
118
+ return prefix + bare
119
+ })
120
+ const block = next.join('\n')
121
+ const result = value.slice(0, lineStart) + block + value.slice(lineEnd)
122
+ if (lines.length > 1) {
123
+ return { value: result, selection: { start: lineStart, end: lineStart + block.length } }
124
+ }
125
+ const delta = block.length - (lineEnd - lineStart)
126
+ return {
127
+ value: result,
128
+ selection: {
129
+ start: Math.max(lineStart, start + delta),
130
+ end: Math.max(lineStart, end + delta),
131
+ },
132
+ }
133
+ }
134
+
135
+ /** Keyboard shortcuts (with ⌘ or Ctrl). */
136
+ export const shortcuts: Partial<Record<string, RichTextTool>> = {
137
+ b: 'bold',
138
+ i: 'italic',
139
+ k: 'link',
140
+ }
141
+
142
+ export type InlineNode =
143
+ | { type: 'text'; text: string }
144
+ | { type: 'code'; text: string }
145
+ | { type: 'bold' | 'italic' | 'strike'; children: InlineNode[] }
146
+ | { type: 'link'; href: string; children: InlineNode[] }
147
+
148
+ export type BlockNode =
149
+ | { type: 'paragraph'; children: InlineNode[] }
150
+ | { type: 'heading'; level: number; children: InlineNode[] }
151
+ | { type: 'quote'; children: BlockNode[] }
152
+ | { type: 'list'; ordered: boolean; start: number; items: InlineNode[][] }
153
+
154
+ const inlinePatterns: { type: InlineNode['type']; pattern: RegExp }[] = [
155
+ { type: 'code', pattern: /`([^`\n]+)`/ },
156
+ { type: 'link', pattern: /\[([^\]\n]+)\]\(([^)\s]+)\)/ },
157
+ { type: 'bold', pattern: /\*\*(.+?)\*\*/ },
158
+ { type: 'strike', pattern: /~~(.+?)~~/ },
159
+ { type: 'italic', pattern: /(?<![\w\\])_(.+?)_(?!\w)|(?<!\*)\*([^*\n]+)\*(?!\*)/ },
160
+ ]
161
+
162
+ /** Only web and mail links are followed; anything else (e.g. `javascript:`) stays text. */
163
+ export function safeHref(href: string) {
164
+ return /^(https?:\/\/|mailto:)/i.test(href) ? href : null
165
+ }
166
+
167
+ export function parseInline(text: string): InlineNode[] {
168
+ const nodes: InlineNode[] = []
169
+ let rest = text
170
+ while (rest) {
171
+ let first: { type: InlineNode['type']; match: RegExpExecArray } | null = null
172
+ for (const { type, pattern } of inlinePatterns) {
173
+ const match = pattern.exec(rest)
174
+ if (match && (!first || match.index < first.match.index)) first = { type, match }
175
+ }
176
+ if (!first) {
177
+ nodes.push({ type: 'text', text: rest })
178
+ break
179
+ }
180
+ const { type, match } = first
181
+ if (match.index > 0) nodes.push({ type: 'text', text: rest.slice(0, match.index) })
182
+ const inner = match[1] ?? match[2] ?? ''
183
+ if (type === 'code') nodes.push({ type: 'code', text: inner })
184
+ else if (type === 'link') {
185
+ const href = safeHref(match[2]!)
186
+ if (href) nodes.push({ type: 'link', href, children: parseInline(inner) })
187
+ else nodes.push(...parseInline(inner))
188
+ } else nodes.push({ type: type as 'bold', children: parseInline(inner) })
189
+ rest = rest.slice(match.index + match[0].length)
190
+ }
191
+ return nodes
192
+ }
193
+
194
+ export function parseMarkdown(source: string): BlockNode[] {
195
+ const blocks: BlockNode[] = []
196
+ const lines = source.replace(/\r\n?/g, '\n').split('\n')
197
+ let i = 0
198
+ while (i < lines.length) {
199
+ const line = lines[i]!
200
+ if (line.trim() === '') {
201
+ i++
202
+ continue
203
+ }
204
+ const heading = /^(#{1,6}) (.*)$/.exec(line)
205
+ if (heading) {
206
+ blocks.push({
207
+ type: 'heading',
208
+ level: heading[1]!.length,
209
+ children: parseInline(heading[2]!),
210
+ })
211
+ i++
212
+ continue
213
+ }
214
+ if (/^> ?/.test(line)) {
215
+ const quoted: string[] = []
216
+ while (i < lines.length && /^> ?/.test(lines[i]!))
217
+ quoted.push(lines[i++]!.replace(/^> ?/, ''))
218
+ blocks.push({ type: 'quote', children: parseMarkdown(quoted.join('\n')) })
219
+ continue
220
+ }
221
+ const listItem = /^([-*]|\d+\.) (.*)$/.exec(line)
222
+ if (listItem) {
223
+ const ordered = listItem[1]!.endsWith('.')
224
+ const items: InlineNode[][] = []
225
+ while (i < lines.length) {
226
+ const item = /^([-*]|\d+\.) (.*)$/.exec(lines[i]!)
227
+ if (!item || item[1]!.endsWith('.') !== ordered) break
228
+ items.push(parseInline(item[2]!))
229
+ i++
230
+ }
231
+ blocks.push({ type: 'list', ordered, start: ordered ? parseInt(listItem[1]!, 10) : 1, items })
232
+ continue
233
+ }
234
+ const paragraph: string[] = []
235
+ while (
236
+ i < lines.length &&
237
+ lines[i]!.trim() !== '' &&
238
+ !/^(#{1,6} |> ?|[-*] |\d+\. )/.test(lines[i]!)
239
+ )
240
+ paragraph.push(lines[i++]!)
241
+ blocks.push({ type: 'paragraph', children: parseInline(paragraph.join('\n')) })
242
+ }
243
+ return blocks
244
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Rich text editing: the Markdown editor, its viewer and the Markdown helpers they share.
2
+ export * from './components/rich-text-editor'