@colixsystems/widget-sdk 0.273.0 → 0.275.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/README.md CHANGED
@@ -86,7 +86,15 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
86
86
 
87
87
  ## Status
88
88
 
89
- `v0.273.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
89
+ `v0.275.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
90
+
91
+ ### What's new in 0.275.0 (contract 1.225.0)
92
+
93
+ **`<MarkdownInput>` styles its field as you type.** The field the user writes in now draws the markdown live: headings at their size, bold, italic, code and links in place, the markers kept in the text but dimmed. So there is one place to read and edit the result instead of a raw-markdown box over a separate preview. The stored value is exactly what was typed, so existing content and `<RichText>` output are unchanged. `showPreview` now defaults to `false`. Set it to `true` to keep drawing the rendered `<RichText>` copy underneath, for content with images or tables, which the field shows only as their markers. The field is a `TextInput` with styled children on iOS and Android, and a plain-text editable element on the web Player, with its own undo history.
94
+
95
+ ### What's new in 0.274.0 (contract 1.224.0)
96
+
97
+ **The shared widget strings ship in Swedish.** Every entry in `CONTRACT.sharedTranslationKeys` now carries a hand-written `sv` value beside `en` (`cancel` is `{ en: "Cancel", sv: "Avbryt" }`), so a Swedish workspace gets the right word instead of a machine translation of a lone word. Translation seeding also matches a regional language to its primary one: a workspace language `sv-SE` takes the `sv` text a widget's `manifest.translations` ships, unless the widget ships `sv-SE` itself.
90
98
 
91
99
  ### What's new in 0.273.0 (contract 1.223.0)
92
100
 
@@ -3632,7 +3640,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
3632
3640
  | `filter` | Keeps the rows that match. A map `{ column: "op:value" }` (an array of expressions puts a range on one column), or an array `[{ column, operator, value }]`. |
3633
3641
  | `filterMode` | `"and"` (the default) keeps rows matching every condition; `"or"` keeps rows matching any. |
3634
3642
  | `q` | Free-text search: a case-insensitive substring match across every text column of the table (encrypted columns excepted). |
3635
- | `sort` | `{ field, dir }` with `dir` `"asc"` or `"desc"`, or the string `"field:desc"`. One column; `created_at` and `updated_at` work too. |
3643
+ | `sort` | `{ field, dir }` with `dir` `"asc"` or `"desc"`, or the string `"field:desc"`. One column; `created_at` and `updated_at` work too. Newest first is `{ field: "created_at", dir: "desc" }`. `"-field"` or an unknown column is refused: `data` stays empty and `error` names the sortable columns. |
3636
3644
  | `limit` | Rows per page: 50 when left out, at most 1000. |
3637
3645
  | `offset` | Rows to skip, for the next page. |
3638
3646
  | `includeCapabilities` | `true` stamps each row with `_capabilities`, what the signed-in user may do to it. |
@@ -3665,12 +3673,13 @@ export default function ContactList({ tableId }) {
3665
3673
  limit: PAGE,
3666
3674
  offset: page * PAGE,
3667
3675
  }));
3668
- const { data, total, loading } = useDatastoreQuery(tableId, query);
3676
+ const { data, total, loading, error } = useDatastoreQuery(tableId, query);
3669
3677
  const pick = (next) => { setStatus(next); setPage(0); };
3670
3678
  return (
3671
3679
  <View>
3672
3680
  <TextInput value={draft} onChangeText={setDraft} placeholder="Search" />
3673
3681
  <Pressable onPress={() => pick(status === "Won" ? "" : "Won")}><Text>Won</Text></Pressable>
3682
+ {error ? <Text>{error.message}</Text> : null}
3674
3683
  {loading ? <Text>Loading…</Text> : data.map((row) => <Text key={row.id}>{row.name}</Text>)}
3675
3684
  <Text>{total ?? 0} contacts</Text>
3676
3685
  {(page + 1) * PAGE < (total ?? 0) ? (
package/dist/contract.cjs CHANGED
@@ -3473,7 +3473,7 @@ const PRIMITIVES = [
3473
3473
  {
3474
3474
  name: "MarkdownInput",
3475
3475
  description:
3476
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a pipe-table skeleton with the first header cell selected, a Link button that opens a form for the link text plus its target, and a live `<RichText>` preview of the result underneath. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3476
+ 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field that styles the markdown live as the user types (headings sized, bold, italic, code and links drawn in place, the markers kept in the text but dimmed), so the user reads and edits the result in one place; a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list); a Table button that inserts a pipe-table skeleton with the first header cell selected; and a Link button that opens a form for the link text plus its target. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text, exactly as typed. Props: `value`, `onChange(next)`, `placeholder`, `showPreview` (default false; true also draws a rendered `<RichText>` copy underneath, for content the field can only show as markers: images and tables), `previewLabel` (default "Preview", the heading above that copy), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3477
3477
  rnComponent: null,
3478
3478
  docsUrl: null,
3479
3479
  },
@@ -5127,32 +5127,33 @@ function sharedTranslationKey(key) {
5127
5127
  return `${SHARED_TRANSLATION_PREFIX}${key}`;
5128
5128
  }
5129
5129
 
5130
- // The predefined set. Map of bare key -> { en } (English default only; the
5131
- // tenant adds target-language values once in the Translations admin and every
5132
- // widget inherits them). Adding/removing a key is a CONTRACT change (it grows
5133
- // the dictionary every tenant gets), so the list lives with the contract and
5134
- // the seeder reads it from here — never a second copy.
5130
+ // The predefined set. Map of bare key -> { en, sv }. Swedish is shipped by
5131
+ // hand because a lone word machine-translates to the wrong sense ("Cancel" ->
5132
+ // "Avboka"); other languages are translated in the Translations admin and
5133
+ // every widget inherits them. Adding/removing a key is a CONTRACT change (it
5134
+ // grows the dictionary every tenant gets), so the list lives with the contract
5135
+ // and the seeder reads it from here — never a second copy.
5135
5136
  const SHARED_TRANSLATION_KEYS = Object.freeze({
5136
- submit: { en: "Submit" },
5137
- cancel: { en: "Cancel" },
5138
- save: { en: "Save" },
5139
- delete: { en: "Delete" },
5140
- edit: { en: "Edit" },
5141
- close: { en: "Close" },
5142
- confirm: { en: "Confirm" },
5143
- back: { en: "Back" },
5144
- next: { en: "Next" },
5145
- previous: { en: "Previous" },
5146
- yes: { en: "Yes" },
5147
- no: { en: "No" },
5148
- ok: { en: "OK" },
5149
- loading: { en: "Loading…" },
5150
- search: { en: "Search" },
5151
- no_results: { en: "No results" },
5152
- required: { en: "Required" },
5153
- retry: { en: "Retry" },
5154
- add: { en: "Add" },
5155
- remove: { en: "Remove" },
5137
+ submit: { en: "Submit", sv: "Skicka" },
5138
+ cancel: { en: "Cancel", sv: "Avbryt" },
5139
+ save: { en: "Save", sv: "Spara" },
5140
+ delete: { en: "Delete", sv: "Ta bort" },
5141
+ edit: { en: "Edit", sv: "Redigera" },
5142
+ close: { en: "Close", sv: "Stäng" },
5143
+ confirm: { en: "Confirm", sv: "Bekräfta" },
5144
+ back: { en: "Back", sv: "Tillbaka" },
5145
+ next: { en: "Next", sv: "Nästa" },
5146
+ previous: { en: "Previous", sv: "Föregående" },
5147
+ yes: { en: "Yes", sv: "Ja" },
5148
+ no: { en: "No", sv: "Nej" },
5149
+ ok: { en: "OK", sv: "OK" },
5150
+ loading: { en: "Loading…", sv: "Laddar…" },
5151
+ search: { en: "Search", sv: "Sök" },
5152
+ no_results: { en: "No results", sv: "Inga resultat" },
5153
+ required: { en: "Required", sv: "Obligatoriskt" },
5154
+ retry: { en: "Retry", sv: "Försök igen" },
5155
+ add: { en: "Add", sv: "Lägg till" },
5156
+ remove: { en: "Remove", sv: "Ta bort" },
5156
5157
  });
5157
5158
 
5158
5159
  // True when `key` is one of the predefined shared keys — the discriminator
@@ -6568,7 +6569,11 @@ const CONTRACT = deepFreeze({
6568
6569
  // 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
6569
6570
  // the scope-header mint or sets `X-Widget-Scopes` is refused
6570
6571
  // (`no-scope-header-mint`).
6571
- version: "1.223.0",
6572
+ // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6573
+ // ships a hand-written `sv` value beside `en`.
6574
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6575
+ // live; `showPreview` now defaults to false.
6576
+ version: "1.225.0",
6572
6577
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6573
6578
  hooks: HOOKS,
6574
6579
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -3470,7 +3470,7 @@ const PRIMITIVES = [
3470
3470
  {
3471
3471
  name: "MarkdownInput",
3472
3472
  description:
3473
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a pipe-table skeleton with the first header cell selected, a Link button that opens a form for the link text plus its target, and a live `<RichText>` preview of the result underneath. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3473
+ 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field that styles the markdown live as the user types (headings sized, bold, italic, code and links drawn in place, the markers kept in the text but dimmed), so the user reads and edits the result in one place; a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list); a Table button that inserts a pipe-table skeleton with the first header cell selected; and a Link button that opens a form for the link text plus its target. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text, exactly as typed. Props: `value`, `onChange(next)`, `placeholder`, `showPreview` (default false; true also draws a rendered `<RichText>` copy underneath, for content the field can only show as markers: images and tables), `previewLabel` (default "Preview", the heading above that copy), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3474
3474
  rnComponent: null,
3475
3475
  docsUrl: null,
3476
3476
  },
@@ -5124,32 +5124,33 @@ function sharedTranslationKey(key) {
5124
5124
  return `${SHARED_TRANSLATION_PREFIX}${key}`;
5125
5125
  }
5126
5126
 
5127
- // The predefined set. Map of bare key -> { en } (English default only; the
5128
- // tenant adds target-language values once in the Translations admin and every
5129
- // widget inherits them). Adding/removing a key is a CONTRACT change (it grows
5130
- // the dictionary every tenant gets), so the list lives with the contract and
5131
- // the seeder reads it from here — never a second copy.
5127
+ // The predefined set. Map of bare key -> { en, sv }. Swedish is shipped by
5128
+ // hand because a lone word machine-translates to the wrong sense ("Cancel" ->
5129
+ // "Avboka"); other languages are translated in the Translations admin and
5130
+ // every widget inherits them. Adding/removing a key is a CONTRACT change (it
5131
+ // grows the dictionary every tenant gets), so the list lives with the contract
5132
+ // and the seeder reads it from here — never a second copy.
5132
5133
  const SHARED_TRANSLATION_KEYS = Object.freeze({
5133
- submit: { en: "Submit" },
5134
- cancel: { en: "Cancel" },
5135
- save: { en: "Save" },
5136
- delete: { en: "Delete" },
5137
- edit: { en: "Edit" },
5138
- close: { en: "Close" },
5139
- confirm: { en: "Confirm" },
5140
- back: { en: "Back" },
5141
- next: { en: "Next" },
5142
- previous: { en: "Previous" },
5143
- yes: { en: "Yes" },
5144
- no: { en: "No" },
5145
- ok: { en: "OK" },
5146
- loading: { en: "Loading…" },
5147
- search: { en: "Search" },
5148
- no_results: { en: "No results" },
5149
- required: { en: "Required" },
5150
- retry: { en: "Retry" },
5151
- add: { en: "Add" },
5152
- remove: { en: "Remove" },
5134
+ submit: { en: "Submit", sv: "Skicka" },
5135
+ cancel: { en: "Cancel", sv: "Avbryt" },
5136
+ save: { en: "Save", sv: "Spara" },
5137
+ delete: { en: "Delete", sv: "Ta bort" },
5138
+ edit: { en: "Edit", sv: "Redigera" },
5139
+ close: { en: "Close", sv: "Stäng" },
5140
+ confirm: { en: "Confirm", sv: "Bekräfta" },
5141
+ back: { en: "Back", sv: "Tillbaka" },
5142
+ next: { en: "Next", sv: "Nästa" },
5143
+ previous: { en: "Previous", sv: "Föregående" },
5144
+ yes: { en: "Yes", sv: "Ja" },
5145
+ no: { en: "No", sv: "Nej" },
5146
+ ok: { en: "OK", sv: "OK" },
5147
+ loading: { en: "Loading…", sv: "Laddar…" },
5148
+ search: { en: "Search", sv: "Sök" },
5149
+ no_results: { en: "No results", sv: "Inga resultat" },
5150
+ required: { en: "Required", sv: "Obligatoriskt" },
5151
+ retry: { en: "Retry", sv: "Försök igen" },
5152
+ add: { en: "Add", sv: "Lägg till" },
5153
+ remove: { en: "Remove", sv: "Ta bort" },
5153
5154
  });
5154
5155
 
5155
5156
  // True when `key` is one of the predefined shared keys — the discriminator
@@ -6565,7 +6566,11 @@ const CONTRACT = deepFreeze({
6565
6566
  // 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
6566
6567
  // the scope-header mint or sets `X-Widget-Scopes` is refused
6567
6568
  // (`no-scope-header-mint`).
6568
- version: "1.223.0",
6569
+ // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6570
+ // ships a hand-written `sv` value beside `en`.
6571
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6572
+ // live; `showPreview` now defaults to false.
6573
+ version: "1.225.0",
6569
6574
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6570
6575
  hooks: HOOKS,
6571
6576
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -5712,9 +5712,11 @@ export const RichText: (props: {
5712
5712
 
5713
5713
  /**
5714
5714
  * sc-6970 — the authoring half of `<RichText>`: a multi-line field whose
5715
- * toolbar wraps the current selection in markdown markers, with a live preview
5716
- * of the rendered result. Use it instead of a bare `TextInput` plus hand-rolled
5717
- * formatting buttons, which can only splice literal tags into the text.
5715
+ * toolbar wraps the current selection in markdown markers. sc-14974: the field
5716
+ * styles the markdown as it is typed, markers dimmed, so the author edits the
5717
+ * formatted text directly. Use it instead of a bare `TextInput` plus
5718
+ * hand-rolled formatting buttons, which can only splice literal tags into the
5719
+ * text.
5718
5720
  *
5719
5721
  * @example
5720
5722
  * <MarkdownInput value={draft} onChange={setDraft} placeholder="Write…" />
@@ -5725,9 +5727,12 @@ export const MarkdownInput: (props: {
5725
5727
  /** Receives the next markdown string on every edit. */
5726
5728
  onChange?: (next: string) => void;
5727
5729
  placeholder?: string;
5728
- /** Heading above the live preview. Defaults to "Preview". */
5730
+ /** Heading above the rendered preview. Defaults to "Preview". */
5729
5731
  previewLabel?: string;
5730
- /** Set false to hide the live preview. Defaults to true. */
5732
+ /**
5733
+ * Also draw a rendered `<RichText>` copy under the field, for images and
5734
+ * tables the field shows only as markers. Defaults to false.
5735
+ */
5731
5736
  showPreview?: boolean;
5732
5737
  /** Passed through to the preview's `<RichText>`. */
5733
5738
  renderImage?: (block: {
@@ -1,10 +1,14 @@
1
1
  // sc-6970 — the ONE implementation behind the `<MarkdownInput>` SDK primitive.
2
2
  //
3
3
  // The authoring half of `<RichText>`: a multi-line field whose toolbar wraps the
4
- // author's SELECTION in markdown markers and whose preview renders the result
5
- // live. Widgets previously had only a bare `TextInput`, so a widget asked for a
6
- // formatting toolbar had to splice literal `<strong>`/`<br>` tags into the text
7
- // — markup the author then read as tag soup, with no preview of the real output.
4
+ // author's SELECTION in markdown markers. Widgets previously had only a bare
5
+ // `TextInput`, so a widget asked for a formatting toolbar had to splice literal
6
+ // `<strong>`/`<br>` tags into the text — markup the author then read as tag soup.
7
+ //
8
+ // sc-14974: the field itself is live-styled, so the author edits the formatted
9
+ // text directly. Each host binding supplies its own `LiveField` (a TextInput
10
+ // with styled children natively, a contentEditable on web); the rendered
11
+ // `<RichText>` preview is opt-in for content the field shows only as markers.
8
12
  //
9
13
  // The two platform bindings differ ONLY in where the primitives come from; the
10
14
  // component is defined once here so the hosts cannot drift (CLAUDE.md §3, §8).
@@ -34,8 +38,8 @@ const TOOLS = Object.freeze([
34
38
  },
35
39
  ]);
36
40
 
37
- export function makeMarkdownInput(rn, RichText, Modal, Button) {
38
- const { Text, View, Pressable, TextInput } = rn;
41
+ export function makeMarkdownInput(rn, RichText, Modal, Button, LiveField) {
42
+ const { Text, View, Pressable } = rn;
39
43
  const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Modal, Button);
40
44
 
41
45
  function MarkdownInput({
@@ -43,7 +47,7 @@ export function makeMarkdownInput(rn, RichText, Modal, Button) {
43
47
  onChange,
44
48
  placeholder,
45
49
  previewLabel = "Preview",
46
- showPreview = true,
50
+ showPreview = false,
47
51
  renderImage,
48
52
  pages,
49
53
  minHeight = 150,
@@ -55,6 +59,7 @@ export function makeMarkdownInput(rn, RichText, Modal, Button) {
55
59
  const theme = useHostTheme();
56
60
  const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
57
61
  const [selection, setSelection] = React.useState({ start: 0, end: 0 });
62
+ const fieldRef = React.useRef(null);
58
63
  const [linkOpen, setLinkOpen] = React.useState(false);
59
64
 
60
65
  const text = typeof value === "string" ? value : "";
@@ -70,6 +75,8 @@ export function makeMarkdownInput(rn, RichText, Modal, Button) {
70
75
  const edit = markdownEditOps[toolKey];
71
76
  if (!edit) return;
72
77
  const next = edit(text, selection, options);
78
+ const field = fieldRef.current;
79
+ if (field && typeof field.recordEdit === "function") field.recordEdit();
73
80
  onChange(next.value);
74
81
  setSelection(next.selection);
75
82
  },
@@ -167,15 +174,15 @@ export function makeMarkdownInput(rn, RichText, Modal, Button) {
167
174
  ),
168
175
  ),
169
176
  ),
170
- React.createElement(TextInput, {
177
+ React.createElement(LiveField, {
178
+ ref: fieldRef,
171
179
  value: text,
172
180
  onChangeText: onChange,
173
181
  onSelectionChange,
182
+ selection,
174
183
  placeholder,
175
- placeholderTextColor: tokens.mutedColor,
176
184
  accessibilityLabel,
177
- multiline: true,
178
- numberOfLines: 6,
185
+ tokens,
179
186
  style: fieldStyle,
180
187
  testID: testID ? `${testID}-field` : undefined,
181
188
  }),
@@ -1,17 +1,20 @@
1
1
  // sc-6970 — `<MarkdownInput>` (web). Binds the shared implementation in
2
- // ./markdown-input-view.js to react-native-web's primitives, and to the web
3
- // `<RichText>` it renders its preview with.
2
+ // ./markdown-input-view.js to react-native-web's primitives, the web
3
+ // `<RichText>` it renders its preview with, and the web live field.
4
4
 
5
5
  import { ReactNative } from "./text-scale.js";
6
6
  import { makeMarkdownInput } from "./markdown-input-view.js";
7
+ import { MarkdownLiveField } from "./markdown-live-field.js";
7
8
  import { RichText } from "./richtext.js";
8
9
  import { Modal } from "./modal.js";
9
10
  import { Button } from "./button.js";
10
11
  import { TextInput } from "./primitives.js";
11
12
 
13
+ // TextInput stays for the link form, which is built from the same `rn`.
12
14
  export const MarkdownInput = makeMarkdownInput(
13
15
  { ...ReactNative, TextInput },
14
16
  RichText,
15
17
  Modal,
16
18
  Button,
19
+ MarkdownLiveField,
17
20
  );
@@ -5,6 +5,7 @@
5
5
  import { View, Pressable, ScrollView } from "react-native";
6
6
  import { Text, TextInput } from "./bounded-text.native.js";
7
7
  import { makeMarkdownInput } from "./markdown-input-view.js";
8
+ import { MarkdownLiveField } from "./markdown-live-field.native.js";
8
9
  import { RichText } from "./richtext.native.js";
9
10
  import { Modal } from "./modal.native.js";
10
11
  import { Button } from "./button.native.js";
@@ -16,4 +17,5 @@ export const MarkdownInput = makeMarkdownInput(
16
17
  RichText,
17
18
  Modal,
18
19
  Button,
20
+ MarkdownLiveField,
19
21
  );
@@ -0,0 +1,312 @@
1
+ // sc-14974 — the live-styled field behind `<MarkdownInput>` (web).
2
+ //
3
+ // A `<textarea>` holds one string in one style, so the browser half is a
4
+ // plain-text `contentEditable` instead: the author types into the styled text
5
+ // itself. React cannot own the children of a node the browser is editing, so
6
+ // this element is painted by hand from `markdownLiveSegments` and read back
7
+ // as text. The native mirror is ./markdown-live-field.native.js.
8
+
9
+ import React from "react";
10
+ import { markdownLiveSegments } from "./markdown.js";
11
+ import { markdownLiveRunStyle } from "./richtext-tokens.js";
12
+ import { flattenStyle } from "./flatten-style.js";
13
+ import { withTouchInputFontFloor } from "./touch-input-font-floor.js";
14
+
15
+ const SENTINEL = "data-live-sentinel";
16
+ const HISTORY_LIMIT = 100;
17
+ // Keystrokes closer together than this undo as one step, like a textarea.
18
+ const HISTORY_COALESCE_MS = 500;
19
+
20
+ function hasCoarsePointer() {
21
+ return (
22
+ typeof window !== "undefined" &&
23
+ typeof window.matchMedia === "function" &&
24
+ window.matchMedia("(pointer: coarse)").matches
25
+ );
26
+ }
27
+
28
+ function isBlock(node) {
29
+ return node.nodeName === "DIV" || node.nodeName === "P";
30
+ }
31
+
32
+ // The browser may split text, add a `<br>` for Enter, or wrap a pasted line
33
+ // in a block; each of those reads back as the newline it stands for.
34
+ function readText(node, dropTrailingBreak = false) {
35
+ const children = [...node.childNodes];
36
+ // A final <br> only holds the last line open, so it is never content.
37
+ const last = children[children.length - 1];
38
+ if (dropTrailingBreak && last && last.nodeName === "BR") children.pop();
39
+ let out = "";
40
+ children.forEach((child, i) => {
41
+ if (child.nodeType === 3) out += child.data;
42
+ else if (child.nodeName === "BR") {
43
+ if (!child.hasAttribute(SENTINEL)) out += "\n";
44
+ } else {
45
+ if (isBlock(child) && i > 0) out += "\n";
46
+ out += readText(child);
47
+ }
48
+ });
49
+ return out;
50
+ }
51
+
52
+ const fieldText = (root) => readText(root, true);
53
+
54
+ function firstDifference(a, b) {
55
+ let i = 0;
56
+ while (i < a.length && a.charAt(i) === b.charAt(i)) i += 1;
57
+ return i;
58
+ }
59
+
60
+ function offsetOf(root, node, offset) {
61
+ const range = root.ownerDocument.createRange();
62
+ range.setStart(root, 0);
63
+ range.setEnd(node, offset);
64
+ return readText(range.cloneContents());
65
+ }
66
+
67
+ function readSelection(root) {
68
+ const selection = root.ownerDocument.getSelection();
69
+ if (!selection || selection.rangeCount === 0) return null;
70
+ const { anchorNode, focusNode } = selection;
71
+ if (!root.contains(anchorNode) || !root.contains(focusNode)) return null;
72
+ const anchor = offsetOf(root, anchorNode, selection.anchorOffset).length;
73
+ const focus = offsetOf(root, focusNode, selection.focusOffset).length;
74
+ return { start: Math.min(anchor, focus), end: Math.max(anchor, focus) };
75
+ }
76
+
77
+ // Only ever called on a tree `paint` built: text nodes inside spans.
78
+ function locate(root, target) {
79
+ let remaining = target;
80
+ for (const child of root.childNodes) {
81
+ const textNode = child.nodeType === 3 ? child : child.firstChild;
82
+ if (!textNode || textNode.nodeType !== 3) continue;
83
+ if (remaining <= textNode.data.length) return [textNode, remaining];
84
+ remaining -= textNode.data.length;
85
+ }
86
+ return [root, Math.max(root.childNodes.length - 1, 0)];
87
+ }
88
+
89
+ function writeSelection(root, { start, end }) {
90
+ const selection = root.ownerDocument.getSelection();
91
+ if (!selection) return;
92
+ const range = root.ownerDocument.createRange();
93
+ range.setStart(...locate(root, start));
94
+ range.setEnd(...locate(root, end));
95
+ selection.removeAllRanges();
96
+ selection.addRange(range);
97
+ }
98
+
99
+ function cssOf(style) {
100
+ const css = {};
101
+ for (const [key, value] of Object.entries(style)) {
102
+ css[key] = typeof value === "number" && key !== "fontWeight" ? `${value}px` : value;
103
+ }
104
+ return css;
105
+ }
106
+
107
+ function paint(root, value, tokens) {
108
+ const doc = root.ownerDocument;
109
+ root.textContent = "";
110
+ for (const run of markdownLiveSegments(value)) {
111
+ const span = doc.createElement("span");
112
+ span.textContent = run.text;
113
+ Object.assign(span.style, cssOf(markdownLiveRunStyle(run, tokens)));
114
+ root.appendChild(span);
115
+ }
116
+ // Without it a trailing newline has no line to draw, nor a caret to hold.
117
+ const sentinel = doc.createElement("br");
118
+ sentinel.setAttribute(SENTINEL, "");
119
+ root.appendChild(sentinel);
120
+ }
121
+
122
+ // The field arrives as a React Native style; this element is plain DOM.
123
+ function fieldCss(style) {
124
+ const { lineHeight, ...rest } = flattenStyle(withTouchInputFontFloor(style, hasCoarsePointer()));
125
+ delete rest.textAlignVertical;
126
+ return {
127
+ ...rest,
128
+ ...(typeof lineHeight === "number" ? { lineHeight: `${lineHeight}px` } : null),
129
+ ...(rest.borderWidth ? { borderStyle: "solid" } : null),
130
+ overflowY: "auto",
131
+ whiteSpace: "pre-wrap",
132
+ overflowWrap: "anywhere",
133
+ outlineOffset: 2,
134
+ };
135
+ }
136
+
137
+ function isUndoKey(event) {
138
+ return (event.metaKey || event.ctrlKey) && !event.altKey;
139
+ }
140
+
141
+
142
+ export const MarkdownLiveField = React.forwardRef(function MarkdownLiveField(
143
+ { value, onChangeText, onSelectionChange, selection, placeholder, accessibilityLabel, tokens, style, testID },
144
+ handle,
145
+ ) {
146
+ const ref = React.useRef(null);
147
+ const painted = React.useRef(null);
148
+ const emitted = React.useRef(null);
149
+ const announced = React.useRef(false);
150
+ const composing = React.useRef(false);
151
+ const [isComposing, setComposing] = React.useState(false);
152
+ const [, rerender] = React.useReducer((n) => n + 1, 0);
153
+ const history = React.useRef({ past: [], future: [], at: 0 });
154
+ const latest = React.useRef(null);
155
+
156
+ // `coalesce` joins the open step; a typed edit leaves its step open for more.
157
+ const record = React.useCallback((coalesce, typing) => {
158
+ const stack = history.current;
159
+ const now = Date.now();
160
+ if (!coalesce || now - stack.at > HISTORY_COALESCE_MS || stack.past.length === 0) {
161
+ stack.past.push({ value: latest.current.value, selection: latest.current.selection });
162
+ if (stack.past.length > HISTORY_LIMIT) stack.past.shift();
163
+ }
164
+ stack.at = typing ? now : 0;
165
+ stack.future = [];
166
+ }, []);
167
+
168
+ // The toolbar edits the value from outside; this makes that edit undoable.
169
+ React.useImperativeHandle(
170
+ handle,
171
+ () => ({
172
+ recordEdit() {
173
+ record(false);
174
+ announced.current = true;
175
+ },
176
+ }),
177
+ [record],
178
+ );
179
+
180
+ const step = (redo) => {
181
+ const stack = history.current;
182
+ const entry = (redo ? stack.future : stack.past).pop();
183
+ if (!entry) return;
184
+ (redo ? stack.past : stack.future).push({ value, selection });
185
+ stack.at = 0;
186
+ if (typeof onChangeText !== "function") return;
187
+ emitted.current = entry.value;
188
+ onChangeText(entry.value);
189
+ const root = ref.current;
190
+ if (root) {
191
+ paint(root, entry.value, tokens);
192
+ painted.current = { value: entry.value, tokens };
193
+ if (entry.selection) writeSelection(root, entry.selection);
194
+ }
195
+ };
196
+ latest.current = { value, selection, onSelectionChange, step };
197
+
198
+ // Runs after every render, so an edit the parent did not take reverts.
199
+ React.useLayoutEffect(() => {
200
+ const root = ref.current;
201
+ if (!root) return;
202
+ const unchanged =
203
+ painted.current && painted.current.value === value && painted.current.tokens === tokens;
204
+ if (unchanged && fieldText(root) === value) return;
205
+ const typed = emitted.current === value;
206
+ if (painted.current && !unchanged && !typed && !announced.current) {
207
+ // The parent replaced the text (a reset, another record): old steps no longer apply.
208
+ history.current = { past: [], future: [], at: 0 };
209
+ }
210
+ announced.current = false;
211
+ const caret = readSelection(root);
212
+ paint(root, value, tokens);
213
+ painted.current = { value, tokens };
214
+ if (caret && (typed || unchanged)) writeSelection(root, caret);
215
+ });
216
+
217
+ React.useEffect(() => {
218
+ const root = ref.current;
219
+ if (!root) return undefined;
220
+ const doc = root.ownerDocument;
221
+ const report = () => {
222
+ const next = readSelection(root);
223
+ const handler = latest.current.onSelectionChange;
224
+ if (next && typeof handler === "function") handler({ nativeEvent: { selection: next } });
225
+ };
226
+ // The Edit menu, a context menu and shake-to-undo arrive here, not as keys.
227
+ const onBeforeInput = (event) => {
228
+ if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return;
229
+ event.preventDefault();
230
+ latest.current.step(event.inputType === "historyRedo");
231
+ };
232
+ doc.addEventListener("selectionchange", report);
233
+ root.addEventListener("beforeinput", onBeforeInput);
234
+ return () => {
235
+ doc.removeEventListener("selectionchange", report);
236
+ root.removeEventListener("beforeinput", onBeforeInput);
237
+ };
238
+ }, []);
239
+
240
+ const onInput = () => {
241
+ const root = ref.current;
242
+ if (composing.current || !root) return;
243
+ const next = fieldText(root);
244
+ if (next !== value && typeof onChangeText === "function") {
245
+ // A typed space or newline starts a new undo step, as in a textarea.
246
+ const inserted = next.length > value.length ? next.charAt(firstDifference(value, next)) : "";
247
+ record(!/\s/.test(inserted), true);
248
+ emitted.current = next;
249
+ onChangeText(next);
250
+ }
251
+ rerender();
252
+ };
253
+
254
+ // Every repaint replaces the nodes the browser's own undo stack points at.
255
+ const onKeyDown = (event) => {
256
+ if (!isUndoKey(event)) return;
257
+ const key = event.key.toLowerCase();
258
+ if (key !== "z" && key !== "y") return;
259
+ event.preventDefault();
260
+ step(key === "y" || event.shiftKey);
261
+ };
262
+
263
+ const css = fieldCss(style);
264
+ return React.createElement(
265
+ "div",
266
+ { style: { position: "relative" } },
267
+ React.createElement("div", {
268
+ ref,
269
+ contentEditable: "plaintext-only",
270
+ suppressContentEditableWarning: true,
271
+ role: "textbox",
272
+ "aria-multiline": "true",
273
+ "aria-label": accessibilityLabel,
274
+ "aria-placeholder": placeholder,
275
+ "data-testid": testID,
276
+ spellCheck: true,
277
+ style: css,
278
+ onInput,
279
+ onKeyDown,
280
+ onCompositionStart: () => {
281
+ composing.current = true;
282
+ setComposing(true);
283
+ },
284
+ onCompositionEnd: () => {
285
+ composing.current = false;
286
+ setComposing(false);
287
+ onInput();
288
+ },
289
+ }),
290
+ !value && !isComposing && placeholder
291
+ ? React.createElement(
292
+ "div",
293
+ {
294
+ "aria-hidden": "true",
295
+ // The field's own box, transparent, so the hint sits where text will.
296
+ style: {
297
+ ...css,
298
+ position: "absolute",
299
+ top: 0,
300
+ left: 0,
301
+ right: 0,
302
+ pointerEvents: "none",
303
+ color: tokens.mutedColor,
304
+ backgroundColor: "transparent",
305
+ borderColor: "transparent",
306
+ },
307
+ },
308
+ placeholder,
309
+ )
310
+ : null,
311
+ );
312
+ });
@@ -0,0 +1,36 @@
1
+ // sc-14974 — the live-styled field behind `<MarkdownInput>` (native).
2
+ //
3
+ // iOS and Android both accept nested `<Text>` as a `TextInput`'s children and
4
+ // draw them as attributed text, so the formatting appears in the field the
5
+ // author types into. The runs join back to `value` exactly, which is what keeps
6
+ // the input controlled. The web mirror is ./markdown-live-field.js.
7
+
8
+ import React from "react";
9
+ import { Text, TextInput } from "./bounded-text.native.js";
10
+ import { markdownLiveSegments } from "./markdown.js";
11
+ import { markdownLiveRunStyle } from "./richtext-tokens.js";
12
+
13
+ // The OS keeps this field's undo history, so the toolbar has nothing to record.
14
+ export const MarkdownLiveField = React.forwardRef(function MarkdownLiveField(
15
+ { value, onChangeText, onSelectionChange, placeholder, accessibilityLabel, tokens, style, testID },
16
+ ref,
17
+ ) {
18
+ const runs = React.useMemo(() => markdownLiveSegments(value), [value]);
19
+ return React.createElement(
20
+ TextInput,
21
+ {
22
+ ref,
23
+ onChangeText,
24
+ onSelectionChange,
25
+ placeholder,
26
+ placeholderTextColor: tokens.mutedColor,
27
+ accessibilityLabel,
28
+ multiline: true,
29
+ style,
30
+ testID,
31
+ },
32
+ runs.map((run, i) =>
33
+ React.createElement(Text, { key: i, style: markdownLiveRunStyle(run, tokens) }, run.text),
34
+ ),
35
+ );
36
+ });
package/dist/markdown.js CHANGED
@@ -192,10 +192,18 @@ const LINK_PATTERN =
192
192
  const PLAIN_PATTERN =
193
193
  /(!?)([^\s\S])([^\s\S])|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
194
194
 
195
- /** Splits one line into `{ text, bold?, italic?, code?, href? }` spans. */
196
- function parseInline(line) {
197
- const source = typeof line === "string" ? line : "";
198
- const spans = [];
195
+ /**
196
+ * Splits one line into positioned tokens: `{ type: "text", start, end }`, or
197
+ * `{ type: "bold"|"italic"|"code", start, end, contentStart, contentEnd }`, or
198
+ * `{ type: "link", start, end, contentStart, contentEnd, href }` whose content
199
+ * is the RAW label. `parseInline` and the live editor both read these, so the
200
+ * editor cannot style a construct the renderer would not draw.
201
+ */
202
+ function scanInline(source) {
203
+ const tokens = [];
204
+ const text = (start, end) => {
205
+ if (end > start) tokens.push({ type: "text", start, end });
206
+ };
199
207
  // One pass over the four delimiters. Alternation order matters: `**` must
200
208
  // be tried before `*` or the bold marker is consumed as two italics.
201
209
  // The link branch scans forward from EVERY `[`, so a line carrying many
@@ -208,34 +216,68 @@ function parseInline(line) {
208
216
  let cursor = 0;
209
217
  let match = pattern.exec(source);
210
218
  while (match) {
211
- if (match.index > cursor) {
212
- spans.push({ text: source.slice(cursor, match.index) });
213
- }
219
+ text(cursor, match.index);
220
+ const end = match.index + match[0].length;
214
221
  if (match[3] !== undefined) {
215
222
  // An image is its own whole-line block, so an inline `![alt](src)` stays
216
223
  // literal text rather than becoming a link to the image.
217
- if (match[1]) spans.push({ text: match[0] });
218
- else for (const span of linkSpans(match[2], match[3])) spans.push(span);
224
+ if (match[1]) text(match.index, end);
225
+ else {
226
+ const contentStart = match.index + 1;
227
+ tokens.push({
228
+ type: "link",
229
+ start: match.index,
230
+ end,
231
+ contentStart,
232
+ contentEnd: contentStart + match[2].length,
233
+ href: match[3],
234
+ });
235
+ }
219
236
  } else if (match[4] || match[8]) {
220
237
  const delim = match[4] || match[8];
221
238
  const content = match[4] ? match[5] : match[9];
222
- const closeEnd = match.index + match[0].length;
223
- if (!emphasisFlanks(delim, content, source, match.index, closeEnd)) {
239
+ if (!emphasisFlanks(delim, content, source, match.index, end)) {
224
240
  // Not emphasis after all: keep the delimiter as text and rescan from
225
241
  // just after it, so a later legitimate pair can still match.
226
- spans.push({ text: delim });
242
+ text(match.index, match.index + delim.length);
227
243
  cursor = match.index + delim.length;
228
244
  pattern.lastIndex = cursor;
229
245
  match = pattern.exec(source);
230
246
  continue;
231
247
  }
232
- if (match[4]) spans.push({ text: content, bold: true });
233
- else spans.push({ text: content, italic: true });
234
- } else if (match[6]) spans.push({ text: match[7], code: true });
235
- cursor = match.index + match[0].length;
248
+ tokens.push({
249
+ type: match[4] ? "bold" : "italic",
250
+ start: match.index,
251
+ end,
252
+ contentStart: match.index + delim.length,
253
+ contentEnd: end - delim.length,
254
+ });
255
+ } else if (match[6]) {
256
+ tokens.push({
257
+ type: "code",
258
+ start: match.index,
259
+ end,
260
+ contentStart: match.index + 1,
261
+ contentEnd: end - 1,
262
+ });
263
+ }
264
+ cursor = end;
236
265
  match = pattern.exec(source);
237
266
  }
238
- if (cursor < source.length) spans.push({ text: source.slice(cursor) });
267
+ text(cursor, source.length);
268
+ return tokens;
269
+ }
270
+
271
+ /** Splits one line into `{ text, bold?, italic?, code?, href? }` spans. */
272
+ function parseInline(line) {
273
+ const source = typeof line === "string" ? line : "";
274
+ const spans = [];
275
+ for (const token of scanInline(source)) {
276
+ const content = source.slice(token.contentStart, token.contentEnd);
277
+ if (token.type === "text") spans.push({ text: source.slice(token.start, token.end) });
278
+ else if (token.type === "link") spans.push(...linkSpans(content, token.href));
279
+ else spans.push({ text: content, [token.type]: true });
280
+ }
239
281
  if (spans.length === 0) return [{ text: source }];
240
282
  // A rejected delimiter leaves plain fragments either side of it; fold them
241
283
  // back so a line of prose stays one span instead of three.
@@ -490,6 +532,116 @@ export function parseMarkdown(text, options) {
490
532
  return blocks;
491
533
  }
492
534
 
535
+ const LIVE_STYLE_KEYS = ["marker", "bold", "italic", "code", "href", "heading"];
536
+
537
+ function sameLiveStyle(a, b) {
538
+ return LIVE_STYLE_KEYS.every((key) => (a[key] || false) === (b[key] || false));
539
+ }
540
+
541
+ /**
542
+ * sc-14974 — `text` cut into styled runs for the live editor. Joining every
543
+ * run's `text` gives back `text` byte for byte: nothing is trimmed or joined,
544
+ * and markdown markers stay in place flagged `marker` so the editor can dim
545
+ * them. Each run is `{ text, marker?, bold?, italic?, code?, href?, heading? }`
546
+ * with `heading` the level. Lines are classified in `parseMarkdown`'s order.
547
+ */
548
+ export function markdownLiveSegments(text) {
549
+ const source = typeof text === "string" ? text : "";
550
+ const runs = [];
551
+ const push = (value, style) => {
552
+ if (!value) return;
553
+ const last = runs[runs.length - 1];
554
+ if (last && sameLiveStyle(last, style)) last.text += value;
555
+ else runs.push({ text: value, ...style });
556
+ };
557
+ const inline = (value, base) => {
558
+ for (const token of scanInline(value)) {
559
+ if (token.type === "text") {
560
+ push(value.slice(token.start, token.end), base);
561
+ continue;
562
+ }
563
+ const content = value.slice(token.contentStart, token.contentEnd);
564
+ push(value.slice(token.start, token.contentStart), { ...base, marker: true });
565
+ if (token.type === "link") inline(content, { ...base, href: token.href });
566
+ else push(content, { ...base, [token.type]: true });
567
+ push(value.slice(token.contentEnd, token.end), { ...base, marker: true });
568
+ }
569
+ };
570
+ const tableRow = (line, base) => {
571
+ let cellStart = 0;
572
+ for (let k = 0; k < line.length; k += 1) {
573
+ if (line.charAt(k) === "\\" && line.charAt(k + 1) === "|") k += 1;
574
+ else if (line.charAt(k) === "|") {
575
+ inline(line.slice(cellStart, k), base);
576
+ push("|", { marker: true });
577
+ cellStart = k + 1;
578
+ }
579
+ }
580
+ inline(line.slice(cellStart), base);
581
+ };
582
+
583
+ const lines = source.split("\n");
584
+ let inFence = false;
585
+ let tableStart = -1;
586
+ let tableEnd = -1;
587
+ for (let i = 0; i < lines.length; i += 1) {
588
+ if (i > 0) push("\n", {});
589
+ const line = lines[i];
590
+ const trimmed = line.trimEnd();
591
+ const tail = line.slice(trimmed.length);
592
+
593
+ if (inFence) {
594
+ if (FENCE_CLOSE_RE.test(line)) inFence = false;
595
+ push(line, inFence ? { code: true } : { marker: true });
596
+ continue;
597
+ }
598
+ if (FENCE_OPEN_RE.test(trimmed)) {
599
+ inFence = true;
600
+ push(line, { marker: true });
601
+ continue;
602
+ }
603
+
604
+ if (i > tableEnd && TABLE_ROW_RE.test(line) && TABLE_DIVIDER_RE.test(lines[i + 1] || "")) {
605
+ tableStart = i;
606
+ tableEnd = i + 1;
607
+ while (tableEnd + 1 < lines.length && TABLE_ROW_RE.test(lines[tableEnd + 1])) tableEnd += 1;
608
+ }
609
+ if (i <= tableEnd) {
610
+ if (i === tableStart + 1) push(line, { marker: true });
611
+ else tableRow(line, i === tableStart ? { bold: true } : {});
612
+ continue;
613
+ }
614
+
615
+ const lead = trimmed.length - trimmed.trimStart().length;
616
+ const image = IMAGE_RE.exec(trimmed.trimStart());
617
+ if (image) {
618
+ const altStart = lead + 2;
619
+ const altEnd = altStart + image[1].length;
620
+ push(line.slice(0, altStart), { marker: true });
621
+ push(line.slice(altStart, altEnd), {});
622
+ push(line.slice(altEnd, trimmed.length), { marker: true });
623
+ push(tail, {});
624
+ continue;
625
+ }
626
+
627
+ const heading = HEADING_RE.exec(trimmed);
628
+ const listItem = heading ? null : BULLET_RE.exec(trimmed) || ORDERED_RE.exec(trimmed);
629
+ if (heading || listItem) {
630
+ const content = heading ? heading[2] : listItem[listItem.length - 1];
631
+ const base = heading ? { heading: heading[1].length } : {};
632
+ const contentStart = trimmed.length - content.length;
633
+ push(trimmed.slice(0, contentStart), { ...base, marker: true });
634
+ inline(content, base);
635
+ push(tail, base);
636
+ continue;
637
+ }
638
+
639
+ inline(trimmed, {});
640
+ push(tail, {});
641
+ }
642
+ return runs;
643
+ }
644
+
493
645
  function spansToText(spans) {
494
646
  return spans.map((span) => span.text).join("");
495
647
  }
@@ -57,3 +57,41 @@ export function resolveRichTextTokens(theme) {
57
57
  }
58
58
 
59
59
  export { IMAGE_WIDTH as RICH_TEXT_IMAGE_WIDTH };
60
+
61
+ /** The style one inline span is drawn with, by `<RichText>` and the editor. */
62
+ export function richTextSpanStyle(span, tokens) {
63
+ return {
64
+ ...(span.bold ? { fontWeight: "700" } : null),
65
+ ...(span.italic ? { fontStyle: "italic" } : null),
66
+ // A code span needs a mono face; the theme has no mono token.
67
+ ...(span.code
68
+ ? { fontFamily: "monospace", fontSize: tokens.codeSize, color: tokens.mutedColor }
69
+ : null),
70
+ // sc-7349: a link looks like a link whether or not THIS host can follow it.
71
+ ...(span.href ? { color: tokens.accent, textDecorationLine: "underline" } : null),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * sc-14974 — the style one `markdownLiveSegments` run is drawn with in
77
+ * `<MarkdownInput>`: the span's `<RichText>` look, sized as its heading, with
78
+ * markers dimmed so the text reads as the result while staying editable.
79
+ */
80
+ export function markdownLiveRunStyle(run, tokens) {
81
+ const headingSize = run.heading
82
+ ? tokens.headingSizes[run.heading] || tokens.headingSizes[3]
83
+ : null;
84
+ return {
85
+ ...(headingSize
86
+ ? {
87
+ fontFamily: tokens.headingFont,
88
+ fontWeight: "700",
89
+ fontSize: headingSize,
90
+ // The field's body leading would overlap a larger heading line.
91
+ lineHeight: Math.round(headingSize * 1.3),
92
+ }
93
+ : null),
94
+ ...richTextSpanStyle(run, tokens),
95
+ ...(run.marker ? { color: tokens.mutedColor, textDecorationLine: "none" } : null),
96
+ };
97
+ }
@@ -11,7 +11,7 @@
11
11
  import React from "react";
12
12
  import { useHostTheme, useHostNavigation } from "./hooks.js";
13
13
  import { parseMarkdown, isHttpImageSrc } from "./markdown.js";
14
- import { resolveRichTextTokens } from "./richtext-tokens.js";
14
+ import { resolveRichTextTokens, richTextSpanStyle } from "./richtext-tokens.js";
15
15
 
16
16
  export function makeRichText(rn) {
17
17
  const { Text, View, Image } = rn;
@@ -25,23 +25,7 @@ export function makeRichText(rn) {
25
25
  key: i,
26
26
  onPress: followable ? () => onLinkPress(span.href) : undefined,
27
27
  accessibilityRole: followable ? "link" : undefined,
28
- style: [
29
- span.bold ? { fontWeight: "700" } : null,
30
- span.italic ? { fontStyle: "italic" } : null,
31
- span.code
32
- ? {
33
- // A code span needs a mono face; the theme has no mono token.
34
- fontFamily: "monospace",
35
- fontSize: tokens.codeSize,
36
- color: tokens.mutedColor,
37
- }
38
- : null,
39
- // sc-7349: a link looks like a link whether or not THIS host can
40
- // follow it, so the editor preview shows the reader's document.
41
- span.href
42
- ? { color: tokens.accent, textDecorationLine: "underline" }
43
- : null,
44
- ],
28
+ style: richTextSpanStyle(span, tokens),
45
29
  },
46
30
  span.text,
47
31
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.273.0",
3
+ "version": "0.275.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -3470,7 +3470,7 @@ const PRIMITIVES = [
3470
3470
  {
3471
3471
  name: "MarkdownInput",
3472
3472
  description:
3473
- 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a pipe-table skeleton with the first header cell selected, a Link button that opens a form for the link text plus its target, and a live `<RichText>` preview of the result underneath. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default "Preview"), `showPreview` (default true), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3473
+ 'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a multi-line field that styles the markdown live as the user types (headings sized, bold, italic, code and links drawn in place, the markers kept in the text but dimmed), so the user reads and edits the result in one place; a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list); a Table button that inserts a pipe-table skeleton with the first header cell selected; and a Link button that opens a form for the link text plus its target. Use it rather than a bare `TextInput` plus formatting buttons of your own; an editor you do build (one that only bolds, a checklist editor) still stores markdown markers and previews with `<RichText>` — buttons that splice `<strong>`/`<br>`/`<table>` tags into the string leave the author reading tag soup with no preview of the real output. The stored value is markdown text, exactly as typed. Props: `value`, `onChange(next)`, `placeholder`, `showPreview` (default false; true also draws a rendered `<RichText>` copy underneath, for content the field can only show as markers: images and tables), `previewLabel` (default "Preview", the heading above that copy), `renderImage` (passed through to the preview), `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name, the way `renderImage` is supplied for filestore images; without it the form offers only an external address, because a widget cannot know the app\'s pages on its own), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`. Links in the preview are deliberately inert, so a tap while editing cannot navigate away from the draft.',
3474
3474
  rnComponent: null,
3475
3475
  docsUrl: null,
3476
3476
  },
@@ -5124,32 +5124,33 @@ function sharedTranslationKey(key) {
5124
5124
  return `${SHARED_TRANSLATION_PREFIX}${key}`;
5125
5125
  }
5126
5126
 
5127
- // The predefined set. Map of bare key -> { en } (English default only; the
5128
- // tenant adds target-language values once in the Translations admin and every
5129
- // widget inherits them). Adding/removing a key is a CONTRACT change (it grows
5130
- // the dictionary every tenant gets), so the list lives with the contract and
5131
- // the seeder reads it from here — never a second copy.
5127
+ // The predefined set. Map of bare key -> { en, sv }. Swedish is shipped by
5128
+ // hand because a lone word machine-translates to the wrong sense ("Cancel" ->
5129
+ // "Avboka"); other languages are translated in the Translations admin and
5130
+ // every widget inherits them. Adding/removing a key is a CONTRACT change (it
5131
+ // grows the dictionary every tenant gets), so the list lives with the contract
5132
+ // and the seeder reads it from here — never a second copy.
5132
5133
  const SHARED_TRANSLATION_KEYS = Object.freeze({
5133
- submit: { en: "Submit" },
5134
- cancel: { en: "Cancel" },
5135
- save: { en: "Save" },
5136
- delete: { en: "Delete" },
5137
- edit: { en: "Edit" },
5138
- close: { en: "Close" },
5139
- confirm: { en: "Confirm" },
5140
- back: { en: "Back" },
5141
- next: { en: "Next" },
5142
- previous: { en: "Previous" },
5143
- yes: { en: "Yes" },
5144
- no: { en: "No" },
5145
- ok: { en: "OK" },
5146
- loading: { en: "Loading…" },
5147
- search: { en: "Search" },
5148
- no_results: { en: "No results" },
5149
- required: { en: "Required" },
5150
- retry: { en: "Retry" },
5151
- add: { en: "Add" },
5152
- remove: { en: "Remove" },
5134
+ submit: { en: "Submit", sv: "Skicka" },
5135
+ cancel: { en: "Cancel", sv: "Avbryt" },
5136
+ save: { en: "Save", sv: "Spara" },
5137
+ delete: { en: "Delete", sv: "Ta bort" },
5138
+ edit: { en: "Edit", sv: "Redigera" },
5139
+ close: { en: "Close", sv: "Stäng" },
5140
+ confirm: { en: "Confirm", sv: "Bekräfta" },
5141
+ back: { en: "Back", sv: "Tillbaka" },
5142
+ next: { en: "Next", sv: "Nästa" },
5143
+ previous: { en: "Previous", sv: "Föregående" },
5144
+ yes: { en: "Yes", sv: "Ja" },
5145
+ no: { en: "No", sv: "Nej" },
5146
+ ok: { en: "OK", sv: "OK" },
5147
+ loading: { en: "Loading…", sv: "Laddar…" },
5148
+ search: { en: "Search", sv: "Sök" },
5149
+ no_results: { en: "No results", sv: "Inga resultat" },
5150
+ required: { en: "Required", sv: "Obligatoriskt" },
5151
+ retry: { en: "Retry", sv: "Försök igen" },
5152
+ add: { en: "Add", sv: "Lägg till" },
5153
+ remove: { en: "Remove", sv: "Ta bort" },
5153
5154
  });
5154
5155
 
5155
5156
  // True when `key` is one of the predefined shared keys — the discriminator
@@ -6565,7 +6566,11 @@ const CONTRACT = deepFreeze({
6565
6566
  // 1.223.0: stricter (sc-12906) — `scopeMintPatterns`; a widget that calls
6566
6567
  // the scope-header mint or sets `X-Widget-Scopes` is refused
6567
6568
  // (`no-scope-header-mint`).
6568
- version: "1.223.0",
6569
+ // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6570
+ // ships a hand-written `sv` value beside `en`.
6571
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6572
+ // live; `showPreview` now defaults to false.
6573
+ version: "1.225.0",
6569
6574
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6570
6575
  hooks: HOOKS,
6571
6576
  primitives: PRIMITIVES,