@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 +12 -3
- package/dist/contract.cjs +32 -27
- package/dist/contract.js +32 -27
- package/dist/index.d.ts +10 -5
- package/dist/markdown-input-view.js +18 -11
- package/dist/markdown-input.js +5 -2
- package/dist/markdown-input.native.js +2 -0
- package/dist/markdown-live-field.js +312 -0
- package/dist/markdown-live-field.native.js +36 -0
- package/dist/markdown.js +169 -17
- package/dist/richtext-tokens.js +38 -0
- package/dist/richtext-view.js +2 -18
- package/package.json +1 -1
- package/src/contract.js +32 -27
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.
|
|
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)
|
|
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 }
|
|
5131
|
-
//
|
|
5132
|
-
//
|
|
5133
|
-
//
|
|
5134
|
-
// the
|
|
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
|
-
|
|
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)
|
|
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 }
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
//
|
|
5131
|
-
// the
|
|
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
|
-
|
|
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
|
|
5716
|
-
*
|
|
5717
|
-
*
|
|
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
|
|
5730
|
+
/** Heading above the rendered preview. Defaults to "Preview". */
|
|
5729
5731
|
previewLabel?: string;
|
|
5730
|
-
/**
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
178
|
-
numberOfLines: 6,
|
|
185
|
+
tokens,
|
|
179
186
|
style: fieldStyle,
|
|
180
187
|
testID: testID ? `${testID}-field` : undefined,
|
|
181
188
|
}),
|
package/dist/markdown-input.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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 `` stays
|
|
216
223
|
// literal text rather than becoming a link to the image.
|
|
217
|
-
if (match[1])
|
|
218
|
-
else
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/richtext-tokens.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/richtext-view.js
CHANGED
|
@@ -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.
|
|
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)
|
|
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 }
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
//
|
|
5131
|
-
// the
|
|
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
|
-
|
|
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,
|