@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.
- package/LICENSE +49 -0
- package/README.md +35 -0
- package/dist/components/rich-text-editor/RichTextContent.d.ts +10 -0
- package/dist/components/rich-text-editor/RichTextContent.d.ts.map +1 -0
- package/dist/components/rich-text-editor/RichTextContent.js +49 -0
- package/dist/components/rich-text-editor/RichTextContent.js.map +1 -0
- package/dist/components/rich-text-editor/RichTextEditor.d.ts +44 -0
- package/dist/components/rich-text-editor/RichTextEditor.d.ts.map +1 -0
- package/dist/components/rich-text-editor/RichTextEditor.js +144 -0
- package/dist/components/rich-text-editor/RichTextEditor.js.map +1 -0
- package/dist/components/rich-text-editor/index.d.ts +4 -0
- package/dist/components/rich-text-editor/index.d.ts.map +1 -0
- package/dist/components/rich-text-editor/index.js +4 -0
- package/dist/components/rich-text-editor/index.js.map +1 -0
- package/dist/components/rich-text-editor/markdown.d.ts +56 -0
- package/dist/components/rich-text-editor/markdown.d.ts.map +1 -0
- package/dist/components/rich-text-editor/markdown.js +203 -0
- package/dist/components/rich-text-editor/markdown.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/package.json +85 -0
- package/src/components/rich-text-editor/RichTextContent.tsx +119 -0
- package/src/components/rich-text-editor/RichTextEditor.tsx +330 -0
- package/src/components/rich-text-editor/index.ts +14 -0
- package/src/components/rich-text-editor/markdown.ts +244 -0
- 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