@lotics/ui 45.2.0 → 45.4.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 CHANGED
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing); the swap rule for a NEW editor (same string ⇒ one element, different string ⇒ swap) worked through its two live cases — `InlineMarkdown` renders prose at rest and refuses to collapse when opened, while a URL takes `InlineTextInput`'s `link` TREATMENT because marking it changes its ink and not its characters, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); a choice that GATES a form is usually one not to ask (two doors to one room is not a chooser; show the dominant path, offer the other beside its submit, and give a recovery link a quieter treatment than an alternative way in); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing); the swap rule for a NEW editor (same string ⇒ one element, different string ⇒ swap) worked through its two live cases — `InlineMarkdown` holds the SAME WYSIWYG the product's knowledge docs use (`MarkdownEditor`, ProseMirror on web, raw text on native) mounted AT REST, so focusing it moves nothing and the prose stays selectable — the case that shows a swap can be made to look stable but never to be it, while a URL takes `InlineTextInput`'s `link` TREATMENT because marking it changes its ink and not its characters, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **a fact band is read at a GLANCE and a glance is WIDE** (header facts paired into columns rather than stacked one per row — the tell is a screen reported as plain whose treatment is fine, spending its height on facts and its width on nothing; plus a record owes the reader the SIZE of its subject, summed from what the page already holds), **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). Selection is never the brand — a neutral ground on a row, a dark doubled edge on a chip or card, with `accent_wash` kept for attention; a lookup key is not a column (the search matches it); `Table` sheds and stacks rather than scrolling horizontally, because narrowing is what asks whether a column earns its width. |
21
21
  | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 11 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), FIRST PAINT (an entry screen showing zero inputs is gated by something, usually a mode chooser), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
@@ -100,7 +100,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
100
100
  `error={overdue ? … }` is the tell: it makes a date due tomorrow read like one due in three
101
101
  months. → [catalog.md §Dates & times](./docs/catalog.md).
102
102
  - **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** `@lotics/ui/brand_mark`
103
- draws Facebook / Zalo / LinkedIn / Google Meet as solid single-colour silhouettes — a reader
103
+ draws Facebook / Zalo / LinkedIn / X / Google Meet as solid single-colour silhouettes — a reader
104
104
  scanning a feed recognises the logo before the word. The names are an allowlist, so a channel
105
105
  with no mark (a phone call, an event, a website) fails to typecheck and falls back to `Icon`
106
106
  instead of borrowing another company's logo. → [catalog.md §Text & formatting](./docs/catalog.md).
package/docs/catalog.md CHANGED
@@ -161,12 +161,22 @@ the navigation ink as a TREATMENT on the same single element — never a swapped
161
161
  in `actions`),
162
162
  `InlineNumberInput`, `InlineSelect` (single or
163
163
  `multi`), `InlineMemberSelect`, `InlineDatePicker`, `InlineTimePicker`,
164
- **`InlineMarkdown`** (markdown RENDERED at rest, raw source while editing — the
165
- member that swaps because its two states show different strings; its editor never
166
- opens shorter than the view it replaced, so pressing a long note cannot collapse
167
- the box, and **`minLines`** sets the floor for a short one); a
164
+ **`InlineMarkdown`** (a markdown field holding **`MarkdownEditor`** the same WYSIWYG
165
+ the product's own knowledge docs use, not a raw textarea. It does NOT swap: the editor
166
+ is mounted at rest and merely gains a draft on focus, so focusing moves nothing and
167
+ the prose stays SELECTABLE rather than sitting under a button. Its toolbar is off for
168
+ that reason — a band that appears with the edit pushes everything under it down.
169
+ **`numberOfLines`** is the reserve, as everywhere else in the family); a
168
170
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
169
- input chrome, so it aligns pixel-for-pixel). A stack of labelled field rows lives in
171
+ input chrome, so it aligns pixel-for-pixel). **`MarkdownEditor`** is that editor standalone a WYSIWYG whose value stays a plain
172
+ markdown string, with a formatting toolbar as a SIBLING of the field (what the border
173
+ encloses is the document; the controls that act on it sit outside it). Web is
174
+ ProseMirror via `@lotics/markdown-editor`; native falls back to raw-text editing, and
175
+ the platform-neutral `MarkdownEditorProps` is what stops the two drifting. Wrap it in a
176
+ label with **`FormMarkdownEditor`**. Pass **`accessibilityLabel`** whenever the visible
177
+ label is a sibling rather than a wrapping `FormField` — a `DetailRow`, an inline
178
+ editor's frame — or the editable surface is an unnamed text box. A stack of labelled
179
+ field rows lives in
170
180
  `DetailTable` + `DetailRow`; the record's identity band is `RecordSummary`; its money
171
181
  summary is `Ledger`.
172
182
 
@@ -466,12 +476,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
466
476
  `SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
467
477
  value; composition.md § Eyebrow vs group lead has the table.
468
478
  - **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
469
- happened on — `facebook`, `zalo`, `linkedin`, `google-meet`. Solid single-colour silhouettes in
479
+ happened on — `facebook`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes in
470
480
  each brand's own hex, not the full-colour logos: a byline draws these at 14–20px and a
471
481
  five-colour logo turns to mud there. `size` (default 16) and `muted` (draw in the text colour).
472
482
  The name list is an ALLOWLIST — a channel with no mark will not typecheck, so the call site
473
483
  falls back to a generic `Icon` rather than rendering the wrong company's logo. Not for the app's
474
- own glyphs; that is `icon`.
484
+ own glyphs; that is `icon`. `x` is the one mark whose brand colour is BLACK, so it is the one
485
+ that needs `muted` on a dark ground; the rest carry their own hue either way.
475
486
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
476
487
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
477
488
  native; takes a markdown `children` string. **`variant`** decides whose type
@@ -132,11 +132,19 @@ field), the string changes anyway, so those editors do swap and should. **The ru
132
132
  editor: same string in both states ⇒ one element; different string ⇒ swap.**
133
133
 
134
134
  Two worked cases sit on either side of it, and they look alike until you apply the rule.
135
- **`InlineMarkdown` swaps**: `**bold**` and **bold** are different strings, so the quarter pixel is
136
- invisible and rendering prose at rest is worth having. A swap editor is supposed to leave the box
137
- where it was, which markdown cannot do exactly so it does the half that matters and refuses to
138
- COLLAPSE: the resting height becomes the editor's floor, because growing is tolerable where
139
- shrinking pulls the text out from under the reader and jumps everything below it.
135
+ **`InlineMarkdown` does NOT swap, and the reason it tried to is the lesson.** Its editor is the
136
+ WYSIWYG `MarkdownEditor`, so both states show rendered prose the rule's first limb, one element.
137
+ It was nonetheless built as a swap on a COST argument (a ProseMirror view per field is not free the
138
+ way an `<input>` is), and every defect that followed came from that one decision: the box changed
139
+ height on open, a toolbar rolled in and pushed the form down, and the resting state was a `Pressable`
140
+ — so it wore the pointer cursor and `userSelect: none` denied a reader the ability to select a
141
+ sentence out of their own note.
142
+
143
+ None of those are fixable while two elements draw one value; a measured height floor only papers
144
+ over the first. **When "the layout must not move" is a requirement rather than a preference, one
145
+ element is the only thing that delivers it** — a swap can be made to look stable, never to be it. So
146
+ the editor is mounted at rest and gains a draft on focus, and the per-field cost is paid on purpose.
147
+ If a screen ever holds dozens of these, the answer is fewer markdown fields on it, not a swap.
140
148
  **A LINK does not swap**: marking a URL changes its ink, not its characters, so `InlineTextInput`
141
149
  takes a **`link`** treatment on its single `<input>` rather than swapping in a `TextLink`. Pressing
142
150
  the field still edits — an `Open` `InlineButton` in `actions` is how you reach the destination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.2.0",
3
+ "version": "45.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -169,6 +169,14 @@
169
169
  "./inline_slot": "./src/inline_slot.tsx",
170
170
  "./inline_static": "./src/inline_static.tsx",
171
171
  "./inline_files": "./src/inline_files.tsx",
172
+ "./inline_markdown": "./src/inline_markdown.tsx",
173
+ "./markdown_editor": {
174
+ "react-native": "./src/markdown_editor.tsx",
175
+ "default": "./src/markdown_editor.web.tsx"
176
+ },
177
+ "./markdown_editor_props": "./src/markdown_editor_props.ts",
178
+ "./markdown_editor.css": "./src/markdown_editor.css",
179
+ "./form_markdown_editor": "./src/form_markdown_editor.tsx",
172
180
  "./inline_text_input": "./src/inline_text_input.tsx",
173
181
  "./inline_number_input": "./src/inline_number_input.tsx",
174
182
  "./inline_select": "./src/inline_select.tsx",
@@ -323,6 +331,7 @@
323
331
  "license": "SEE LICENSE IN LICENSE.md",
324
332
  "dependencies": {
325
333
  "@lotics/docx": "^0.3.0",
334
+ "@lotics/markdown-editor": "^0.1.0",
326
335
  "@lotics/xlsx": "^0.2.0",
327
336
  "ai": "^7.0.30",
328
337
  "mdast-util-from-markdown": "^2.0.3",
@@ -7,7 +7,7 @@ import { Svg, Path } from "react-native-svg";
7
7
  * email that is not tied to one provider. Inventing a mark for those is how a
8
8
  * byline ends up claiming a channel the record does not carry.
9
9
  */
10
- export type BrandName = "facebook" | "zalo" | "linkedin" | "google-meet";
10
+ export type BrandName = "facebook" | "zalo" | "linkedin" | "x" | "google-meet";
11
11
 
12
12
  interface Mark {
13
13
  /** The brand's own colour. A mark drawn in anything else is not the mark. */
@@ -40,6 +40,14 @@ const MARKS: Record<BrandName, Mark> = {
40
40
  color: "#0A66C2",
41
41
  path: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
42
42
  },
43
+ x: {
44
+ // X's mark is black, and that is the mark — a brand drawn in a colour it
45
+ // does not use is not the brand. It is the one entry here that depends on
46
+ // its ground: on a dark surface pass `muted`, which draws it in the text
47
+ // colour like every other mark and keeps it visible.
48
+ color: "#000000",
49
+ path: "M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z",
50
+ },
43
51
  "google-meet": {
44
52
  color: "#00897B",
45
53
  path: "M5.53 2.13 0 7.75h5.53zm.398 0v5.62h7.608v3.65l5.47-4.45c-.014-1.22.031-2.25-.025-3.46-.148-1.09-1.287-1.47-2.236-1.36zM23.1 4.32c-.802.295-1.358.995-2.047 1.49-2.506 2.05-4.982 4.12-7.468 6.19 3.025 2.59 6.04 5.18 9.065 7.76 1.218.671 1.428-.814 1.328-1.64v-13a.828.828 0 0 0-.877-.825zM.038 8.15v7.7h5.53v-7.7zm13.577 8.1H6.008v5.62c3.864-.006 7.737.011 11.58-.009 1.02-.07 1.618-1.12 1.468-2.07v-2.51l-5.47-4.68v3.65zm-13.577 0c.02 1.44-.041 2.88.033 4.31.162.948 1.158 1.43 2.047 1.31h3.464v-5.62z",
@@ -0,0 +1,15 @@
1
+ import { FormField, FormFieldProps } from "./form_field";
2
+ import { MarkdownEditor } from "./markdown_editor";
3
+ import type { MarkdownEditorProps } from "./markdown_editor_props";
4
+
5
+ export interface FormMarkdownEditorProps extends Omit<FormFieldProps, "style">, MarkdownEditorProps {}
6
+
7
+ export function FormMarkdownEditor(props: FormMarkdownEditorProps) {
8
+ const { label, description, optional, error, ...editorProps } = props;
9
+
10
+ return (
11
+ <FormField label={label} description={description} error={error} optional={optional}>
12
+ <MarkdownEditor {...editorProps} />
13
+ </FormField>
14
+ );
15
+ }
@@ -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
  );
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",
@@ -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
+ }
@@ -0,0 +1,231 @@
1
+ import { useCallback } from "react";
2
+ import { View } from "react-native";
3
+ import type { MarkdownEditorHandle } from "@lotics/markdown-editor/editor_view";
4
+ import { markdownSchema } from "@lotics/markdown-editor/schema";
5
+ import type { MarkType, NodeType } from "prosemirror-model";
6
+ import {
7
+ applyLink,
8
+ canApplyLink,
9
+ clearLink,
10
+ insertHorizontalRule,
11
+ insertTable,
12
+ isBlockActive,
13
+ isMarkActive,
14
+ toggleBlockquote,
15
+ toggleBulletList,
16
+ toggleCode,
17
+ toggleCodeBlock,
18
+ toggleEm,
19
+ toggleHeading,
20
+ toggleOrderedList,
21
+ toggleStrong,
22
+ } from "@lotics/markdown-editor/commands";
23
+ import { colors } from "./colors";
24
+ import { IconButton } from "./icon_button";
25
+ import type { IconName } from "./icon";
26
+ import { useLoticsLocale } from "./locale";
27
+
28
+ type EditorCommand = typeof toggleStrong;
29
+
30
+ interface ToolbarButton {
31
+ kind: "button";
32
+ icon: IconName;
33
+ tooltip: string;
34
+ active: boolean;
35
+ onPress: () => void;
36
+ }
37
+
38
+ type ToolbarItem = ToolbarButton | { kind: "divider" };
39
+
40
+ /**
41
+ * The formatting band.
42
+ *
43
+ * It renders whether or not there is an editor behind it yet, and whether or not
44
+ * that editor is enabled — the buttons simply go inert. Presence belongs to the
45
+ * caller (`MarkdownEditor`'s `toolbar`), never to a state that changes while the
46
+ * reader is looking: a band that arrives when the view mounts, or when the field
47
+ * gains focus, moves everything under it at exactly the moment attention is
48
+ * there.
49
+ */
50
+ export function MarkdownToolbar({
51
+ handle,
52
+ disabled,
53
+ }: {
54
+ handle: MarkdownEditorHandle | null;
55
+ disabled?: boolean;
56
+ }) {
57
+ const { markdownToolbar: t } = useLoticsLocale();
58
+ const view = handle?.view ?? null;
59
+ const inert = disabled === true || view === null;
60
+
61
+ const run = useCallback(
62
+ (command: EditorCommand) => {
63
+ if (!view) return;
64
+ command(view.state, view.dispatch, view);
65
+ view.focus();
66
+ },
67
+ [view],
68
+ );
69
+
70
+ const handleLink = useCallback(() => {
71
+ if (!view) return;
72
+ if (isMarkActive(view.state, markdownSchema.marks.link)) {
73
+ run(clearLink);
74
+ return;
75
+ }
76
+ if (!canApplyLink(view.state)) return;
77
+ const href = window.prompt(t.linkUrl)?.trim();
78
+ if (href) run(applyLink(href));
79
+ }, [view, run, t]);
80
+
81
+ const state = view?.state ?? null;
82
+
83
+ // With no document there is nothing to be active IN. Wrapping once beats
84
+ // guarding at each of the thirteen reads below, and keeps the button table a
85
+ // table rather than a column of ternaries.
86
+ const blockActive = (nodeType: NodeType, attrs?: Record<string, unknown>): boolean =>
87
+ state !== null && isBlockActive(state, nodeType, attrs);
88
+ const markActive = (markType: MarkType): boolean =>
89
+ state !== null && isMarkActive(state, markType);
90
+ const { nodes, marks } = markdownSchema;
91
+
92
+ const items: ToolbarItem[] = [
93
+ {
94
+ kind: "button",
95
+ icon: "heading-1",
96
+ tooltip: t.heading1,
97
+ active: blockActive(nodes.heading, { level: 1 }),
98
+ onPress: () => run(toggleHeading(1)),
99
+ },
100
+ {
101
+ kind: "button",
102
+ icon: "heading-2",
103
+ tooltip: t.heading2,
104
+ active: blockActive(nodes.heading, { level: 2 }),
105
+ onPress: () => run(toggleHeading(2)),
106
+ },
107
+ {
108
+ kind: "button",
109
+ icon: "heading-3",
110
+ tooltip: t.heading3,
111
+ active: blockActive(nodes.heading, { level: 3 }),
112
+ onPress: () => run(toggleHeading(3)),
113
+ },
114
+ { kind: "divider" },
115
+ {
116
+ kind: "button",
117
+ icon: "bold",
118
+ tooltip: t.bold,
119
+ active: markActive(marks.strong),
120
+ onPress: () => run(toggleStrong),
121
+ },
122
+ {
123
+ kind: "button",
124
+ icon: "italic",
125
+ tooltip: t.italic,
126
+ active: markActive(marks.em),
127
+ onPress: () => run(toggleEm),
128
+ },
129
+ {
130
+ kind: "button",
131
+ icon: "code",
132
+ tooltip: t.inlineCode,
133
+ active: markActive(marks.code),
134
+ onPress: () => run(toggleCode),
135
+ },
136
+ { kind: "divider" },
137
+ {
138
+ kind: "button",
139
+ icon: "list",
140
+ tooltip: t.bulletList,
141
+ active: blockActive(nodes.bullet_list),
142
+ onPress: () => run(toggleBulletList),
143
+ },
144
+ {
145
+ kind: "button",
146
+ icon: "list-ordered",
147
+ tooltip: t.numberedList,
148
+ active: blockActive(nodes.ordered_list),
149
+ onPress: () => run(toggleOrderedList),
150
+ },
151
+ {
152
+ kind: "button",
153
+ icon: "text-quote",
154
+ tooltip: t.quote,
155
+ active: blockActive(nodes.blockquote),
156
+ onPress: () => run(toggleBlockquote),
157
+ },
158
+ {
159
+ kind: "button",
160
+ icon: "code-xml",
161
+ tooltip: t.codeBlock,
162
+ active: blockActive(nodes.code_block),
163
+ onPress: () => run(toggleCodeBlock),
164
+ },
165
+ { kind: "divider" },
166
+ {
167
+ kind: "button",
168
+ icon: "link-2",
169
+ tooltip: t.link,
170
+ active: markActive(marks.link),
171
+ onPress: handleLink,
172
+ },
173
+ {
174
+ kind: "button",
175
+ icon: "table-2",
176
+ tooltip: t.insertTable,
177
+ active: false,
178
+ onPress: () => run(insertTable),
179
+ },
180
+ {
181
+ kind: "button",
182
+ icon: "minus",
183
+ tooltip: t.divider,
184
+ active: false,
185
+ onPress: () => run(insertHorizontalRule),
186
+ },
187
+ ];
188
+
189
+ return (
190
+ <View
191
+ style={{
192
+ flexDirection: "row",
193
+ flexWrap: "wrap",
194
+ alignItems: "center",
195
+ gap: 2,
196
+ // No rule beneath it. The toolbar sits ABOVE the field rather than
197
+ // inside its frame, so it needs no edge of its own — the field's own
198
+ // border is the boundary, and a second line 4px above it was two
199
+ // boundaries for one object.
200
+ paddingBottom: 6,
201
+ }}
202
+ >
203
+ {items.map((item, index) =>
204
+ item.kind === "divider" ? (
205
+ <View
206
+ key={`divider-${index}`}
207
+ style={{ width: 1, height: 22, marginHorizontal: 6, backgroundColor: colors.border }}
208
+ />
209
+ ) : (
210
+ <IconButton
211
+ key={item.icon}
212
+ // OUTSIDE the field, these are standalone controls rather than
213
+ // furniture inside one — and a standalone control is the band's
214
+ // height. Inside the frame `md` was right; on their own they read
215
+ // as undersized next to every other control on the page.
216
+ size="lg"
217
+ icon={item.icon}
218
+ tooltip={item.tooltip}
219
+ onPress={item.onPress}
220
+ disabled={inert}
221
+ iconColor={item.active ? colors.zinc[900] : colors.zinc[600]}
222
+ style={{
223
+ borderRadius: 8,
224
+ backgroundColor: item.active ? colors.zinc[200] : "transparent",
225
+ }}
226
+ />
227
+ ),
228
+ )}
229
+ </View>
230
+ );
231
+ }