panelui-native 0.78.0 → 0.79.1

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 (64) hide show
  1. package/lib/module/components/accordion/index.js +13 -1
  2. package/lib/module/components/accordion/index.js.map +1 -1
  3. package/lib/module/components/map/index.js +63 -39
  4. package/lib/module/components/map/index.js.map +1 -1
  5. package/lib/module/components/map/map-children.js +54 -0
  6. package/lib/module/components/map/map-children.js.map +1 -0
  7. package/lib/module/components/map/maplibre.js +31 -3
  8. package/lib/module/components/map/maplibre.js.map +1 -1
  9. package/lib/module/components/markdown-editor/index.js +288 -34
  10. package/lib/module/components/markdown-editor/index.js.map +1 -1
  11. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  12. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  13. package/lib/module/components/marquee/index.js +119 -46
  14. package/lib/module/components/marquee/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +162 -34
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/progress-button/index.js +279 -52
  18. package/lib/module/components/progress-button/index.js.map +1 -1
  19. package/lib/module/components/progress-button/progress-button-hold.js +40 -6
  20. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -1
  21. package/lib/module/components/theme-selector/index.js +239 -0
  22. package/lib/module/components/theme-selector/index.js.map +1 -0
  23. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  24. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  25. package/lib/module/index.js +1 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/map/index.d.ts +8 -4
  29. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  31. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  32. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  33. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  34. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  35. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  37. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  38. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  40. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/progress-button/index.d.ts +29 -13
  42. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +26 -6
  44. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -1
  45. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  46. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  48. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  49. package/lib/typescript/src/index.d.ts +4 -3
  50. package/lib/typescript/src/index.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/components/accordion/index.tsx +13 -0
  53. package/src/components/map/index.tsx +63 -36
  54. package/src/components/map/map-children.ts +66 -0
  55. package/src/components/map/maplibre.ts +32 -4
  56. package/src/components/markdown-editor/index.tsx +413 -35
  57. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  58. package/src/components/marquee/index.tsx +128 -55
  59. package/src/components/panelside/index.tsx +179 -40
  60. package/src/components/progress-button/index.tsx +264 -45
  61. package/src/components/progress-button/progress-button-hold.ts +37 -6
  62. package/src/components/theme-selector/index.tsx +281 -0
  63. package/src/components/theme-selector/theme-preview.tsx +205 -0
  64. package/src/index.ts +15 -0
@@ -42,16 +42,20 @@
42
42
  * Works controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
43
43
  */
44
44
  import {
45
+ Fragment,
45
46
  createContext,
46
47
  forwardRef,
47
48
  useCallback,
48
49
  useContext,
50
+ useImperativeHandle,
49
51
  useMemo,
50
52
  useRef,
51
53
  useState,
52
54
  type ReactNode,
53
55
  } from 'react';
54
56
  import {
57
+ Pressable,
58
+ ScrollView,
55
59
  View,
56
60
  type NativeSyntheticEvent,
57
61
  type TextInput,
@@ -64,6 +68,7 @@ import {
64
68
  CodeIcon,
65
69
  EyeIcon,
66
70
  HeadingIcon,
71
+ ImageIcon,
67
72
  ItalicIcon,
68
73
  LinkIcon,
69
74
  ListIcon,
@@ -78,6 +83,12 @@ import { ButtonGroup } from '../button-group';
78
83
  import { Response } from '../response';
79
84
  import { Textarea, type TextareaProps } from '../textarea';
80
85
  import {
86
+ continueList,
87
+ hasFence,
88
+ hasLinePrefix,
89
+ hasOrderedList,
90
+ hasWrap,
91
+ insertImage,
81
92
  insertLink,
82
93
  toggleFence,
83
94
  toggleLinePrefix,
@@ -101,7 +112,8 @@ export type MarkdownEditorAction =
101
112
  | 'code'
102
113
  | 'bulletList'
103
114
  | 'orderedList'
104
- | 'link';
115
+ | 'link'
116
+ | 'image';
105
117
 
106
118
  /** The actions a toolbar shows when it is not told which to show. */
107
119
  const DEFAULT_ACTIONS: MarkdownEditorAction[] = [
@@ -115,6 +127,42 @@ const DEFAULT_ACTIONS: MarkdownEditorAction[] = [
115
127
  'link',
116
128
  ];
117
129
 
130
+ /**
131
+ * The same actions in the capsule, grouped.
132
+ *
133
+ * Eight rather than nine: the capsule is a fixed row of targets with hairlines
134
+ * between its groups, and `quote` is the one a writer reaches for least. It is
135
+ * still available — pass `actions` and it appears in whatever grouping the
136
+ * families below produce.
137
+ */
138
+ const DEFAULT_PILL_ACTIONS: MarkdownEditorAction[] = [
139
+ 'bold',
140
+ 'italic',
141
+ 'heading',
142
+ 'link',
143
+ 'image',
144
+ 'orderedList',
145
+ 'bulletList',
146
+ 'code',
147
+ ];
148
+
149
+ /**
150
+ * Which family each action belongs to, which is what the capsule's hairlines
151
+ * separate: what the words look like, what is being put into the document, and
152
+ * what shape the block is.
153
+ */
154
+ const FAMILY: Record<MarkdownEditorAction, 'inline' | 'insert' | 'block'> = {
155
+ bold: 'inline',
156
+ italic: 'inline',
157
+ heading: 'inline',
158
+ link: 'insert',
159
+ image: 'insert',
160
+ orderedList: 'insert',
161
+ bulletList: 'insert',
162
+ code: 'block',
163
+ quote: 'block',
164
+ };
165
+
118
166
  /**
119
167
  * What each action does, what it is called, and what it looks like.
120
168
  *
@@ -127,73 +175,136 @@ const ACTIONS: Record<
127
175
  label: string;
128
176
  icon: (size: number) => ReactNode;
129
177
  apply: (text: string, selection: EditorSelection) => EditResult;
178
+ /**
179
+ * Whether this action is already in effect where the caret is.
180
+ *
181
+ * The toolbar marks those buttons, which is what makes "pressing twice
182
+ * undoes it" visible rather than something the writer has to discover. An
183
+ * action that only ever inserts — a link, an image — has no such state and
184
+ * says so by leaving this out.
185
+ */
186
+ isActive?: (text: string, selection: EditorSelection) => boolean;
130
187
  }
131
188
  > = {
132
189
  bold: {
133
190
  label: 'Bold',
134
191
  icon: (size) => <BoldIcon size={size} />,
135
192
  apply: (text, selection) => toggleWrap(text, selection, '**'),
193
+ isActive: (text, selection) => hasWrap(text, selection, '**'),
136
194
  },
137
195
  italic: {
138
196
  label: 'Italic',
139
197
  icon: (size) => <ItalicIcon size={size} />,
140
198
  apply: (text, selection) => toggleWrap(text, selection, '_'),
199
+ isActive: (text, selection) => hasWrap(text, selection, '_'),
141
200
  },
142
201
  heading: {
143
202
  label: 'Heading',
144
203
  icon: (size) => <HeadingIcon size={size} />,
145
204
  apply: (text, selection) => toggleLinePrefix(text, selection, '## '),
205
+ isActive: (text, selection) => hasLinePrefix(text, selection, '## '),
146
206
  },
147
207
  quote: {
148
208
  label: 'Quote',
149
209
  icon: (size) => <QuoteIcon size={size} />,
150
210
  apply: (text, selection) => toggleLinePrefix(text, selection, '> '),
211
+ isActive: (text, selection) => hasLinePrefix(text, selection, '> '),
151
212
  },
152
213
  code: {
153
214
  label: 'Code block',
154
215
  icon: (size) => <CodeIcon size={size} />,
155
216
  apply: toggleFence,
217
+ isActive: hasFence,
156
218
  },
157
219
  bulletList: {
158
220
  label: 'Bulleted list',
159
221
  icon: (size) => <ListIcon size={size} />,
160
222
  apply: (text, selection) => toggleLinePrefix(text, selection, '- '),
223
+ isActive: (text, selection) => hasLinePrefix(text, selection, '- '),
161
224
  },
162
225
  orderedList: {
163
226
  label: 'Numbered list',
164
227
  icon: (size) => <ListOrderedIcon size={size} />,
165
228
  apply: toggleOrderedList,
229
+ isActive: hasOrderedList,
166
230
  },
167
231
  link: {
168
232
  label: 'Link',
169
233
  icon: (size) => <LinkIcon size={size} />,
170
234
  apply: insertLink,
171
235
  },
236
+ image: {
237
+ label: 'Image',
238
+ icon: (size) => <ImageIcon size={size} />,
239
+ apply: insertImage,
240
+ },
172
241
  };
173
242
 
174
243
  const markdownEditorVariants = tv({
175
244
  slots: {
176
245
  root: 'w-full gap-2',
177
246
  toolbar: 'flex-row items-center justify-between gap-2',
247
+ /**
248
+ * The capsule holding the formatting buttons in the `pill` toolbar.
249
+ *
250
+ * `shrink` and `overflow-hidden` are load-bearing. Eight 36pt targets, two
251
+ * hairlines and the round button beside them come to more than a phone is
252
+ * wide, and a row that cannot shrink does not wrap — it runs off the edge
253
+ * of the screen taking the last two actions with it. The capsule gives up
254
+ * width instead, and scrolls whatever no longer fits.
255
+ */
256
+ capsule:
257
+ 'min-w-0 shrink flex-row items-center overflow-hidden rounded-full border border-input bg-popover px-1.5 shadow-lg',
258
+ /** One hairline between two groups of them. */
259
+ divider: 'mx-1 h-5 w-px bg-border',
260
+ /** The round button outside the capsule, which switches the pane. */
261
+ escape:
262
+ 'h-11 w-11 shrink-0 items-center justify-center rounded-full border border-input bg-popover shadow-lg',
178
263
  // The preview stands in the field's place, so it takes the field's shape.
179
264
  // A rendered draft that sat on the page with no edge around it would not
180
265
  // read as the same object the writing was in.
181
266
  preview: 'w-full rounded-xl border border-input bg-popover px-3 py-2.5',
182
267
  empty: 'py-6 text-center',
183
268
  },
269
+ variants: {
270
+ /**
271
+ * How the toolbar is drawn.
272
+ *
273
+ * `bar` is the row: formatting on one side, the write/preview switch on the
274
+ * other, and room between them for a word count or a submit. `pill` is the
275
+ * floating capsule — icon-only, grouped by hairlines, with the pane switch
276
+ * as a round button beside it. The capsule has no room for anything else,
277
+ * so a toolbar with children wants the bar.
278
+ */
279
+ variant: {
280
+ bar: {},
281
+ pill: { toolbar: 'justify-center gap-2' },
282
+ },
283
+ },
284
+ defaultVariants: { variant: 'bar' },
184
285
  });
185
286
 
287
+ export type MarkdownEditorToolbarVariant = 'bar' | 'pill';
288
+
186
289
  interface MarkdownEditorContextValue {
187
290
  value: string;
188
291
  mode: MarkdownEditorMode;
189
292
  setMode: (mode: MarkdownEditorMode) => void;
190
293
  /** Runs a transform against the live text and selection. */
191
294
  run: (action: MarkdownEditorAction) => void;
295
+ /**
296
+ * Which actions are already in effect where the caret is. Recomputed when
297
+ * the caret moves rather than on every keystroke — the toolbar is the only
298
+ * thing that reads it, and it has eight buttons.
299
+ */
300
+ active: ReadonlySet<MarkdownEditorAction>;
192
301
  /** What the field should be showing as selected, once, after an edit. */
193
302
  pending: EditorSelection | undefined;
194
303
  setSelection: (selection: EditorSelection) => void;
195
304
  clearPending: () => void;
196
305
  onChangeText: (next: string) => void;
306
+ /** Registers the field, so the root's ref can focus it. */
307
+ registerField: (field: TextInput | null) => void;
197
308
  disabled: boolean;
198
309
  }
199
310
 
@@ -205,6 +316,32 @@ function useMarkdownEditor(part: string): MarkdownEditorContextValue {
205
316
  return ctx;
206
317
  }
207
318
 
319
+ /**
320
+ * What a `ref` on the editor gives you.
321
+ *
322
+ * The same transforms the toolbar runs, so a caller drawing their own controls
323
+ * — a keyboard accessory, a context menu, a single Bold button somewhere else
324
+ * on the screen — does not have to reimplement any of them. Every one applies
325
+ * to the live selection and leaves the caret where the toolbar would.
326
+ */
327
+ export interface MarkdownEditorHandle {
328
+ /** The container, for measuring and for anything a `View` ref is good for. */
329
+ readonly view: View | null;
330
+ /** Runs one of the toolbar's actions against the current selection. */
331
+ apply: (action: MarkdownEditorAction) => void;
332
+ /** Which actions are already in effect where the caret is. */
333
+ getActive: () => MarkdownEditorAction[];
334
+ /** Where the caret is, or what is selected. */
335
+ getSelection: () => EditorSelection;
336
+ /** Moves the caret, or selects a range. */
337
+ setSelection: (selection: EditorSelection) => void;
338
+ /** Puts the caret in the field. Switches to the writing pane if it is not up. */
339
+ focus: () => void;
340
+ blur: () => void;
341
+ /** Switches pane. */
342
+ setMode: (mode: MarkdownEditorMode) => void;
343
+ }
344
+
208
345
  export interface MarkdownEditorProps extends Omit<ViewProps, 'children'> {
209
346
  className?: string;
210
347
  /** Controlled text. Leave unset and pass `defaultValue` to run uncontrolled. */
@@ -217,7 +354,11 @@ export interface MarkdownEditorProps extends Omit<ViewProps, 'children'> {
217
354
  /** Starting pane when uncontrolled. */
218
355
  defaultMode?: MarkdownEditorMode;
219
356
  onModeChange?: (mode: MarkdownEditorMode) => void;
220
- /** Stop the field being edited and the toolbar being pressed. */
357
+ /**
358
+ * Stop the field being edited and the formatting actions being pressed. The
359
+ * switch between the panes stays live: a draft nobody may edit is still a
360
+ * draft somebody may want to read rendered.
361
+ */
221
362
  disabled?: boolean;
222
363
  /**
223
364
  * The parts, in the order they should stack. Left out, the editor draws its
@@ -229,9 +370,23 @@ export interface MarkdownEditorProps extends Omit<ViewProps, 'children'> {
229
370
  placeholder?: string;
230
371
  /** Height of the field, in lines. Forwarded to the field the editor draws. */
231
372
  rows?: number;
373
+ /**
374
+ * Lift the editor above the keyboard. Forwarded to the field the editor
375
+ * draws, and off by default because it changes which component renders the
376
+ * container — so it cannot be toggled at runtime without remounting the
377
+ * field and dropping focus.
378
+ */
379
+ avoidKeyboard?: boolean;
380
+ /**
381
+ * Continue a list when Return is pressed inside one, and end it when Return
382
+ * is pressed on an item with nothing in it. On by default: a list that stops
383
+ * numbering itself after the first item is a list the writer finishes by
384
+ * hand.
385
+ */
386
+ continueLists?: boolean;
232
387
  }
233
388
 
234
- const MarkdownEditorRoot = forwardRef<View, MarkdownEditorProps>(
389
+ const MarkdownEditorRoot = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(
235
390
  (
236
391
  {
237
392
  className,
@@ -245,11 +400,15 @@ const MarkdownEditorRoot = forwardRef<View, MarkdownEditorProps>(
245
400
  children,
246
401
  placeholder,
247
402
  rows,
403
+ avoidKeyboard,
404
+ continueLists = true,
248
405
  ...props
249
406
  },
250
407
  ref
251
408
  ) => {
252
409
  const slots = markdownEditorVariants();
410
+ const viewRef = useRef<View>(null);
411
+ const fieldRef = useRef<TextInput | null>(null);
253
412
 
254
413
  const [internalValue, setInternalValue] = useState(defaultValue);
255
414
  const value = valueProp ?? internalValue;
@@ -296,6 +455,32 @@ const MarkdownEditorRoot = forwardRef<View, MarkdownEditorProps>(
296
455
  [modeProp, onModeChange]
297
456
  );
298
457
 
458
+ /*
459
+ * Which actions are already in effect, held in state because the toolbar
460
+ * draws from it.
461
+ *
462
+ * Recomputed when the caret moves or the text changes through an edit, not
463
+ * on every keystroke: eight predicates over the whole document on every
464
+ * character typed is work nobody asked for, and typing a letter cannot
465
+ * change what is applied at the caret. Toggling a marker can, which is why
466
+ * `run` recomputes it too.
467
+ */
468
+ const [active, setActive] = useState<ReadonlySet<MarkdownEditorAction>>(
469
+ () => new Set()
470
+ );
471
+
472
+ const readActive = useCallback((text: string, selection: EditorSelection) => {
473
+ const next = new Set<MarkdownEditorAction>();
474
+ for (const key of Object.keys(ACTIONS) as MarkdownEditorAction[]) {
475
+ if (ACTIONS[key].isActive?.(text, selection)) next.add(key);
476
+ }
477
+ setActive((current) =>
478
+ current.size === next.size && [...next].every((key) => current.has(key))
479
+ ? current
480
+ : next
481
+ );
482
+ }, []);
483
+
299
484
  const run = useCallback(
300
485
  (action: MarkdownEditorAction) => {
301
486
  const result = ACTIONS[action].apply(valueRef.current, selectionRef.current);
@@ -305,8 +490,81 @@ const MarkdownEditorRoot = forwardRef<View, MarkdownEditorProps>(
305
490
  selectionRef.current = result.selection;
306
491
  commit(result.text);
307
492
  setPending(result.selection);
493
+ readActive(result.text, result.selection);
494
+ },
495
+ [commit, readActive]
496
+ );
497
+
498
+ /*
499
+ * A list continues itself when Return lands inside one.
500
+ *
501
+ * Read off the text change rather than off `onKeyPress`, because a key
502
+ * event here cannot be prevented — the field applies the newline whatever
503
+ * the handler does, so acting on the key would insert the marker *and* a
504
+ * second line break. Watching the change instead means the newline is
505
+ * already in the string, and what happens is a rewrite of it: the marker
506
+ * goes in behind it, or, on an item with nothing in it, the marker comes
507
+ * off and the list ends.
508
+ *
509
+ * One inserted character only. A paste that happens to contain a newline
510
+ * is not somebody pressing Return.
511
+ */
512
+ const applyChange = useCallback(
513
+ (next: string) => {
514
+ const previous = valueRef.current;
515
+
516
+ if (continueLists && next.length === previous.length + 1) {
517
+ let at = 0;
518
+ while (at < previous.length && previous[at] === next[at]) at += 1;
519
+
520
+ if (next[at] === '\n') {
521
+ const result = continueList(previous, { start: at, end: at });
522
+ if (result) {
523
+ valueRef.current = result.text;
524
+ selectionRef.current = result.selection;
525
+ commit(result.text);
526
+ setPending(result.selection);
527
+ readActive(result.text, result.selection);
528
+ return;
529
+ }
530
+ }
531
+ }
532
+
533
+ valueRef.current = next;
534
+ commit(next);
308
535
  },
309
- [commit]
536
+ [continueLists, commit, readActive]
537
+ );
538
+
539
+ const registerField = useCallback((field: TextInput | null) => {
540
+ fieldRef.current = field;
541
+ }, []);
542
+
543
+ useImperativeHandle(
544
+ ref,
545
+ () => ({
546
+ get view() {
547
+ return viewRef.current;
548
+ },
549
+ apply: run,
550
+ getActive: () => [...active],
551
+ getSelection: () => ({ ...selectionRef.current }),
552
+ setSelection: (selection) => {
553
+ selectionRef.current = selection;
554
+ setPending(selection);
555
+ readActive(valueRef.current, selection);
556
+ },
557
+ focus: () => {
558
+ // The field is unmounted while the preview is up, so focusing it has
559
+ // to bring it back first — otherwise this is a silent no-op that
560
+ // looks like a broken ref.
561
+ setMode('write');
562
+ fieldRef.current?.focus();
563
+ },
564
+ blur: () => fieldRef.current?.blur(),
565
+ setMode,
566
+ }),
567
+ [run, active, readActive, setMode]
310
568
  );
311
569
 
312
570
  const context = useMemo<MarkdownEditorContextValue>(
@@ -315,27 +573,31 @@ const MarkdownEditorRoot = forwardRef<View, MarkdownEditorProps>(
315
573
  mode,
316
574
  setMode,
317
575
  run,
576
+ active,
318
577
  pending,
319
578
  setSelection: (selection) => {
320
579
  selectionRef.current = selection;
580
+ readActive(valueRef.current, selection);
321
581
  },
322
582
  clearPending: () => setPending(undefined),
323
- onChangeText: (next) => {
324
- valueRef.current = next;
325
- commit(next);
326
- },
583
+ onChangeText: applyChange,
584
+ registerField,
327
585
  disabled,
328
586
  }),
329
- [value, mode, setMode, run, pending, commit, disabled]
587
+ [value, mode, setMode, run, active, pending, applyChange, readActive, registerField, disabled]
330
588
  );
331
589
 
332
590
  return (
333
591
  <MarkdownEditorContext.Provider value={context}>
334
- <View ref={ref} className={slots.root({ className })} {...props}>
592
+ <View ref={viewRef} className={slots.root({ className })} {...props}>
335
593
  {children ?? (
336
594
  <>
337
595
  <MarkdownEditorToolbar />
338
- <MarkdownEditorInput placeholder={placeholder} rows={rows} />
596
+ <MarkdownEditorInput
597
+ placeholder={placeholder}
598
+ rows={rows}
599
+ avoidKeyboard={avoidKeyboard}
600
+ />
339
601
  <MarkdownEditorPreview />
340
602
  </>
341
603
  )}
@@ -353,14 +615,28 @@ MarkdownEditorRoot.displayName = 'MarkdownEditor';
353
615
 
354
616
  export interface MarkdownEditorToolbarProps extends Omit<ViewProps, 'children'> {
355
617
  className?: string;
356
- /** Which formatting actions to offer, in the order given. */
618
+ /**
619
+ * How the toolbar is drawn. `bar` is the full-width row, with room on it for
620
+ * a word count or a submit. `pill` is the floating capsule: icon-only,
621
+ * grouped by hairlines, with the pane switch as a round button beside it.
622
+ */
623
+ variant?: MarkdownEditorToolbarVariant;
624
+ /**
625
+ * Which formatting actions to offer, in the order given. The capsule groups
626
+ * whatever it is given by family — what the words look like, what is being
627
+ * put into the document, what shape the block is — and draws a hairline
628
+ * where the family changes.
629
+ */
357
630
  actions?: MarkdownEditorAction[];
358
631
  /**
359
632
  * Show the write/preview switch. On by default — a preview nobody can reach
360
633
  * is a pane that does not exist.
361
634
  */
362
635
  showModeSwitch?: boolean;
363
- /** Anything else to put on the row: a word count, a save state, a submit. */
636
+ /**
637
+ * Anything else to put on the row: a word count, a save state, a submit.
638
+ * The capsule has no room for these, so a toolbar with children wants `bar`.
639
+ */
364
640
  children?: ReactNode;
365
641
  }
366
642
 
@@ -371,27 +647,95 @@ export interface MarkdownEditorToolbarProps extends Omit<ViewProps, 'children'>
371
647
  * buttons act on a selection, and in the preview there is no selection for them
372
648
  * to act on — a row of controls that are present but inert is a row that has to
373
649
  * be tried before it can be understood.
650
+ *
651
+ * An action already in effect where the caret is draws as pressed. Every button
652
+ * here is a toggle, and a toggle that looks the same in both states is a toggle
653
+ * nobody discovers is one.
374
654
  */
375
655
  const MarkdownEditorToolbar = forwardRef<View, MarkdownEditorToolbarProps>(
376
- ({ className, actions = DEFAULT_ACTIONS, showModeSwitch = true, children, ...props }, ref) => {
377
- const { mode, setMode, run, disabled } = useMarkdownEditor('MarkdownEditor.Toolbar');
378
- const slots = markdownEditorVariants();
656
+ (
657
+ {
658
+ className,
659
+ variant = 'bar',
660
+ actions,
661
+ showModeSwitch = true,
662
+ children,
663
+ ...props
664
+ },
665
+ ref
666
+ ) => {
667
+ const { mode, setMode, run, active, disabled } = useMarkdownEditor(
668
+ 'MarkdownEditor.Toolbar'
669
+ );
670
+ const slots = markdownEditorVariants({ variant });
671
+ const pill = variant === 'pill';
672
+ const shown = actions ?? (pill ? DEFAULT_PILL_ACTIONS : DEFAULT_ACTIONS);
673
+
674
+ const button = (action: MarkdownEditorAction, size: number) => (
675
+ <Button
676
+ key={action}
677
+ accessibilityLabel={ACTIONS[action].label}
678
+ accessibilityState={{ selected: active.has(action) }}
679
+ variant={active.has(action) ? 'secondary' : 'ghost'}
680
+ disabled={disabled}
681
+ onPress={() => run(action)}
682
+ className={pill ? 'h-9 w-9 rounded-full px-0' : 'px-2'}
683
+ >
684
+ {ACTIONS[action].icon(size)}
685
+ </Button>
686
+ );
687
+
688
+ if (pill) {
689
+ return (
690
+ <View ref={ref} className={slots.toolbar({ className })} {...props}>
691
+ {mode === 'write' ? (
692
+ <View className={slots.capsule()}>
693
+ <ScrollView
694
+ horizontal
695
+ showsHorizontalScrollIndicator={false}
696
+ keyboardShouldPersistTaps="always"
697
+ contentContainerClassName="flex-row items-center"
698
+ >
699
+ {shown.map((action, index) => (
700
+ <Fragment key={action}>
701
+ {/* A hairline wherever the family changes, and nowhere
702
+ else: a divider between every pair is eight dividers,
703
+ which is a row of eight separate things rather than
704
+ three groups. */}
705
+ {index > 0 && FAMILY[action] !== FAMILY[shown[index - 1]!] ? (
706
+ <View className={slots.divider()} />
707
+ ) : null}
708
+ {button(action, 18)}
709
+ </Fragment>
710
+ ))}
711
+ </ScrollView>
712
+ </View>
713
+ ) : null}
714
+
715
+ {showModeSwitch ? (
716
+ /* Outside the capsule, and round rather than a segment of it. The
717
+ capsule is what the writing is done with; this is the way out of
718
+ it, and a control that leaves somewhere should not look like one
719
+ of the things you do while you are there. */
720
+ <Pressable
721
+ accessibilityRole="button"
722
+ accessibilityLabel={mode === 'write' ? 'Preview' : 'Write'}
723
+ accessibilityState={{ selected: mode === 'preview' }}
724
+ className={slots.escape()}
725
+ onPress={() => setMode(mode === 'write' ? 'preview' : 'write')}
726
+ >
727
+ {mode === 'write' ? <EyeIcon size={18} /> : <PencilIcon size={18} />}
728
+ </Pressable>
729
+ ) : null}
730
+ </View>
731
+ );
732
+ }
379
733
 
380
734
  return (
381
735
  <View ref={ref} className={slots.toolbar({ className })} {...props}>
382
736
  {mode === 'write' ? (
383
737
  <ButtonGroup variant="ghost" size="sm" className="shrink">
384
- {actions.map((action) => (
385
- <Button
386
- key={action}
387
- accessibilityLabel={ACTIONS[action].label}
388
- disabled={disabled}
389
- onPress={() => run(action)}
390
- className="px-2"
391
- >
392
- {ACTIONS[action].icon(16)}
393
- </Button>
394
- ))}
738
+ {shown.map((action) => button(action, 16))}
395
739
  </ButtonGroup>
396
740
  ) : (
397
741
  <View className="shrink" />
@@ -442,24 +786,58 @@ export interface MarkdownEditorInputProps
442
786
  */
443
787
  const MarkdownEditorInput = forwardRef<TextInput, MarkdownEditorInputProps>(
444
788
  ({ className, rows = 8, ...props }, ref) => {
445
- const { value, mode, pending, setSelection, clearPending, onChangeText, disabled } =
446
- useMarkdownEditor('MarkdownEditor.Input');
789
+ const {
790
+ value,
791
+ mode,
792
+ pending,
793
+ setSelection,
794
+ clearPending,
795
+ onChangeText,
796
+ registerField,
797
+ disabled,
798
+ } = useMarkdownEditor('MarkdownEditor.Input');
447
799
 
800
+ /*
801
+ * The field is registered so the editor's own ref can focus it, and
802
+ * forwarded so a caller's ref still works. Both, rather than either.
803
+ */
804
+ const attach = useCallback(
805
+ (field: TextInput | null) => {
806
+ registerField(field);
807
+ if (typeof ref === 'function') ref(field);
808
+ else if (ref) ref.current = field;
809
+ },
810
+ [ref, registerField]
811
+ );
812
+
813
+ /*
814
+ * The forced selection is cleared only once it has actually arrived.
815
+ *
816
+ * Clearing on the first selection change of any kind was the bug: the new
817
+ * `value` landing produces one of its own, and it fires first — so the
818
+ * caret was released before it had been put anywhere, and ended up at the
819
+ * end of the document instead of between the markers just inserted.
820
+ */
448
821
  const handleSelectionChange = useCallback(
449
822
  (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
450
- setSelection(event.nativeEvent.selection);
451
- // The edit's own selection has now landed, so stop forcing it and give
452
- // the caret back to whoever is holding the phone.
453
- clearPending();
823
+ const { selection } = event.nativeEvent;
824
+ setSelection(selection);
825
+ if (
826
+ pending &&
827
+ selection.start === pending.start &&
828
+ selection.end === pending.end
829
+ ) {
830
+ clearPending();
831
+ }
454
832
  },
455
- [setSelection, clearPending]
833
+ [setSelection, clearPending, pending]
456
834
  );
457
835
 
458
836
  if (mode !== 'write') return null;
459
837
 
460
838
  return (
461
839
  <Textarea
462
- ref={ref}
840
+ ref={attach}
463
841
  value={value}
464
842
  onChangeText={onChangeText}
465
843
  selection={pending}