@colixsystems/widget-sdk 0.274.0 → 0.275.1

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.274.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.1` — 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.1 (contract 1.225.0)
92
+
93
+ **A filter on a list column asks about its members.** On a `STRING_ARRAY` or `INT_ARRAY` column, `eq` keeps the rows whose list holds that value (exactly, case included), and `empty` / `nempty` keep the rows whose list is empty or not. Any other operator on a list column is refused with `INVALID_FILTER`. Before, every filter on a list column matched nothing (`empty` matched everything), so a widget that shows the documents tagged for one page had to fetch a batch and narrow it in JavaScript. Now it sends `filter: [{ column: pagesField, operator: "eq", value: tag }]`.
94
+
95
+ ### What's new in 0.275.0 (contract 1.225.0)
96
+
97
+ **`<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.
90
98
 
91
99
  ### What's new in 0.274.0 (contract 1.224.0)
92
100
 
@@ -3327,7 +3335,7 @@ Additive: `WidgetManifest` carries an optional `datastoreTemplate` field. When a
3327
3335
  import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@colixsystems/widget-sdk";
3328
3336
  ```
3329
3337
 
3330
- - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
3338
+ - `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register. For published packages: a widget sent with `submit_widget_source` default-exports the component, and its manifest goes only in the `manifest` field.
3331
3339
  - `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
3332
3340
  - `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goToStartPage, scrollToSection, scrollIntoView, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `goToStartPage()` opens the signed-in user's own start page (the page the app opens on for them); `scrollToSection(sectionId)` scrolls the current page to one of its sections (bind the id with a `sectionRef` prop); `scrollIntoView(ref)` scrolls it to an element inside the calling widget; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
3333
3341
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
@@ -3641,7 +3649,7 @@ export default function OpenTasks({ tableId, titleField, statusField }) {
3641
3649
  | `offset` | Rows to skip, for the next page. |
3642
3650
  | `includeCapabilities` | `true` stamps each row with `_capabilities`, what the signed-in user may do to it. |
3643
3651
 
3644
- The filter operators are `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains` (a case-insensitive substring), `empty` and `nempty` (no value). In the array form a condition may carry `valueMode`: `"currentUser"` or `"currentUserGroup"` with `eq` or `neq` matches the signed-in user (or their groups) on a USER or USER_GROUP column, and `"relativeDate"` with an ordering operator reads `value` as "that many days ago".
3652
+ The filter operators are `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains` (a case-insensitive substring), `empty` and `nempty` (no value). On a list column (`STRING_ARRAY`, `INT_ARRAY`), `eq` keeps the rows whose list holds the value exactly, `empty` / `nempty` test whether it holds any, and every other operator is refused. In the array form a condition may carry `valueMode`: `"currentUser"` or `"currentUserGroup"` with `eq` or `neq` matches the signed-in user (or their groups) on a USER or USER_GROUP column, and `"relativeDate"` with an ordering operator reads `value` as "that many days ago".
3645
3653
 
3646
3654
  The result's `total` is how many rows match in all, not just this page: show "12 of 312" and page with `offset`. For a count, a sum or an average, use `useDatastoreFigure(tableId, { aggregate, column, filter })` instead of reading rows.
3647
3655
 
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
  },
@@ -6571,7 +6571,9 @@ const CONTRACT = deepFreeze({
6571
6571
  // (`no-scope-header-mint`).
6572
6572
  // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6573
6573
  // ships a hand-written `sv` value beside `en`.
6574
- version: "1.224.0",
6574
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6575
+ // live; `showPreview` now defaults to false.
6576
+ version: "1.225.0",
6575
6577
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6576
6578
  hooks: HOOKS,
6577
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
  },
@@ -6568,7 +6568,9 @@ const CONTRACT = deepFreeze({
6568
6568
  // (`no-scope-header-mint`).
6569
6569
  // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6570
6570
  // ships a hand-written `sv` value beside `en`.
6571
- version: "1.224.0",
6571
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6572
+ // live; `showPreview` now defaults to false.
6573
+ version: "1.225.0",
6572
6574
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6573
6575
  hooks: HOOKS,
6574
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.274.0",
3
+ "version": "0.275.1",
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
  },
@@ -6568,7 +6568,9 @@ const CONTRACT = deepFreeze({
6568
6568
  // (`no-scope-header-mint`).
6569
6569
  // 1.224.0: additive (sc-14501) — every `sharedTranslationKeys` entry
6570
6570
  // ships a hand-written `sv` value beside `en`.
6571
- version: "1.224.0",
6571
+ // 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
6572
+ // live; `showPreview` now defaults to false.
6573
+ version: "1.225.0",
6572
6574
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6573
6575
  hooks: HOOKS,
6574
6576
  primitives: PRIMITIVES,