@lotics/ui 44.14.0 → 45.3.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/AGENTS.md +9 -2
- package/MIGRATION.md +61 -5
- package/docs/ai_patterns.md +2 -3
- package/docs/catalog.md +40 -17
- package/docs/composition.md +143 -42
- package/docs/data_entry.md +15 -8
- package/docs/reviewing.md +24 -12
- package/docs/templates.md +1 -1
- package/package.json +10 -1
- package/src/accordion.tsx +2 -2
- package/src/avatar_group.tsx +1 -1
- package/src/avatar_size.ts +2 -3
- package/src/card_select_item.tsx +7 -6
- package/src/color_tokens.ts +22 -6
- package/src/combobox.tsx +11 -1
- package/src/control_surface.ts +22 -16
- package/src/date_stamp.tsx +1 -3
- package/src/detail_row.tsx +4 -5
- package/src/dialog.tsx +13 -6
- package/src/drawer.tsx +18 -4
- package/src/filter_chip.tsx +1 -2
- package/src/finding.tsx +24 -7
- package/src/form_markdown_editor.tsx +15 -0
- package/src/heading_altitude.ts +65 -0
- package/src/inline_markdown.tsx +49 -76
- package/src/inline_text_input.tsx +4 -5
- package/src/ledger.tsx +3 -3
- package/src/locale.tsx +48 -0
- package/src/markdown.web.tsx +3 -6
- package/src/markdown_editor.css +144 -0
- package/src/markdown_editor.tsx +29 -0
- package/src/markdown_editor.web.tsx +89 -0
- package/src/markdown_editor_props.ts +29 -0
- package/src/markdown_toolbar.web.tsx +231 -0
- package/src/matrix.tsx +1 -1
- package/src/modal.tsx +10 -1
- package/src/popover.tsx +151 -142
- package/src/pressable_row.tsx +24 -15
- package/src/reference_field.tsx +11 -9
- package/src/section_heading.tsx +57 -71
- package/src/stepper.tsx +2 -3
- package/src/summary.tsx +3 -3
- package/src/table.tsx +4 -1
- package/src/text_utils.ts +3 -4
- package/src/timeline.tsx +3 -4
package/src/inline_markdown.tsx
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import { View, type LayoutChangeEvent, type NativeSyntheticEvent, type TextInputKeyPressEventData } from "react-native";
|
|
3
1
|
import { InlineEditFrame, useInlineEdit, type InlineEditVariant } from "./inline_edit";
|
|
4
|
-
import {
|
|
5
|
-
import { TextInputField } from "./text_input_field";
|
|
2
|
+
import { MarkdownEditor } from "./markdown_editor";
|
|
6
3
|
|
|
7
4
|
export interface InlineMarkdownProps {
|
|
8
|
-
/** The markdown SOURCE. Rendered
|
|
5
|
+
/** The markdown SOURCE. Rendered rich, edited in place. */
|
|
9
6
|
value: string;
|
|
10
7
|
/** Persist the new source. May be async — the field shows a saving state and
|
|
11
8
|
* surfaces a thrown error inline, staying in edit mode so nothing is lost. */
|
|
@@ -14,80 +11,55 @@ export interface InlineMarkdownProps {
|
|
|
14
11
|
disabled?: boolean;
|
|
15
12
|
/** How much frame shows at rest — see {@link InlineEditVariant}. Default "framed". */
|
|
16
13
|
variant?: InlineEditVariant;
|
|
17
|
-
/**
|
|
18
|
-
*
|
|
19
|
-
|
|
14
|
+
/** Line budget for the field. The box reserves this whether or not the value
|
|
15
|
+
* fills it, so nothing moves as the value grows into it. */
|
|
16
|
+
numberOfLines?: number;
|
|
20
17
|
accessibilityLabel?: string;
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
/** A short prose reserve, so an empty field is still obviously somewhere to write. */
|
|
24
|
-
const DEFAULT_MIN_LINES = 3;
|
|
25
|
-
const APPROX_LINE_HEIGHT = 22;
|
|
26
|
-
|
|
27
20
|
/**
|
|
28
|
-
* An inline-editable MARKDOWN value
|
|
21
|
+
* An inline-editable MARKDOWN value.
|
|
22
|
+
*
|
|
23
|
+
* **ONE element, always mounted — it does not swap.** This started as a swap
|
|
24
|
+
* (rendered prose at rest, an editor on press) and every problem that followed
|
|
25
|
+
* came from there: the box changed height when it opened, a toolbar rolled in
|
|
26
|
+
* and pushed the rest of the form down, and the resting state was a BUTTON, so
|
|
27
|
+
* it took the pointer cursor while `userSelect: none` denied a reader the
|
|
28
|
+
* ability to select a sentence out of their own note.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* shape a new editor takes, and markdown is the clearest case of the second.
|
|
30
|
+
* None of those are fixable while two elements draw one value. There is no
|
|
31
|
+
* arrangement of two renderers of the same markdown that GUARANTEES zero shift —
|
|
32
|
+
* so the guarantee has to come from there being one. The editor renders at rest
|
|
33
|
+
* and simply gains a draft when focused, exactly as `InlineTextInput` is one
|
|
34
|
+
* `<input>` in both states.
|
|
36
35
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* tolerable direction — shrinking pulls the text the reader was just looking at
|
|
43
|
-
* out from under them, and everything below it jumps up.
|
|
36
|
+
* What that costs is a ProseMirror view per field, mounted whether or not anyone
|
|
37
|
+
* edits. That is a real price, paid deliberately: a record surface holds a
|
|
38
|
+
* handful of these, and "the layout never moves" is worth more than the
|
|
39
|
+
* instances. If a screen ever holds dozens, the answer is fewer markdown fields
|
|
40
|
+
* on it — not a swap that reintroduces the shift.
|
|
44
41
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
42
|
+
* The toolbar is OFF here for the same reason: a band that appears with the edit
|
|
43
|
+
* moves everything under it. `MarkdownEditor` keeps it for surfaces whose whole
|
|
44
|
+
* job is the document, where it is always present and so shifts nothing.
|
|
48
45
|
*/
|
|
49
46
|
export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
50
|
-
const { value, onSave, placeholder, disabled, variant,
|
|
47
|
+
const { value, onSave, placeholder, disabled, variant, numberOfLines, accessibilityLabel } = props;
|
|
51
48
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
52
|
-
const [restingHeight, setRestingHeight] = useState(0);
|
|
53
|
-
// Not state: it must be current for the render that mounts the input, and a
|
|
54
|
-
// set during layout would arrive one frame late — as a visible jump.
|
|
55
|
-
const measured = useRef(0);
|
|
56
|
-
|
|
57
|
-
const onViewLayout = useCallback((e: LayoutChangeEvent) => {
|
|
58
|
-
measured.current = e.nativeEvent.layout.height;
|
|
59
|
-
}, []);
|
|
60
|
-
|
|
61
|
-
const begin = useCallback(() => {
|
|
62
|
-
setRestingHeight(measured.current);
|
|
63
|
-
edit.begin();
|
|
64
|
-
}, [edit]);
|
|
65
|
-
|
|
66
|
-
const onKeyPress = useCallback(
|
|
67
|
-
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
68
|
-
// Enter is a PARAGRAPH BREAK here — this is prose, and `numberOfLines > 1`
|
|
69
|
-
// is what declares that everywhere else in the family. Blur commits.
|
|
70
|
-
if (e.nativeEvent.key === "Escape") edit.cancel();
|
|
71
|
-
},
|
|
72
|
-
[edit],
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const floor = Math.max((minLines ?? DEFAULT_MIN_LINES) * APPROX_LINE_HEIGHT, restingHeight);
|
|
76
|
-
|
|
77
|
-
const display: ReactNode = value === "" ? "" : (
|
|
78
|
-
<View onLayout={onViewLayout}>
|
|
79
|
-
<Markdown>{value}</Markdown>
|
|
80
|
-
</View>
|
|
81
|
-
);
|
|
82
49
|
|
|
83
50
|
return (
|
|
84
51
|
<InlineEditFrame
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
52
|
+
// ALWAYS the editor. `editing` is a MOUNT decision, and mounting
|
|
53
|
+
// unconditionally is what makes the resting and editing states the same
|
|
54
|
+
// element — see the note above on why nothing else holds the no-shift
|
|
55
|
+
// guarantee.
|
|
56
|
+
editing
|
|
57
|
+
// ...but the ✓/✕ verbs still key off a REAL edit: at rest there is nothing
|
|
58
|
+
// to accept and nothing to revert.
|
|
59
|
+
editOpen={edit.editing}
|
|
60
|
+
display={value}
|
|
89
61
|
placeholder={placeholder}
|
|
90
|
-
onBegin={begin}
|
|
62
|
+
onBegin={edit.begin}
|
|
91
63
|
controls="blur"
|
|
92
64
|
onCommit={() => void edit.commit()}
|
|
93
65
|
onCancel={edit.cancel}
|
|
@@ -95,23 +67,24 @@ export function InlineMarkdown(props: InlineMarkdownProps) {
|
|
|
95
67
|
error={edit.error}
|
|
96
68
|
disabled={disabled}
|
|
97
69
|
variant={variant}
|
|
70
|
+
numberOfLines={numberOfLines}
|
|
98
71
|
accessibilityLabel={accessibilityLabel}
|
|
99
72
|
>
|
|
100
|
-
<
|
|
101
|
-
|
|
73
|
+
<MarkdownEditor
|
|
74
|
+
// The DRAFT only exists once editing has begun; before that the field
|
|
75
|
+
// must show the record's value, or an external update would be masked by
|
|
76
|
+
// a stale draft from the last edit (`begin` is what syncs them).
|
|
77
|
+
value={edit.editing ? edit.draft : value}
|
|
102
78
|
onChangeText={edit.setDraft}
|
|
79
|
+
// Focus IS begin — the element the reader clicks is already the editor,
|
|
80
|
+
// so the gesture that used to mount one now just opens a draft on it.
|
|
81
|
+
onFocus={edit.begin}
|
|
103
82
|
onBlur={() => void edit.commit()}
|
|
104
|
-
|
|
105
|
-
multiline
|
|
106
|
-
// The input ARRIVES with the edit, so it takes the caret on mount. This
|
|
107
|
-
// is the case `InlineTextInput` cannot serve — it is permanently mounted
|
|
108
|
-
// and deliberately has no `autoFocus`, so a revealed field there would
|
|
109
|
-
// cost the reader a second click.
|
|
110
|
-
autoFocus
|
|
83
|
+
toolbar={false}
|
|
111
84
|
placeholder={placeholder}
|
|
112
|
-
|
|
85
|
+
numberOfLines={numberOfLines}
|
|
113
86
|
disabled={disabled}
|
|
114
|
-
|
|
87
|
+
accessibilityLabel={accessibilityLabel}
|
|
115
88
|
/>
|
|
116
89
|
</InlineEditFrame>
|
|
117
90
|
);
|
|
@@ -54,8 +54,8 @@ export interface InlineTextInputProps {
|
|
|
54
54
|
* a payment term — where reserving the space keeps the page still. It is wrong
|
|
55
55
|
* for open prose, and it fails in the worst way: the field renders a box the
|
|
56
56
|
* value does not fit, with no ellipsis, no scrollbar and no scroll, so the
|
|
57
|
-
* reader is given no evidence that anything is missing
|
|
58
|
-
*
|
|
57
|
+
* reader is given no evidence that anything is missing — a long note simply
|
|
58
|
+
* loses its tail.
|
|
59
59
|
*
|
|
60
60
|
* Growing costs nothing this control was protecting: the field is ONE input in
|
|
61
61
|
* both states, so a grown box is the same height resting and editing, and
|
|
@@ -139,9 +139,8 @@ export function InlineTextInput(props: InlineTextInputProps) {
|
|
|
139
139
|
// same string identically, and nothing enforces it: the padding drifted
|
|
140
140
|
// (8px), then the transparent border (1px), then the ink (zinc-900 vs the
|
|
141
141
|
// UA's black) — each fixed in turn, each revealing the next. The last one
|
|
142
|
-
// cannot be fixed at all:
|
|
143
|
-
//
|
|
144
|
-
// (row centroid 12.985 -> 13.985, identical ink, identical column
|
|
142
|
+
// cannot be fixed at all: the glyph mass sits one DEVICE pixel lower while
|
|
143
|
+
// editing (identical ink, identical column
|
|
145
144
|
// centroid). Both paths compute a 10px text top, so it is not a padding
|
|
146
145
|
// mistake — an `<input>` centres its text by FONT METRICS and a `<div>`
|
|
147
146
|
// positions it by LINE BOX, and the residual is a quarter of a CSS pixel.
|
package/src/ledger.tsx
CHANGED
|
@@ -257,9 +257,9 @@ export function LedgerRow(props: LedgerRowProps) {
|
|
|
257
257
|
const rowDetails = useLoticsLocale().ledger.rowDetails;
|
|
258
258
|
const { format, dropMeta } = useLedger();
|
|
259
259
|
// The caption goes entirely rather than shrinking to nothing. Yielding first
|
|
260
|
-
// (below) is the right ORDER but not a floor:
|
|
261
|
-
//
|
|
262
|
-
//
|
|
260
|
+
// (below) is the right ORDER but not a floor: at phone width a long caption
|
|
261
|
+
// still claims most of the text budget, clips, and takes the label down with
|
|
262
|
+
// it. Two separate
|
|
263
263
|
// authors had already worked around this by dropping `meta` at small widths
|
|
264
264
|
// in their own apps, which is the component's job.
|
|
265
265
|
const showMeta = meta != null && meta !== "" && !dropMeta;
|
package/src/locale.tsx
CHANGED
|
@@ -171,6 +171,22 @@ export interface LoticsLocale {
|
|
|
171
171
|
/** `FilesEditor`'s shipped bar pieces (upload, select, select-all, download,
|
|
172
172
|
* remove) and the remove confirm. A HOST verb is a plain `Button` and names
|
|
173
173
|
* itself, so nothing here is about what an app happens to do with a file. */
|
|
174
|
+
markdownToolbar: {
|
|
175
|
+
linkUrl: string;
|
|
176
|
+
heading1: string;
|
|
177
|
+
heading2: string;
|
|
178
|
+
heading3: string;
|
|
179
|
+
bold: string;
|
|
180
|
+
italic: string;
|
|
181
|
+
inlineCode: string;
|
|
182
|
+
bulletList: string;
|
|
183
|
+
numberedList: string;
|
|
184
|
+
quote: string;
|
|
185
|
+
codeBlock: string;
|
|
186
|
+
link: string;
|
|
187
|
+
insertTable: string;
|
|
188
|
+
divider: string;
|
|
189
|
+
};
|
|
174
190
|
filesEditor: {
|
|
175
191
|
upload: string;
|
|
176
192
|
select: string;
|
|
@@ -388,6 +404,22 @@ export const en: LoticsLocale = {
|
|
|
388
404
|
overlay: { close: "Close" },
|
|
389
405
|
fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
|
|
390
406
|
fileThumbnail: { remove: "Remove" },
|
|
407
|
+
markdownToolbar: {
|
|
408
|
+
linkUrl: "Link URL",
|
|
409
|
+
heading1: "Heading 1",
|
|
410
|
+
heading2: "Heading 2",
|
|
411
|
+
heading3: "Heading 3",
|
|
412
|
+
bold: "Bold",
|
|
413
|
+
italic: "Italic",
|
|
414
|
+
inlineCode: "Inline code",
|
|
415
|
+
bulletList: "Bullet list",
|
|
416
|
+
numberedList: "Numbered list",
|
|
417
|
+
quote: "Quote",
|
|
418
|
+
codeBlock: "Code block",
|
|
419
|
+
link: "Link",
|
|
420
|
+
insertTable: "Insert table",
|
|
421
|
+
divider: "Divider",
|
|
422
|
+
},
|
|
391
423
|
filesEditor: {
|
|
392
424
|
upload: "Upload",
|
|
393
425
|
select: "Select",
|
|
@@ -578,6 +610,22 @@ export const vi: LoticsLocale = {
|
|
|
578
610
|
overlay: { close: "Đóng" },
|
|
579
611
|
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm chọn, hoặc dán (Ctrl+V)", drop: "Thả để tải lên" },
|
|
580
612
|
fileThumbnail: { remove: "Xóa" },
|
|
613
|
+
markdownToolbar: {
|
|
614
|
+
linkUrl: "Địa chỉ liên kết",
|
|
615
|
+
heading1: "Tiêu đề 1",
|
|
616
|
+
heading2: "Tiêu đề 2",
|
|
617
|
+
heading3: "Tiêu đề 3",
|
|
618
|
+
bold: "Đậm",
|
|
619
|
+
italic: "Nghiêng",
|
|
620
|
+
inlineCode: "Mã trong dòng",
|
|
621
|
+
bulletList: "Danh sách gạch đầu dòng",
|
|
622
|
+
numberedList: "Danh sách đánh số",
|
|
623
|
+
quote: "Trích dẫn",
|
|
624
|
+
codeBlock: "Khối mã",
|
|
625
|
+
link: "Liên kết",
|
|
626
|
+
insertTable: "Chèn bảng",
|
|
627
|
+
divider: "Đường phân cách",
|
|
628
|
+
},
|
|
581
629
|
filesEditor: {
|
|
582
630
|
upload: "Tải lên",
|
|
583
631
|
select: "Chọn",
|
package/src/markdown.web.tsx
CHANGED
|
@@ -34,12 +34,9 @@ const markdownComponents = {
|
|
|
34
34
|
* Sizing the headings down fixes what a screen SHOWS and nothing about what it
|
|
35
35
|
* ANNOUNCES: an `h2` the author of the text happened to write is still an `h2`,
|
|
36
36
|
* so it lands in heading navigation as a PEER of the page's own sections.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* — a model's call-summary title sitting between two real sections, from inside
|
|
42
|
-
* one row of a feed that can hold twenty more. No visual probe finds this, and
|
|
37
|
+
* A model's call-summary title then sits in the heading outline between two of
|
|
38
|
+
* the page's real sections, from inside one row of a feed that can hold twenty
|
|
39
|
+
* more. No visual probe finds this, and
|
|
43
40
|
* fixing the size is what makes it invisible: the defect stops looking wrong at
|
|
44
41
|
* the exact moment it stops being measurable.
|
|
45
42
|
*
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* WYSIWYG markdown editor chrome. Typography is inherited from `.ui-markdown`
|
|
2
|
+
(markdown.css) — this file only covers the editing surface and ProseMirror. */
|
|
3
|
+
|
|
4
|
+
/* The frame around the DOCUMENT. The toolbar is a sibling above it, so this
|
|
5
|
+
encloses content and nothing else — and it takes a softer radius than a
|
|
6
|
+
control would, because it is a sheet of writing rather than a field. */
|
|
7
|
+
.md-editor {
|
|
8
|
+
border: 1px solid rgba(228, 228, 231, 1);
|
|
9
|
+
border-radius: 16px;
|
|
10
|
+
background-color: rgba(255, 255, 255, 1);
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
/* The ProseMirror editable element is this node's only child; let it fill the
|
|
13
|
+
configured min-height so clicks anywhere in the field focus the editor. */
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* The kit's field grammar, which this editor was not following: hover moves the
|
|
19
|
+
BORDER, focus adds the RING, and the border never changes colour on focus.
|
|
20
|
+
It painted a blue border on focus-within instead — a colour that appears
|
|
21
|
+
nowhere else in the system and a mechanism no other input uses, so the one
|
|
22
|
+
multi-line field on a page announced focus differently from every field
|
|
23
|
+
beside it.
|
|
24
|
+
|
|
25
|
+
Literal rgba because this is plain CSS with no access to the tokens; the
|
|
26
|
+
values are `HOVER_BORDER` (zinc-500) and `FOCUS_RING` (2px zinc-900) from
|
|
27
|
+
`@lotics/ui/control_surface`, and they are the two to update if those move. */
|
|
28
|
+
.md-editor:hover {
|
|
29
|
+
border-color: rgba(113, 113, 122, 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.md-editor:focus-within {
|
|
33
|
+
box-shadow: 0 0 0 2px rgba(24, 24, 27, 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.md-editor[data-disabled="true"] {
|
|
37
|
+
opacity: 0.6;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.md-editor-content {
|
|
41
|
+
flex: 1 1 auto;
|
|
42
|
+
/* position:relative anchors the absolutely-positioned gap cursor. */
|
|
43
|
+
position: relative;
|
|
44
|
+
padding: 8px 12px;
|
|
45
|
+
outline: none;
|
|
46
|
+
white-space: pre-wrap;
|
|
47
|
+
word-wrap: break-word;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.md-editor-content .ProseMirror-selectednode {
|
|
51
|
+
outline: 2px solid rgba(59, 130, 246, 0.6);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.md-editor-content > :first-child {
|
|
55
|
+
padding-top: 0;
|
|
56
|
+
margin-top: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.md-editor-content > :last-child {
|
|
60
|
+
padding-bottom: 0;
|
|
61
|
+
margin-bottom: 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Empty-document placeholder */
|
|
65
|
+
|
|
66
|
+
.md-editor-placeholder::before {
|
|
67
|
+
content: attr(data-placeholder);
|
|
68
|
+
color: rgba(161, 161, 170, 1);
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
height: 0;
|
|
71
|
+
float: left;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ProseMirror selection / gap cursor */
|
|
75
|
+
|
|
76
|
+
.ProseMirror[contenteditable="false"] {
|
|
77
|
+
cursor: default;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.ProseMirror-hideselection *::selection {
|
|
81
|
+
background: transparent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ProseMirror-gapcursor {
|
|
85
|
+
display: none;
|
|
86
|
+
pointer-events: none;
|
|
87
|
+
position: absolute;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ProseMirror-gapcursor::after {
|
|
91
|
+
content: "";
|
|
92
|
+
display: block;
|
|
93
|
+
position: absolute;
|
|
94
|
+
top: -2px;
|
|
95
|
+
width: 20px;
|
|
96
|
+
border-top: 1px solid rgba(24, 24, 27, 1);
|
|
97
|
+
animation: md-editor-blink 1.1s steps(2, start) infinite;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@keyframes md-editor-blink {
|
|
101
|
+
to {
|
|
102
|
+
visibility: hidden;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.ProseMirror-focused .ProseMirror-gapcursor {
|
|
107
|
+
display: block;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Table editing (prosemirror-tables) */
|
|
111
|
+
|
|
112
|
+
.md-editor-content .tableWrapper {
|
|
113
|
+
overflow-x: auto;
|
|
114
|
+
margin: 4px 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.md-editor-content td,
|
|
118
|
+
.md-editor-content th {
|
|
119
|
+
position: relative;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.md-editor-content .selectedCell::after {
|
|
123
|
+
content: "";
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: 0;
|
|
126
|
+
background: rgba(59, 130, 246, 0.16);
|
|
127
|
+
pointer-events: none;
|
|
128
|
+
z-index: 2;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.md-editor-content .column-resize-handle {
|
|
132
|
+
position: absolute;
|
|
133
|
+
right: -2px;
|
|
134
|
+
top: 0;
|
|
135
|
+
bottom: 0;
|
|
136
|
+
width: 4px;
|
|
137
|
+
background-color: rgba(59, 130, 246, 1);
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
z-index: 3;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.md-editor-content.resize-cursor {
|
|
143
|
+
cursor: col-resize;
|
|
144
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TextInputField } from "./text_input_field";
|
|
2
|
+
import type { MarkdownEditorProps } from "./markdown_editor_props";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native fallback for the markdown editor. The WYSIWYG editor is DOM/ProseMirror
|
|
6
|
+
* based and web-only (see `markdown_editor.web.tsx`); on native, markdown is
|
|
7
|
+
* edited as raw text.
|
|
8
|
+
*/
|
|
9
|
+
export function MarkdownEditor(props: MarkdownEditorProps) {
|
|
10
|
+
const { value, onChangeText, onBlur, onFocus, placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
|
|
11
|
+
props;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<TextInputField
|
|
15
|
+
testID={testID}
|
|
16
|
+
value={value}
|
|
17
|
+
onChangeText={onChangeText}
|
|
18
|
+
onBlur={onBlur}
|
|
19
|
+
onFocus={onFocus}
|
|
20
|
+
placeholder={placeholder}
|
|
21
|
+
numberOfLines={numberOfLines ?? 6}
|
|
22
|
+
multiline
|
|
23
|
+
autoGrow
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
autoFocus={autoFocus}
|
|
26
|
+
accessibilityLabel={accessibilityLabel}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import "./markdown.css";
|
|
2
|
+
import "./markdown_editor.css";
|
|
3
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
createMarkdownEditorView,
|
|
6
|
+
type MarkdownEditorHandle,
|
|
7
|
+
} from "@lotics/markdown-editor/editor_view";
|
|
8
|
+
import { getInputLineHeight } from "./text_utils";
|
|
9
|
+
import { useContainerSize } from "./size_boundary";
|
|
10
|
+
import type { MarkdownEditorProps } from "./markdown_editor_props";
|
|
11
|
+
import { MarkdownToolbar } from "./markdown_toolbar.web";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* WYSIWYG markdown editor. Users edit rich text directly; the value stays a
|
|
15
|
+
* plain markdown string. The ProseMirror engine lives in `@lotics/markdown-editor`.
|
|
16
|
+
*/
|
|
17
|
+
export function MarkdownEditor(props: MarkdownEditorProps) {
|
|
18
|
+
const { value, onChangeText, onBlur, onFocus, toolbar = true, placeholder, numberOfLines, disabled, autoFocus, accessibilityLabel, testID } =
|
|
19
|
+
props;
|
|
20
|
+
|
|
21
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const handleRef = useRef<MarkdownEditorHandle | null>(null);
|
|
23
|
+
const valueRef = useRef(value);
|
|
24
|
+
const callbacks = useRef({ onChangeText, onBlur, onFocus });
|
|
25
|
+
callbacks.current = { onChangeText, onBlur, onFocus };
|
|
26
|
+
|
|
27
|
+
const [handle, setHandle] = useState<MarkdownEditorHandle | null>(null);
|
|
28
|
+
const [, forceRender] = useReducer((tick: number) => tick + 1, 0);
|
|
29
|
+
|
|
30
|
+
const { small } = useContainerSize();
|
|
31
|
+
const minHeight = (numberOfLines ?? 6) * getInputLineHeight(small) + 16;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const host = hostRef.current;
|
|
35
|
+
if (!host) return;
|
|
36
|
+
|
|
37
|
+
const created = createMarkdownEditorView(host, {
|
|
38
|
+
markdown: valueRef.current,
|
|
39
|
+
editable: !disabled,
|
|
40
|
+
placeholder,
|
|
41
|
+
autoFocus,
|
|
42
|
+
ariaLabel: accessibilityLabel,
|
|
43
|
+
onChange: (markdown) => {
|
|
44
|
+
valueRef.current = markdown;
|
|
45
|
+
callbacks.current.onChangeText(markdown);
|
|
46
|
+
},
|
|
47
|
+
onStateChange: forceRender,
|
|
48
|
+
onBlur: () => callbacks.current.onBlur?.(),
|
|
49
|
+
onFocus: () => callbacks.current.onFocus?.(),
|
|
50
|
+
});
|
|
51
|
+
handleRef.current = created;
|
|
52
|
+
setHandle(created);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
created.destroy();
|
|
56
|
+
handleRef.current = null;
|
|
57
|
+
setHandle(null);
|
|
58
|
+
};
|
|
59
|
+
}, [disabled, placeholder, autoFocus, accessibilityLabel]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (value !== valueRef.current) {
|
|
63
|
+
valueRef.current = value;
|
|
64
|
+
handleRef.current?.setMarkdown(value);
|
|
65
|
+
}
|
|
66
|
+
}, [value]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
// ONE node for the field. It used to be three — an RN `View` holding a
|
|
70
|
+
// styled `.md-editor` holding a `.md-editor-host` — and each was doing a job
|
|
71
|
+
// the others could: ProseMirror mounts into whatever node it is handed, so
|
|
72
|
+
// the styled frame IS the host, and the outer `View` existed only to sit two
|
|
73
|
+
// siblings next to each other, which a fragment does without a box.
|
|
74
|
+
//
|
|
75
|
+
// The toolbar is a SIBLING of the field, not a band inside it. What the
|
|
76
|
+
// border encloses is the document; the controls that act on it belong
|
|
77
|
+
// outside that boundary, the way a page's actions sit outside the page.
|
|
78
|
+
<>
|
|
79
|
+
{toolbar ? <MarkdownToolbar handle={handle} disabled={disabled} /> : null}
|
|
80
|
+
<div
|
|
81
|
+
ref={hostRef}
|
|
82
|
+
className="md-editor"
|
|
83
|
+
data-testid={testID}
|
|
84
|
+
data-disabled={disabled ? "true" : undefined}
|
|
85
|
+
style={{ minHeight }}
|
|
86
|
+
/>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared prop contract for the markdown editor. Declared platform-neutrally so
|
|
3
|
+
* the web (`markdown_editor.web.tsx`) and native (`markdown_editor.tsx`)
|
|
4
|
+
* implementations cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
export interface MarkdownEditorProps {
|
|
7
|
+
value: string;
|
|
8
|
+
onChangeText: (text: string) => void;
|
|
9
|
+
onBlur?: () => void;
|
|
10
|
+
onFocus?: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Show the formatting toolbar.
|
|
13
|
+
*
|
|
14
|
+
* OFF for a field embedded in a record row: it is a band that appears with the
|
|
15
|
+
* edit, and anything that appears moves everything under it. On a surface
|
|
16
|
+
* whose whole job is the document — a knowledge doc, a settings screen — it is
|
|
17
|
+
* always there and shifts nothing, so it stays on.
|
|
18
|
+
*/
|
|
19
|
+
toolbar?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
numberOfLines?: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
autoFocus?: boolean;
|
|
24
|
+
/** Accessible name. Required in spirit whenever the field's visible label is
|
|
25
|
+
* a sibling rather than a wrapping `FormField` — a `DetailRow`, an inline
|
|
26
|
+
* editor's frame. */
|
|
27
|
+
accessibilityLabel?: string;
|
|
28
|
+
testID?: string;
|
|
29
|
+
}
|