@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 +2 -2
- package/docs/catalog.md +18 -7
- package/docs/data_entry.md +13 -5
- package/package.json +10 -1
- package/src/brand_mark.tsx +9 -1
- package/src/form_markdown_editor.tsx +15 -0
- package/src/inline_markdown.tsx +49 -76
- package/src/locale.tsx +48 -0
- 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/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`
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
the
|
|
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).
|
|
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
|
package/docs/data_entry.md
CHANGED
|
@@ -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`
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
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",
|
package/src/brand_mark.tsx
CHANGED
|
@@ -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
|
+
}
|
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
|
);
|
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
|
+
}
|