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.
- package/lib/module/components/accordion/index.js +13 -1
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/map/index.js +63 -39
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/map/map-children.js +54 -0
- package/lib/module/components/map/map-children.js.map +1 -0
- package/lib/module/components/map/maplibre.js +31 -3
- package/lib/module/components/map/maplibre.js.map +1 -1
- package/lib/module/components/markdown-editor/index.js +288 -34
- package/lib/module/components/markdown-editor/index.js.map +1 -1
- package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
- package/lib/module/components/marquee/index.js +119 -46
- package/lib/module/components/marquee/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +162 -34
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/progress-button/index.js +279 -52
- package/lib/module/components/progress-button/index.js.map +1 -1
- package/lib/module/components/progress-button/progress-button-hold.js +40 -6
- package/lib/module/components/progress-button/progress-button-hold.js.map +1 -1
- package/lib/module/components/theme-selector/index.js +239 -0
- package/lib/module/components/theme-selector/index.js.map +1 -0
- package/lib/module/components/theme-selector/theme-preview.js +320 -0
- package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -4
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/map-children.d.ts +41 -0
- package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
- package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
- package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +79 -4
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress-button/index.d.ts +29 -13
- package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +26 -6
- package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -1
- package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
- package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
- package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +13 -0
- package/src/components/map/index.tsx +63 -36
- package/src/components/map/map-children.ts +66 -0
- package/src/components/map/maplibre.ts +32 -4
- package/src/components/markdown-editor/index.tsx +413 -35
- package/src/components/markdown-editor/markdown-transforms.ts +274 -10
- package/src/components/marquee/index.tsx +128 -55
- package/src/components/panelside/index.tsx +179 -40
- package/src/components/progress-button/index.tsx +264 -45
- package/src/components/progress-button/progress-button-hold.ts +37 -6
- package/src/components/theme-selector/index.tsx +281 -0
- package/src/components/theme-selector/theme-preview.tsx +205 -0
- 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
|
-
/**
|
|
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<
|
|
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:
|
|
324
|
-
|
|
325
|
-
commit(next);
|
|
326
|
-
},
|
|
583
|
+
onChangeText: applyChange,
|
|
584
|
+
registerField,
|
|
327
585
|
disabled,
|
|
328
586
|
}),
|
|
329
|
-
[value, mode, setMode, run, pending,
|
|
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={
|
|
592
|
+
<View ref={viewRef} className={slots.root({ className })} {...props}>
|
|
335
593
|
{children ?? (
|
|
336
594
|
<>
|
|
337
595
|
<MarkdownEditorToolbar />
|
|
338
|
-
<MarkdownEditorInput
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
(
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
{
|
|
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 {
|
|
446
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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={
|
|
840
|
+
ref={attach}
|
|
463
841
|
value={value}
|
|
464
842
|
onChangeText={onChangeText}
|
|
465
843
|
selection={pending}
|