@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.
Files changed (45) hide show
  1. package/AGENTS.md +9 -2
  2. package/MIGRATION.md +61 -5
  3. package/docs/ai_patterns.md +2 -3
  4. package/docs/catalog.md +40 -17
  5. package/docs/composition.md +143 -42
  6. package/docs/data_entry.md +15 -8
  7. package/docs/reviewing.md +24 -12
  8. package/docs/templates.md +1 -1
  9. package/package.json +10 -1
  10. package/src/accordion.tsx +2 -2
  11. package/src/avatar_group.tsx +1 -1
  12. package/src/avatar_size.ts +2 -3
  13. package/src/card_select_item.tsx +7 -6
  14. package/src/color_tokens.ts +22 -6
  15. package/src/combobox.tsx +11 -1
  16. package/src/control_surface.ts +22 -16
  17. package/src/date_stamp.tsx +1 -3
  18. package/src/detail_row.tsx +4 -5
  19. package/src/dialog.tsx +13 -6
  20. package/src/drawer.tsx +18 -4
  21. package/src/filter_chip.tsx +1 -2
  22. package/src/finding.tsx +24 -7
  23. package/src/form_markdown_editor.tsx +15 -0
  24. package/src/heading_altitude.ts +65 -0
  25. package/src/inline_markdown.tsx +49 -76
  26. package/src/inline_text_input.tsx +4 -5
  27. package/src/ledger.tsx +3 -3
  28. package/src/locale.tsx +48 -0
  29. package/src/markdown.web.tsx +3 -6
  30. package/src/markdown_editor.css +144 -0
  31. package/src/markdown_editor.tsx +29 -0
  32. package/src/markdown_editor.web.tsx +89 -0
  33. package/src/markdown_editor_props.ts +29 -0
  34. package/src/markdown_toolbar.web.tsx +231 -0
  35. package/src/matrix.tsx +1 -1
  36. package/src/modal.tsx +10 -1
  37. package/src/popover.tsx +151 -142
  38. package/src/pressable_row.tsx +24 -15
  39. package/src/reference_field.tsx +11 -9
  40. package/src/section_heading.tsx +57 -71
  41. package/src/stepper.tsx +2 -3
  42. package/src/summary.tsx +3 -3
  43. package/src/table.tsx +4 -1
  44. package/src/text_utils.ts +3 -4
  45. package/src/timeline.tsx +3 -4
@@ -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 { Markdown } from "./markdown";
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 at rest, edited raw. */
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
- /** The editor's floor, in lines, before the resting height is taken into
18
- * account. Keeps a short or empty value from opening a one-line slot. */
19
- minLines?: number;
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: rendered at rest, raw source while editing.
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
- * This one SWAPS, and it is allowed to for the same reason the number and date
31
- * editors do: its resting display and its editor show genuinely different
32
- * strings (`**bold**` against **bold**), so the sub-pixel drift that forced
33
- * `InlineTextInput` to stay a single element is not perceptible here. That rule
34
- * same string ⇒ one element, different string ⇒ swap — is what decides which
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
- * **The editor never opens SHORTER than the view it replaced.** A swap editor
38
- * is supposed to leave the box where it was; for markdown it cannot exactly,
39
- * because rendered prose and its source are different lengths by nature. What it
40
- * CAN do is refuse to collapse: the resting height is measured and becomes the
41
- * input's floor, so pressing a long note opens a long editor. Growing is the
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
- * Reach for it wherever a field holds markdown a PERSON wrote. A model's output
46
- * is read-only (you re-run it, you do not hand-edit it) and belongs in a plain
47
- * `Markdown`.
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, minLines, accessibilityLabel } = props;
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
- // Unlike `InlineTextInput`, `editing` here IS the mount: the input exists
86
- // only during an edit, which is what makes the rendered view possible.
87
- editing={edit.editing}
88
- display={display}
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
- <TextInputField
101
- value={edit.draft}
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
- onKeyPress={onKeyPress}
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
- accessibilityLabel={accessibilityLabel}
85
+ numberOfLines={numberOfLines}
113
86
  disabled={disabled}
114
- style={{ minHeight: floor }}
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. Measured on a record
58
- * note, 285 characters drew 76px of a 116px value — two lines gone, silently.
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: measured on an interior 4x crop with no border or
143
- // ring in frame, the glyph mass sits 1 DEVICE pixel lower while editing
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: measured at 375, a 30-character
261
- // caption still wanted 184px of a ~250px text budget and clipped and took
262
- // the label down with it, ellipsising a 74px identity at 71px. Two separate
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",
@@ -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
- * Measured on a record surface, the outline read
38
- *
39
- * Activity · "Tóm tắt cuộc họp (AI) — …" · "Bài học …" · Details · Next action
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
+ }