@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. package/src/sourceRules.test.ts +52 -1
@@ -0,0 +1,566 @@
1
+ /**
2
+ * Every edit the editor can make, as pure text-in / text-out.
3
+ *
4
+ * Nothing here touches the DOM, so the whole formatting grammar —
5
+ * what Ctrl+B does to a selection that already has asterisks
6
+ * outside it, what Enter does at the end of an empty list item,
7
+ * how an ordered list renumbers when you promote three paragraphs
8
+ * — is a Node test rather than a browser one. That split is not
9
+ * tidiness: the browser suite is where the expensive tests live,
10
+ * and this is the half with all the edge cases.
11
+ *
12
+ * ### Everything is `{ selectionEnd, selectionStart, text }`
13
+ *
14
+ * The same three fields a `<textarea>` exposes, in and out. A
15
+ * command takes the state the control is in and returns the state
16
+ * it should be in, and the component's only job is to get from one
17
+ * to the other without breaking undo.
18
+ *
19
+ * ### And that is what `getMinimalEdit` is for
20
+ *
21
+ * Writing `textarea.value = next` — or handing a controlled React
22
+ * `value` a new string — **erases the browser's undo stack**. Ctrl+Z
23
+ * after a toolbar click then jumps past everything the user typed,
24
+ * or does nothing at all. The fix is to express the change as a
25
+ * *replacement of a range*, select that range, and let the browser
26
+ * apply it through its own editing pipeline
27
+ * (`document.execCommand("insertText", …)`, which is deprecated and
28
+ * is still the only API that appends to the native undo stack).
29
+ *
30
+ * So commands return whole strings, because that is the easy thing
31
+ * to reason about and to test, and `getMinimalEdit` turns the pair
32
+ * back into the range replacement the DOM wants. A bold toggle
33
+ * becomes one undo step covering four characters instead of a
34
+ * document-sized one.
35
+ *
36
+ * ### No HTML, anywhere
37
+ *
38
+ * Every function here returns markdown. There is no serialiser, no
39
+ * intermediate document model, and no HTML string — which is the
40
+ * mechanical reason `<b>` cannot reach the stored value: nothing in
41
+ * this file can produce one, and the surface these run against is a
42
+ * `<textarea>`, which takes `text/plain` off the clipboard and
43
+ * nothing else.
44
+ */
45
+
46
+ export type MarkdownSelection = {
47
+ selectionEnd: number
48
+ selectionStart: number
49
+ text: string
50
+ }
51
+
52
+ export type MarkdownLinePrefix = {
53
+ /**
54
+ * Matches a line that **already is** this kind, which is what
55
+ * decides whether the toggle adds or removes.
56
+ */
57
+ pattern: RegExp
58
+ /**
59
+ * What to take off before adding — wider than `pattern`, and the
60
+ * two genuinely differ. A task list *detects* `- [ ] `, because a
61
+ * plain bullet is not a task; but it *strips* the bare `- ` too,
62
+ * so toggling a task on an existing bullet replaces the marker
63
+ * instead of producing `- - [ ] `. Collapsing these into one
64
+ * regex is how that bug gets written.
65
+ */
66
+ stripPattern: RegExp
67
+ /**
68
+ * The prefix to add, given the line's zero-based position inside
69
+ * the selection. Ordered lists are the only kind that uses the
70
+ * argument, and it is why this is a function.
71
+ */
72
+ toPrefix: (indexInSelection: number) => string
73
+ }
74
+
75
+ /**
76
+ * The prefixes the toolbar and the shortcuts drive, written out
77
+ * rather than derived.
78
+ *
79
+ * `task` deliberately matches a plain bullet's prefix *plus* the
80
+ * checkbox, so toggling a task on an existing `- item` replaces the
81
+ * bullet rather than nesting one inside the other.
82
+ */
83
+ export const MARKDOWN_LINE_PREFIXES = {
84
+ blockquote: {
85
+ pattern: /^>[ \t]?/,
86
+ stripPattern: /^>[ \t]?/,
87
+ toPrefix: () => "> ",
88
+ },
89
+ bulletList: {
90
+ pattern: /^[-*+][ \t]+(?:\[[ xX]\][ \t]*)?/,
91
+ stripPattern: /^[-*+][ \t]+(?:\[[ xX]\][ \t]*)?/,
92
+ toPrefix: () => "- ",
93
+ },
94
+ orderedList: {
95
+ pattern: /^\d{1,9}[.)][ \t]+/,
96
+ stripPattern: /^\d{1,9}[.)][ \t]+/,
97
+ toPrefix: (indexInSelection: number) =>
98
+ `${indexInSelection + 1}. `,
99
+ },
100
+ taskList: {
101
+ pattern: /^[-*+][ \t]+\[[ xX]\][ \t]*/,
102
+ stripPattern: /^[-*+][ \t]+(?:\[[ xX]\][ \t]*)?/,
103
+ toPrefix: () => "- [ ] ",
104
+ },
105
+ } as const satisfies Record<string, MarkdownLinePrefix>
106
+
107
+ const INDENT_PATTERN = /^[ \t]*/
108
+
109
+ const HEADING_PATTERN = /^#{1,6}[ \t]+/
110
+
111
+ /**
112
+ * A continuable line: an ordered item, a bullet, a task, or a
113
+ * blockquote — with its indent, its marker, its optional checkbox
114
+ * and whatever came after them, captured separately so Enter can
115
+ * rebuild the marker without the content.
116
+ */
117
+ const CONTINUABLE_PATTERN =
118
+ /^([ \t]*)(?:(\d{1,9})([.)])[ \t]+|([-*+])[ \t]+|(>[ \t]?))(\[[ xX]\][ \t]*)?(.*)$/
119
+
120
+ /** The character range of the whole lines a selection touches. */
121
+ export const getLineRange = ({
122
+ selectionEnd,
123
+ selectionStart,
124
+ text,
125
+ }: MarkdownSelection): { end: number; start: number } => {
126
+ const start =
127
+ text.lastIndexOf("\n", selectionStart - 1) + 1
128
+
129
+ const nextNewline = text.indexOf("\n", selectionEnd)
130
+
131
+ return {
132
+ end: nextNewline === -1 ? text.length : nextNewline,
133
+ start,
134
+ }
135
+ }
136
+
137
+ const replaceRange = ({
138
+ end,
139
+ insertion,
140
+ selectionEnd,
141
+ selectionStart,
142
+ start,
143
+ text,
144
+ }: {
145
+ end: number
146
+ insertion: string
147
+ selectionEnd: number
148
+ selectionStart: number
149
+ start: number
150
+ text: string
151
+ }): MarkdownSelection => ({
152
+ selectionEnd,
153
+ selectionStart,
154
+ text: text.slice(0, start) + insertion + text.slice(end),
155
+ })
156
+
157
+ /** Type text over the selection, as if it had been typed. */
158
+ export const insertText = (
159
+ { selectionEnd, selectionStart, text }: MarkdownSelection,
160
+ insertion: string,
161
+ ): MarkdownSelection =>
162
+ replaceRange({
163
+ end: selectionEnd,
164
+ insertion,
165
+ selectionEnd: selectionStart + insertion.length,
166
+ selectionStart: selectionStart + insertion.length,
167
+ start: selectionStart,
168
+ text,
169
+ })
170
+
171
+ /**
172
+ * Wrap or unwrap a selection in `**`, `_`, `` ` `` or `~~`.
173
+ *
174
+ * Three cases, and the middle one is the one hand-rolled editors
175
+ * miss: the markers may be **outside** the selection. Double-click
176
+ * a word inside `**bold**` and the browser selects `bold`, not
177
+ * `**bold**`, so a naive implementation adds a second pair and
178
+ * produces `****bold****`. Looking just past both ends first is
179
+ * what makes Ctrl+B genuinely a toggle.
180
+ */
181
+ export const toggleInlineMarker = (
182
+ { selectionEnd, selectionStart, text }: MarkdownSelection,
183
+ marker: string,
184
+ ): MarkdownSelection => {
185
+ const selected = text.slice(selectionStart, selectionEnd)
186
+
187
+ if (
188
+ selected.length >= marker.length * 2 &&
189
+ selected.startsWith(marker) &&
190
+ selected.endsWith(marker)
191
+ ) {
192
+ const stripped = selected.slice(
193
+ marker.length,
194
+ selected.length - marker.length,
195
+ )
196
+
197
+ return replaceRange({
198
+ end: selectionEnd,
199
+ insertion: stripped,
200
+ selectionEnd: selectionStart + stripped.length,
201
+ selectionStart,
202
+ start: selectionStart,
203
+ text,
204
+ })
205
+ }
206
+
207
+ const isWrappedOutside =
208
+ text.slice(
209
+ selectionStart - marker.length,
210
+ selectionStart,
211
+ ) === marker &&
212
+ text.slice(
213
+ selectionEnd,
214
+ selectionEnd + marker.length,
215
+ ) === marker
216
+
217
+ if (isWrappedOutside) {
218
+ return replaceRange({
219
+ end: selectionEnd + marker.length,
220
+ insertion: selected,
221
+ selectionEnd:
222
+ selectionStart - marker.length + selected.length,
223
+ selectionStart: selectionStart - marker.length,
224
+ start: selectionStart - marker.length,
225
+ text,
226
+ })
227
+ }
228
+
229
+ return replaceRange({
230
+ end: selectionEnd,
231
+ insertion: `${marker}${selected}${marker}`,
232
+ // The caret lands *inside* the new pair when nothing was
233
+ // selected, so Ctrl+B then typing works the way it does in
234
+ // every other editor.
235
+ selectionEnd:
236
+ selectionStart + marker.length + selected.length,
237
+ selectionStart: selectionStart + marker.length,
238
+ start: selectionStart,
239
+ text,
240
+ })
241
+ }
242
+
243
+ /**
244
+ * Add or remove a line prefix across every line the selection
245
+ * touches.
246
+ *
247
+ * Removal wins when **every** line already has the prefix, which is
248
+ * the behaviour that makes the toolbar button a toggle rather than
249
+ * a one-way trip. A mixed block — two bullets and a paragraph —
250
+ * gets the prefix added to all three, because the alternative is a
251
+ * button whose effect depends on invisible state.
252
+ */
253
+ export const toggleLinePrefix = (
254
+ state: MarkdownSelection,
255
+ { pattern, stripPattern, toPrefix }: MarkdownLinePrefix,
256
+ ): MarkdownSelection => {
257
+ const { end, start } = getLineRange(state)
258
+
259
+ const lines = state.text.slice(start, end).split("\n")
260
+
261
+ const hasPrefixEverywhere = lines.every((line) =>
262
+ pattern.test(line.replace(INDENT_PATTERN, "")),
263
+ )
264
+
265
+ const nextLines = lines.map((line, indexInSelection) => {
266
+ const indent = INDENT_PATTERN.exec(line)?.[0] ?? ""
267
+
268
+ const body = line.slice(indent.length)
269
+
270
+ const stripped = body.replace(stripPattern, "")
271
+
272
+ return hasPrefixEverywhere
273
+ ? `${indent}${stripped}`
274
+ : `${indent}${toPrefix(indexInSelection)}${stripped}`
275
+ })
276
+
277
+ const insertion = nextLines.join("\n")
278
+
279
+ return replaceRange({
280
+ end,
281
+ insertion,
282
+ selectionEnd: start + insertion.length,
283
+ selectionStart: start,
284
+ start,
285
+ text: state.text,
286
+ })
287
+ }
288
+
289
+ /**
290
+ * `#` … `######`, and pressing the same level twice removes it.
291
+ *
292
+ * A heading replaces any heading already on the line rather than
293
+ * stacking, so H2-then-H3 is H3 and not `## ### `.
294
+ */
295
+ export const toggleHeading = (
296
+ state: MarkdownSelection,
297
+ level: number,
298
+ ): MarkdownSelection => {
299
+ const { end, start } = getLineRange(state)
300
+
301
+ const wanted = `${"#".repeat(level)} `
302
+
303
+ const insertion = state.text
304
+ .slice(start, end)
305
+ .split("\n")
306
+ .map((line) => {
307
+ const indent = INDENT_PATTERN.exec(line)?.[0] ?? ""
308
+
309
+ const body = line.slice(indent.length)
310
+
311
+ const existing = HEADING_PATTERN.exec(body)?.[0]
312
+
313
+ const stripped = body.replace(HEADING_PATTERN, "")
314
+
315
+ return existing?.trimEnd() === wanted.trimEnd()
316
+ ? `${indent}${stripped}`
317
+ : `${indent}${wanted}${stripped}`
318
+ })
319
+ .join("\n")
320
+
321
+ return replaceRange({
322
+ end,
323
+ insertion,
324
+ selectionEnd: start + insertion.length,
325
+ selectionStart: start,
326
+ start,
327
+ text: state.text,
328
+ })
329
+ }
330
+
331
+ /**
332
+ * Markdown-safe `![alt](url)`.
333
+ *
334
+ * The alt text is escaped rather than stripped — a filename with a
335
+ * bracket in it is legal, and silently deleting characters out of
336
+ * somebody's caption is worse than the escape being visible. The
337
+ * destination goes inside angle brackets when it contains anything
338
+ * that would end the destination early, which is the CommonMark
339
+ * spelling for "this whole thing is the URL".
340
+ */
341
+ export const toMarkdownImage = ({
342
+ alt = "",
343
+ url,
344
+ }: {
345
+ alt?: string
346
+ url: string
347
+ }): string => {
348
+ const safeAlt = alt
349
+ .replace(/[\r\n]+/g, " ")
350
+ .replace(/([\\[\]])/g, "\\$1")
351
+
352
+ const flatUrl = url.replace(/[\r\n]+/g, "")
353
+
354
+ const safeUrl = /[\s()<>]/.test(flatUrl)
355
+ ? `<${flatUrl.replace(/([<>])/g, "\\$1")}>`
356
+ : flatUrl
357
+
358
+ return `![${safeAlt}](${safeUrl})`
359
+ }
360
+
361
+ /** Insert an image at the caret, replacing any selection. */
362
+ export const insertImage = (
363
+ state: MarkdownSelection,
364
+ image: { alt?: string; url: string },
365
+ ): MarkdownSelection =>
366
+ insertText(state, toMarkdownImage(image))
367
+
368
+ /**
369
+ * `[selected](url)`, with the caret parked in the destination when
370
+ * there is nothing to put there yet — which is the only reason this
371
+ * is not `toggleInlineMarker` with a funny marker.
372
+ */
373
+ export const insertLink = (
374
+ state: MarkdownSelection,
375
+ url = "",
376
+ ): MarkdownSelection => {
377
+ const selected = state.text.slice(
378
+ state.selectionStart,
379
+ state.selectionEnd,
380
+ )
381
+
382
+ const insertion = `[${selected}](${url})`
383
+
384
+ const destinationStart =
385
+ state.selectionStart + selected.length + 3
386
+
387
+ return replaceRange({
388
+ end: state.selectionEnd,
389
+ insertion,
390
+ selectionEnd: destinationStart + url.length,
391
+ selectionStart: destinationStart,
392
+ start: state.selectionStart,
393
+ text: state.text,
394
+ })
395
+ }
396
+
397
+ /**
398
+ * What Enter should do inside a list, a task list or a blockquote —
399
+ * or `null` when it should do the ordinary thing and the component
400
+ * must not call `preventDefault`.
401
+ *
402
+ * Two behaviours, and the second is the one people notice when it
403
+ * is missing: continuing the marker onto the next line, and
404
+ * **clearing** it when the item is empty. Without the second, an
405
+ * empty bullet is a trap — every Enter makes another empty bullet
406
+ * and the only way out is Backspace.
407
+ *
408
+ * Returning `null` rather than the unchanged state is deliberate.
409
+ * A plain Enter has to fall through to the browser so it lands on
410
+ * the native undo stack as a normal typing step.
411
+ */
412
+ export const continueList = ({
413
+ selectionEnd,
414
+ selectionStart,
415
+ text,
416
+ }: MarkdownSelection): MarkdownSelection | null => {
417
+ if (selectionStart !== selectionEnd) {
418
+ return null
419
+ }
420
+
421
+ const lineStart =
422
+ text.lastIndexOf("\n", selectionStart - 1) + 1
423
+
424
+ const line = text.slice(lineStart, selectionStart)
425
+
426
+ const match = CONTINUABLE_PATTERN.exec(line)
427
+
428
+ if (!match) {
429
+ return null
430
+ }
431
+
432
+ const [
433
+ ,
434
+ indent = "",
435
+ orderedNumber,
436
+ orderedDelimiter,
437
+ bullet,
438
+ quote,
439
+ checkbox,
440
+ content = "",
441
+ ] = match
442
+
443
+ if (content === "") {
444
+ // An empty item: clear the marker instead of making another
445
+ // one. The line becomes its own indent, and the caret sits at
446
+ // the end of it.
447
+ return replaceRange({
448
+ end: selectionStart,
449
+ insertion: indent,
450
+ selectionEnd: lineStart + indent.length,
451
+ selectionStart: lineStart + indent.length,
452
+ start: lineStart,
453
+ text,
454
+ })
455
+ }
456
+
457
+ const nextMarker =
458
+ orderedNumber === undefined
459
+ ? `${bullet ?? quote ?? ""}${quote === undefined ? " " : ""}`
460
+ : `${Number(orderedNumber) + 1}${orderedDelimiter ?? "."} `
461
+
462
+ const insertion = `\n${indent}${nextMarker}${
463
+ checkbox === undefined ? "" : "[ ] "
464
+ }`
465
+
466
+ return replaceRange({
467
+ end: selectionEnd,
468
+ insertion,
469
+ selectionEnd: selectionStart + insertion.length,
470
+ selectionStart: selectionStart + insertion.length,
471
+ start: selectionStart,
472
+ text,
473
+ })
474
+ }
475
+
476
+ const INDENT_UNIT = " "
477
+
478
+ /** Two spaces onto every line the selection touches. */
479
+ export const indentLines = (
480
+ state: MarkdownSelection,
481
+ ): MarkdownSelection => {
482
+ const { end, start } = getLineRange(state)
483
+
484
+ const insertion = state.text
485
+ .slice(start, end)
486
+ .split("\n")
487
+ .map((line) => `${INDENT_UNIT}${line}`)
488
+ .join("\n")
489
+
490
+ return replaceRange({
491
+ end,
492
+ insertion,
493
+ selectionEnd: start + insertion.length,
494
+ selectionStart: start,
495
+ start,
496
+ text: state.text,
497
+ })
498
+ }
499
+
500
+ /** Up to two spaces, or one tab, off every line. */
501
+ export const outdentLines = (
502
+ state: MarkdownSelection,
503
+ ): MarkdownSelection => {
504
+ const { end, start } = getLineRange(state)
505
+
506
+ const insertion = state.text
507
+ .slice(start, end)
508
+ .split("\n")
509
+ .map((line) => line.replace(/^(?: {1,2}|\t)/, ""))
510
+ .join("\n")
511
+
512
+ return replaceRange({
513
+ end,
514
+ insertion,
515
+ selectionEnd: start + insertion.length,
516
+ selectionStart: start,
517
+ start,
518
+ text: state.text,
519
+ })
520
+ }
521
+
522
+ /**
523
+ * The smallest range replacement that turns `before` into `after`.
524
+ *
525
+ * Whole-string commands are easy to write and easy to test; whole
526
+ * string *assignments* destroy undo. This closes the gap between
527
+ * the two by finding the common prefix and the common suffix and
528
+ * reporting only what is left, so a bold toggle on one word is one
529
+ * four-character edit and one undo step — not a document-sized one
530
+ * that swallows the paragraph the user typed before it.
531
+ *
532
+ * The prefix scan runs first and the suffix scan is bounded by what
533
+ * it left, so the two can never overlap on a repetitive string
534
+ * (`aaaa` → `aaaaa` reports an insert, not a negative range).
535
+ */
536
+ export const getMinimalEdit = (
537
+ before: string,
538
+ after: string,
539
+ ): { end: number; start: number; text: string } => {
540
+ const shortest = Math.min(before.length, after.length)
541
+
542
+ let start = 0
543
+
544
+ while (
545
+ start < shortest &&
546
+ before[start] === after[start]
547
+ ) {
548
+ start += 1
549
+ }
550
+
551
+ let suffix = 0
552
+
553
+ while (
554
+ suffix < shortest - start &&
555
+ before[before.length - 1 - suffix] ===
556
+ after[after.length - 1 - suffix]
557
+ ) {
558
+ suffix += 1
559
+ }
560
+
561
+ return {
562
+ end: before.length - suffix,
563
+ start,
564
+ text: after.slice(start, after.length - suffix),
565
+ }
566
+ }