@colixsystems/widget-sdk 0.133.1 → 0.134.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 +17 -1
- package/dist/contract.cjs +20 -3
- package/dist/contract.js +20 -3
- package/dist/hooks.js +11 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/markdown-edit.js +67 -0
- package/dist/markdown-input-view.js +56 -4
- package/dist/markdown-input.js +2 -1
- package/dist/markdown-input.native.js +6 -2
- package/dist/markdown-link-dialog.js +291 -0
- package/dist/markdown.js +153 -19
- package/dist/richtext-view.js +118 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,7 +70,23 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
70
70
|
|
|
71
71
|
## Status
|
|
72
72
|
|
|
73
|
-
`v0.
|
|
73
|
+
`v0.134.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**.
|
|
74
|
+
|
|
75
|
+
### What's new in 0.134.0 (contract 1.103.0)
|
|
76
|
+
|
|
77
|
+
**Formatted content can carry links and tables (sc-7349).** `<MarkdownInput>` and `<RichText>` covered emphasis, headings, lists and images, but a link was not in the grammar at all — so a post that said "see the Booking page" had no way to get the reader there, and a comparison table could only be faked with spaces.
|
|
78
|
+
|
|
79
|
+
```jsx
|
|
80
|
+
<MarkdownInput value={draft} onChange={setDraft} pages={pages} />
|
|
81
|
+
<RichText value={post.body} />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **Links: `[label](target)`.** The target is stored **verbatim** and followed through the host's `navigation.openLink`, which already decides page / external / refuse. An absolute URL leaves the app; a bare page id or slug navigates inside it; anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself, and never route one through `Linking.openURL` — it performs anything. A link label keeps its own emphasis, so `[**Book now**](booking)` reads bold.
|
|
85
|
+
- **Tables: a header row, a `| --- | --- |` divider, then body rows.** `:--` / `--:` / `:-:` in a divider cell sets that column's alignment, and `\|` puts a literal pipe in a cell. Cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.
|
|
86
|
+
- **Two new toolbar buttons.** Table inserts a skeleton with the first header cell selected. Link opens a form for the link text plus its target — and when you pass the new optional `pages` prop (`[{ id, name }]`) it offers those pages by name, the way `renderImage` is supplied for filestore images. Without `pages` the form offers only an external address, because a widget cannot know the app's pages on its own.
|
|
87
|
+
- **`<RichText>` gains `followLinks`** (default `true`). The editor's own preview passes `false`, so links render styled but inert and a tap while writing cannot navigate away from the draft.
|
|
88
|
+
|
|
89
|
+
Additive — one shared view module per primitive, bound per host, so both hosts gain this together. Every string already stored keeps parsing identically: a line needs a divider row to become a table, and `` is still read as an image, never a link. `CONTRACT.version` → `1.103.0`.
|
|
74
90
|
|
|
75
91
|
### What's new in 0.133.1 (contract 1.102.1)
|
|
76
92
|
|
package/dist/contract.cjs
CHANGED
|
@@ -1925,7 +1925,7 @@ const PRIMITIVES = [
|
|
|
1925
1925
|
{
|
|
1926
1926
|
name: "RichText",
|
|
1927
1927
|
description:
|
|
1928
|
-
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
|
|
1928
|
+
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
|
|
1929
1929
|
rnComponent: null,
|
|
1930
1930
|
docsUrl: null,
|
|
1931
1931
|
},
|
|
@@ -1935,7 +1935,7 @@ const PRIMITIVES = [
|
|
|
1935
1935
|
{
|
|
1936
1936
|
name: "MarkdownInput",
|
|
1937
1937
|
description:
|
|
1938
|
-
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE 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), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` 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), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
|
|
1938
|
+
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE 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 instead of a bare `TextInput` plus hand-rolled formatting buttons — 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.',
|
|
1939
1939
|
rnComponent: null,
|
|
1940
1940
|
docsUrl: null,
|
|
1941
1941
|
},
|
|
@@ -3879,7 +3879,24 @@ const CONTRACT = deepFreeze({
|
|
|
3879
3879
|
// fails the publish gate. Every signature is now a valid call and the
|
|
3880
3880
|
// omittable parts moved to a new `optionalArgs` array the prompt prints
|
|
3881
3881
|
// beside it. Documentation only; no hook changed shape or behaviour.
|
|
3882
|
-
|
|
3882
|
+
// 1.103.0: additive (sc-7349) — the markdown subset behind `<RichText>` /
|
|
3883
|
+
// `<MarkdownInput>` gains inline `[label](target)` links and `| a | b |`
|
|
3884
|
+
// pipe tables, plus the two toolbar buttons that author them (Table
|
|
3885
|
+
// inserts a skeleton; Link opens a form for the text and its target, and
|
|
3886
|
+
// offers the app's pages by name when the host passes the new optional
|
|
3887
|
+
// `pages` prop). Formatted content could not carry a link AT ALL, so a
|
|
3888
|
+
// post saying "see the Booking page" had no way to get the reader there.
|
|
3889
|
+
// A target is stored VERBATIM and followed through the host's existing
|
|
3890
|
+
// `navigation.openLink`, which already decides page / external / refuse —
|
|
3891
|
+
// no second resolver, and no way for a stored `javascript:` to be
|
|
3892
|
+
// performed. `<RichText>` also gains `followLinks` (default true) so the
|
|
3893
|
+
// editor's own preview renders links styled but inert. Both primitives
|
|
3894
|
+
// stay ONE shared view module bound per host, so this is full parity, not
|
|
3895
|
+
// a §8 native-only case. Every string already stored keeps parsing
|
|
3896
|
+
// identically: a line needs a `| --- |` divider to become a table, and an
|
|
3897
|
+
// `` image is still read as an image, never a link. Minor bump
|
|
3898
|
+
// on the pre-1.0 channel.
|
|
3899
|
+
version: "1.103.0",
|
|
3883
3900
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3884
3901
|
hooks: HOOKS,
|
|
3885
3902
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1925,7 +1925,7 @@ const PRIMITIVES = [
|
|
|
1925
1925
|
{
|
|
1926
1926
|
name: "RichText",
|
|
1927
1927
|
description:
|
|
1928
|
-
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them.',
|
|
1928
|
+
'Formatted-content renderer. `<RichText value={record.body} />`. THE way to display text an app user wrote with formatting — bold, italic, inline code, `#`/`##`/`###` headings, `-` bullets, `1.` numbered items, `[label](target)` links, `| a | b |` pipe tables (a header row, a `| --- | --- |` divider, then body rows; `:--` / `--:` / `:-:` in a divider cell sets that column\'s alignment), and one-line `` images. `value` is markdown text, NOT HTML: widgets render through React Native primitives, which have no `dangerouslySetInnerHTML` on either host, so HTML inside a content string shows up as literal tag soup — never assemble content out of `<strong>`/`<br>`/`<table>` tags. Stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. A link is pressed through the host\'s `navigation.openLink`, which is what decides page / external / refuse — so a link target is just a string here: an absolute URL leaves the app, a bare page id or slug navigates inside it, and anything unfollowable (`javascript:`, a control character) is refused by that one resolver. Never pre-filter a target yourself and never route one through `Linking.openURL`, which performs anything. Props: `value` (markdown string), `renderImage` (optional `({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving those needs the scopes your own widget holds; without it only absolute http(s) URLs render), `followLinks` (default true; pass false to render links styled but inert, which is what an editing preview wants so a tap cannot discard the draft), `style`, `testID`. Colour, type scale and leading all come from the workspace theme — never re-style them. Table cells are equal-width columns whose text wraps, so a wide table stays inside the widget on a phone instead of scrolling sideways.',
|
|
1929
1929
|
rnComponent: null,
|
|
1930
1930
|
docsUrl: null,
|
|
1931
1931
|
},
|
|
@@ -1935,7 +1935,7 @@ const PRIMITIVES = [
|
|
|
1935
1935
|
{
|
|
1936
1936
|
name: "MarkdownInput",
|
|
1937
1937
|
description:
|
|
1938
|
-
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE 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), and a live `<RichText>` preview of the result underneath. Use it instead of a bare `TextInput` plus hand-rolled formatting buttons — buttons that splice `<strong>`/`<br>` 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), `minHeight` (default 150) and `maxHeight` (default 280) so the field scrolls internally instead of growing without bound, `accessibilityLabel`, `style`, `testID`.',
|
|
1938
|
+
'Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. THE 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 instead of a bare `TextInput` plus hand-rolled formatting buttons — 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.',
|
|
1939
1939
|
rnComponent: null,
|
|
1940
1940
|
docsUrl: null,
|
|
1941
1941
|
},
|
|
@@ -3879,7 +3879,24 @@ const CONTRACT = deepFreeze({
|
|
|
3879
3879
|
// fails the publish gate. Every signature is now a valid call and the
|
|
3880
3880
|
// omittable parts moved to a new `optionalArgs` array the prompt prints
|
|
3881
3881
|
// beside it. Documentation only; no hook changed shape or behaviour.
|
|
3882
|
-
|
|
3882
|
+
// 1.103.0: additive (sc-7349) — the markdown subset behind `<RichText>` /
|
|
3883
|
+
// `<MarkdownInput>` gains inline `[label](target)` links and `| a | b |`
|
|
3884
|
+
// pipe tables, plus the two toolbar buttons that author them (Table
|
|
3885
|
+
// inserts a skeleton; Link opens a form for the text and its target, and
|
|
3886
|
+
// offers the app's pages by name when the host passes the new optional
|
|
3887
|
+
// `pages` prop). Formatted content could not carry a link AT ALL, so a
|
|
3888
|
+
// post saying "see the Booking page" had no way to get the reader there.
|
|
3889
|
+
// A target is stored VERBATIM and followed through the host's existing
|
|
3890
|
+
// `navigation.openLink`, which already decides page / external / refuse —
|
|
3891
|
+
// no second resolver, and no way for a stored `javascript:` to be
|
|
3892
|
+
// performed. `<RichText>` also gains `followLinks` (default true) so the
|
|
3893
|
+
// editor's own preview renders links styled but inert. Both primitives
|
|
3894
|
+
// stay ONE shared view module bound per host, so this is full parity, not
|
|
3895
|
+
// a §8 native-only case. Every string already stored keeps parsing
|
|
3896
|
+
// identically: a line needs a `| --- |` divider to become a table, and an
|
|
3897
|
+
// `` image is still read as an image, never a link. Minor bump
|
|
3898
|
+
// on the pre-1.0 channel.
|
|
3899
|
+
version: "1.103.0",
|
|
3883
3900
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3884
3901
|
hooks: HOOKS,
|
|
3885
3902
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -188,6 +188,17 @@ export function useHostTheme() {
|
|
|
188
188
|
return ctx && ctx.workspace ? ctx.workspace.theme : undefined;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// @internal — the navigation counterpart of useHostTheme: reads the host's
|
|
192
|
+
// navigation slice WITHOUT throwing when no provider is mounted, unlike
|
|
193
|
+
// useNavigation() which asserts a widget context. `<RichText>` reads it so a
|
|
194
|
+
// link in formatted content is followable inside a widget yet the component
|
|
195
|
+
// stays renderable anywhere (a Studio preview, a test harness). Not part of the
|
|
196
|
+
// public widget hook surface — not re-exported from index.js.
|
|
197
|
+
export function useHostNavigation() {
|
|
198
|
+
const ctx = useContext(HostWidgetContext);
|
|
199
|
+
return ctx ? ctx.navigation : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
191
202
|
/**
|
|
192
203
|
* REQ-THEME-13 — returns the author-set per-widget style values: the object the
|
|
193
204
|
* host delivers under `props.style`, keyed by the style-field names the widget
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
package/dist/markdown-edit.js
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
// author's SELECTION rather than appending at the end, and that is a thing
|
|
6
6
|
// tests can pin without a renderer.
|
|
7
7
|
|
|
8
|
+
import { escapeMarkdownLabel, formatMarkdownTable } from "./markdown.js";
|
|
9
|
+
|
|
8
10
|
const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
|
|
9
11
|
|
|
12
|
+
// The literal the inserted table skeleton opens with; the caret lands on it.
|
|
13
|
+
const FIRST_HEADING = "Column 1";
|
|
14
|
+
|
|
10
15
|
const LINE_KINDS = Object.freeze({
|
|
11
16
|
heading2: { marker: "## ", test: /^##\s/ },
|
|
12
17
|
heading3: { marker: "### ", test: /^###\s/ },
|
|
@@ -86,6 +91,66 @@ function linePrefix(kind) {
|
|
|
86
91
|
};
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
// sc-7349 — a link needs a TARGET, which no selection can supply, so this is
|
|
95
|
+
// the one op that takes a third argument. The target is stored verbatim: the
|
|
96
|
+
// host's `navigation.openLink` is what decides page / external / refuse, so
|
|
97
|
+
// this must not pre-judge it (see markdown.js).
|
|
98
|
+
function linkOp() {
|
|
99
|
+
return (text, selection, options) => {
|
|
100
|
+
const { src, start, end } = readRange(text, selection);
|
|
101
|
+
// A bare string is accepted as the target, so the common case stays terse.
|
|
102
|
+
const opts =
|
|
103
|
+
options && typeof options === "object" ? options : { target: options };
|
|
104
|
+
const href = typeof opts.target === "string" ? opts.target.trim() : "";
|
|
105
|
+
// Nothing to point at — leave the text exactly as the author left it.
|
|
106
|
+
if (!href) return { value: src, selection: { start, end } };
|
|
107
|
+
|
|
108
|
+
// An explicit label wins, so a dialog can name the link when the author
|
|
109
|
+
// had nothing selected; otherwise the selection IS the label.
|
|
110
|
+
const chosen =
|
|
111
|
+
typeof opts.label === "string" && opts.label.trim()
|
|
112
|
+
? opts.label
|
|
113
|
+
: src.slice(start, end);
|
|
114
|
+
const label = escapeMarkdownLabel(chosen) || href;
|
|
115
|
+
const markup = `[${label}](${href})`;
|
|
116
|
+
return {
|
|
117
|
+
value: src.slice(0, start) + markup + src.slice(end),
|
|
118
|
+
// Leaves the LABEL selected, so typing replaces the link text rather
|
|
119
|
+
// than the markup around it.
|
|
120
|
+
selection: { start: start + 1, end: start + 1 + label.length },
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A table is a block, so it needs its own lines: a blank line before keeps it
|
|
126
|
+
// from fusing with the paragraph the caret was in.
|
|
127
|
+
function tableOp() {
|
|
128
|
+
return (text, selection) => {
|
|
129
|
+
const { src, start, end } = readRange(text, selection);
|
|
130
|
+
const before = src.slice(0, start);
|
|
131
|
+
const after = src.slice(end);
|
|
132
|
+
const lead =
|
|
133
|
+
before.length === 0 || before.endsWith("\n\n")
|
|
134
|
+
? ""
|
|
135
|
+
: before.endsWith("\n")
|
|
136
|
+
? "\n"
|
|
137
|
+
: "\n\n";
|
|
138
|
+
const trail = after.startsWith("\n") ? "" : "\n";
|
|
139
|
+
|
|
140
|
+
const skeleton = formatMarkdownTable();
|
|
141
|
+
const offset = before.length + lead.length;
|
|
142
|
+
return {
|
|
143
|
+
value: before + lead + skeleton + trail + after,
|
|
144
|
+
// Selects the first header cell, so the author types over "Column 1"
|
|
145
|
+
// instead of hunting for the caret inside the pipes.
|
|
146
|
+
selection: {
|
|
147
|
+
start: offset + 2,
|
|
148
|
+
end: offset + 2 + FIRST_HEADING.length,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
89
154
|
export const markdownEditOps = Object.freeze({
|
|
90
155
|
bold: wrap("**"),
|
|
91
156
|
italic: wrap("*"),
|
|
@@ -94,4 +159,6 @@ export const markdownEditOps = Object.freeze({
|
|
|
94
159
|
heading3: linePrefix("heading3"),
|
|
95
160
|
bullet: linePrefix("bullet"),
|
|
96
161
|
ordered: linePrefix("ordered"),
|
|
162
|
+
link: linkOp(),
|
|
163
|
+
table: tableOp(),
|
|
97
164
|
});
|
|
@@ -13,6 +13,7 @@ import React from "react";
|
|
|
13
13
|
import { useHostTheme } from "./hooks.js";
|
|
14
14
|
import { resolveRichTextTokens } from "./richtext-tokens.js";
|
|
15
15
|
import { markdownEditOps } from "./markdown-edit.js";
|
|
16
|
+
import { makeMarkdownLinkDialog } from "./markdown-link-dialog.js";
|
|
16
17
|
|
|
17
18
|
const TOOLS = Object.freeze([
|
|
18
19
|
{ key: "bold", label: "B", accessibilityLabel: "Bold", weight: "700" },
|
|
@@ -22,10 +23,20 @@ const TOOLS = Object.freeze([
|
|
|
22
23
|
{ key: "heading3", label: "H3", accessibilityLabel: "Subheading" },
|
|
23
24
|
{ key: "bullet", label: "•", accessibilityLabel: "Bulleted list" },
|
|
24
25
|
{ key: "ordered", label: "1.", accessibilityLabel: "Numbered list" },
|
|
26
|
+
{ key: "table", label: "Table", accessibilityLabel: "Insert table" },
|
|
27
|
+
// sc-7349: the one tool a selection cannot complete — a link needs a target,
|
|
28
|
+
// so this button opens the form instead of editing the string directly.
|
|
29
|
+
{
|
|
30
|
+
key: "link",
|
|
31
|
+
label: "Link",
|
|
32
|
+
accessibilityLabel: "Insert link",
|
|
33
|
+
opensDialog: true,
|
|
34
|
+
},
|
|
25
35
|
]);
|
|
26
36
|
|
|
27
|
-
export function makeMarkdownInput(rn, RichText) {
|
|
37
|
+
export function makeMarkdownInput(rn, RichText, Overlay) {
|
|
28
38
|
const { Text, View, Pressable, TextInput } = rn;
|
|
39
|
+
const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Overlay);
|
|
29
40
|
|
|
30
41
|
function MarkdownInput({
|
|
31
42
|
value,
|
|
@@ -34,6 +45,7 @@ export function makeMarkdownInput(rn, RichText) {
|
|
|
34
45
|
previewLabel = "Preview",
|
|
35
46
|
showPreview = true,
|
|
36
47
|
renderImage,
|
|
48
|
+
pages,
|
|
37
49
|
minHeight = 150,
|
|
38
50
|
maxHeight = 280,
|
|
39
51
|
accessibilityLabel,
|
|
@@ -43,6 +55,7 @@ export function makeMarkdownInput(rn, RichText) {
|
|
|
43
55
|
const theme = useHostTheme();
|
|
44
56
|
const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
|
|
45
57
|
const [selection, setSelection] = React.useState({ start: 0, end: 0 });
|
|
58
|
+
const [linkOpen, setLinkOpen] = React.useState(false);
|
|
46
59
|
|
|
47
60
|
const text = typeof value === "string" ? value : "";
|
|
48
61
|
|
|
@@ -52,17 +65,43 @@ export function makeMarkdownInput(rn, RichText) {
|
|
|
52
65
|
}, []);
|
|
53
66
|
|
|
54
67
|
const applyTool = React.useCallback(
|
|
55
|
-
(toolKey) => {
|
|
68
|
+
(toolKey, options) => {
|
|
56
69
|
if (typeof onChange !== "function") return;
|
|
57
70
|
const edit = markdownEditOps[toolKey];
|
|
58
71
|
if (!edit) return;
|
|
59
|
-
const next = edit(text, selection);
|
|
72
|
+
const next = edit(text, selection, options);
|
|
60
73
|
onChange(next.value);
|
|
61
74
|
setSelection(next.selection);
|
|
62
75
|
},
|
|
63
76
|
[onChange, text, selection],
|
|
64
77
|
);
|
|
65
78
|
|
|
79
|
+
const pressTool = React.useCallback(
|
|
80
|
+
(tool) => {
|
|
81
|
+
if (tool.opensDialog) {
|
|
82
|
+
setLinkOpen(true);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
applyTool(tool.key);
|
|
86
|
+
},
|
|
87
|
+
[applyTool],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const insertLink = React.useCallback(
|
|
91
|
+
(result) => {
|
|
92
|
+
setLinkOpen(false);
|
|
93
|
+
applyTool("link", result);
|
|
94
|
+
},
|
|
95
|
+
[applyTool],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// The selection seeds the dialog's Link text, so highlighting a phrase and
|
|
99
|
+
// pressing Link keeps that phrase as the label.
|
|
100
|
+
const selected = text.slice(
|
|
101
|
+
Math.min(selection.start, selection.end),
|
|
102
|
+
Math.max(selection.start, selection.end),
|
|
103
|
+
);
|
|
104
|
+
|
|
66
105
|
const fieldStyle = {
|
|
67
106
|
fontFamily: tokens.bodyFont,
|
|
68
107
|
fontSize: tokens.bodySize,
|
|
@@ -96,7 +135,7 @@ export function makeMarkdownInput(rn, RichText) {
|
|
|
96
135
|
Pressable,
|
|
97
136
|
{
|
|
98
137
|
key: tool.key,
|
|
99
|
-
onPress: () =>
|
|
138
|
+
onPress: () => pressTool(tool),
|
|
100
139
|
accessibilityRole: "button",
|
|
101
140
|
accessibilityLabel: tool.accessibilityLabel,
|
|
102
141
|
testID: testID ? `${testID}-${tool.key}` : undefined,
|
|
@@ -159,10 +198,23 @@ export function makeMarkdownInput(rn, RichText) {
|
|
|
159
198
|
React.createElement(RichText, {
|
|
160
199
|
value: text,
|
|
161
200
|
renderImage,
|
|
201
|
+
// sc-7349: the preview shows the reader's document, but a tap
|
|
202
|
+
// must NOT leave the page — that would throw away the draft the
|
|
203
|
+
// author is still writing.
|
|
204
|
+
followLinks: false,
|
|
162
205
|
testID: testID ? `${testID}-preview` : undefined,
|
|
163
206
|
}),
|
|
164
207
|
)
|
|
165
208
|
: null,
|
|
209
|
+
React.createElement(MarkdownLinkDialog, {
|
|
210
|
+
visible: linkOpen,
|
|
211
|
+
onClose: () => setLinkOpen(false),
|
|
212
|
+
onSubmit: insertLink,
|
|
213
|
+
tokens,
|
|
214
|
+
pages,
|
|
215
|
+
initialLabel: selected,
|
|
216
|
+
testID,
|
|
217
|
+
}),
|
|
166
218
|
);
|
|
167
219
|
}
|
|
168
220
|
|
package/dist/markdown-input.js
CHANGED
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
import * as ReactNative from "react-native-web";
|
|
6
6
|
import { makeMarkdownInput } from "./markdown-input-view.js";
|
|
7
7
|
import { RichText } from "./richtext.js";
|
|
8
|
+
import { Overlay } from "./overlay.js";
|
|
8
9
|
|
|
9
|
-
export const MarkdownInput = makeMarkdownInput(ReactNative, RichText);
|
|
10
|
+
export const MarkdownInput = makeMarkdownInput(ReactNative, RichText, Overlay);
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
// ./markdown-input-view.js to React Native's own primitives and the native
|
|
3
3
|
// `<RichText>`. Only the imports differ from the web mirror.
|
|
4
4
|
|
|
5
|
-
import { Text, View, Pressable, TextInput } from "react-native";
|
|
5
|
+
import { Text, View, Pressable, TextInput, ScrollView } from "react-native";
|
|
6
6
|
import { makeMarkdownInput } from "./markdown-input-view.js";
|
|
7
7
|
import { RichText } from "./richtext.native.js";
|
|
8
|
+
import { Overlay } from "./overlay.native.js";
|
|
8
9
|
|
|
10
|
+
// sc-7349: ScrollView joins the set for the link form's page list, which must
|
|
11
|
+
// scroll rather than grow an app's every page past the panel.
|
|
9
12
|
export const MarkdownInput = makeMarkdownInput(
|
|
10
|
-
{ Text, View, Pressable, TextInput },
|
|
13
|
+
{ Text, View, Pressable, TextInput, ScrollView },
|
|
11
14
|
RichText,
|
|
15
|
+
Overlay,
|
|
12
16
|
);
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// sc-7349 — the link form `<MarkdownInput>`'s Link button opens.
|
|
2
|
+
//
|
|
3
|
+
// A link needs a TARGET, and no selection can supply one: the author has to
|
|
4
|
+
// name either a page in this app or an external address. That is a form, and a
|
|
5
|
+
// form belongs in an `<Overlay>` — the SDK primitive that escapes the widget's
|
|
6
|
+
// own clipping box on both hosts — not in a bespoke absolutely-positioned panel
|
|
7
|
+
// the layout container would crop.
|
|
8
|
+
//
|
|
9
|
+
// Split out of markdown-input-view.js so each file stays one component's worth
|
|
10
|
+
// of markup. Like every view module here it is bound per host by the caller
|
|
11
|
+
// (see markdown-input.js / .native.js), so the two hosts cannot drift.
|
|
12
|
+
|
|
13
|
+
import React from "react";
|
|
14
|
+
|
|
15
|
+
// The page list scrolls rather than growing an app's 40 pages past the panel.
|
|
16
|
+
const PAGE_LIST_MAX_HEIGHT = 180;
|
|
17
|
+
|
|
18
|
+
export function makeMarkdownLinkDialog(rn, Overlay) {
|
|
19
|
+
const { Text, View, Pressable, TextInput, ScrollView } = rn;
|
|
20
|
+
|
|
21
|
+
function Field({ label, tokens, children }) {
|
|
22
|
+
return React.createElement(
|
|
23
|
+
View,
|
|
24
|
+
{ style: { marginBottom: tokens.blockGap } },
|
|
25
|
+
React.createElement(
|
|
26
|
+
Text,
|
|
27
|
+
{
|
|
28
|
+
style: {
|
|
29
|
+
fontFamily: tokens.bodyFont,
|
|
30
|
+
fontSize: tokens.codeSize,
|
|
31
|
+
color: tokens.mutedColor,
|
|
32
|
+
marginBottom: tokens.markerGap,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
label,
|
|
36
|
+
),
|
|
37
|
+
children,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} props
|
|
43
|
+
* @param {boolean} props.visible
|
|
44
|
+
* @param {() => void} props.onClose
|
|
45
|
+
* @param {(result: {label: string, target: string}) => void} props.onSubmit
|
|
46
|
+
* @param {object} props.tokens — resolveRichTextTokens output.
|
|
47
|
+
* @param {Array<{id: string, name: string}>} props.pages — pickable pages;
|
|
48
|
+
* empty means this host offered none, so only the URL mode is shown.
|
|
49
|
+
* @param {string} props.initialLabel — the author's current selection.
|
|
50
|
+
* @param {string} [props.testID]
|
|
51
|
+
*/
|
|
52
|
+
function MarkdownLinkDialog({
|
|
53
|
+
visible,
|
|
54
|
+
onClose,
|
|
55
|
+
onSubmit,
|
|
56
|
+
tokens,
|
|
57
|
+
pages,
|
|
58
|
+
initialLabel,
|
|
59
|
+
testID,
|
|
60
|
+
}) {
|
|
61
|
+
const pageList = Array.isArray(pages) ? pages.filter((p) => p && p.id) : [];
|
|
62
|
+
const canPickPage = pageList.length > 0;
|
|
63
|
+
|
|
64
|
+
const [label, setLabel] = React.useState("");
|
|
65
|
+
const [url, setUrl] = React.useState("");
|
|
66
|
+
const [pageId, setPageId] = React.useState("");
|
|
67
|
+
const [mode, setMode] = React.useState("url");
|
|
68
|
+
|
|
69
|
+
// Re-seed on each open: the author's selection and the mode they should
|
|
70
|
+
// land in are both properties of THIS opening, not of the component.
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (!visible) return;
|
|
73
|
+
setLabel(typeof initialLabel === "string" ? initialLabel : "");
|
|
74
|
+
setUrl("");
|
|
75
|
+
setPageId("");
|
|
76
|
+
setMode(canPickPage ? "page" : "url");
|
|
77
|
+
}, [visible, initialLabel, canPickPage]);
|
|
78
|
+
|
|
79
|
+
const target = mode === "page" ? pageId : url.trim();
|
|
80
|
+
|
|
81
|
+
const submit = () => {
|
|
82
|
+
if (!target) return;
|
|
83
|
+
if (typeof onSubmit === "function") onSubmit({ label, target });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const inputStyle = {
|
|
87
|
+
fontFamily: tokens.bodyFont,
|
|
88
|
+
fontSize: tokens.bodySize,
|
|
89
|
+
color: tokens.color,
|
|
90
|
+
backgroundColor: tokens.surface,
|
|
91
|
+
borderWidth: 1,
|
|
92
|
+
borderColor: tokens.border,
|
|
93
|
+
borderRadius: tokens.radius,
|
|
94
|
+
padding: tokens.markerGap,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const tab = (key, text) =>
|
|
98
|
+
React.createElement(
|
|
99
|
+
Pressable,
|
|
100
|
+
{
|
|
101
|
+
key,
|
|
102
|
+
onPress: () => setMode(key),
|
|
103
|
+
// RNW 0.21.2 drops the a11y state prop, so the chosen tab was
|
|
104
|
+
// legible only as a fill colour (sc-6350). role + aria-* announces
|
|
105
|
+
// on both hosts; aria-selected needs role="tab", not "button".
|
|
106
|
+
accessibilityRole: "tab",
|
|
107
|
+
"aria-selected": mode === key,
|
|
108
|
+
testID: testID ? `${testID}-mode-${key}` : undefined,
|
|
109
|
+
style: {
|
|
110
|
+
paddingVertical: tokens.markerGap,
|
|
111
|
+
paddingHorizontal: tokens.padding,
|
|
112
|
+
marginRight: tokens.markerGap,
|
|
113
|
+
borderWidth: 1,
|
|
114
|
+
borderColor: mode === key ? tokens.accent : tokens.border,
|
|
115
|
+
borderRadius: tokens.radius,
|
|
116
|
+
...(mode === key ? { backgroundColor: tokens.accent } : null),
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
React.createElement(
|
|
120
|
+
Text,
|
|
121
|
+
{
|
|
122
|
+
style: {
|
|
123
|
+
fontFamily: tokens.bodyFont,
|
|
124
|
+
fontSize: tokens.codeSize,
|
|
125
|
+
fontWeight: "500",
|
|
126
|
+
color: mode === key ? tokens.onAccent : tokens.color,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
text,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const action = (key, text, { primary, disabled, onPress }) =>
|
|
134
|
+
React.createElement(
|
|
135
|
+
Pressable,
|
|
136
|
+
{
|
|
137
|
+
key,
|
|
138
|
+
onPress,
|
|
139
|
+
disabled,
|
|
140
|
+
accessibilityRole: "button",
|
|
141
|
+
testID: testID ? `${testID}-${key}` : undefined,
|
|
142
|
+
style: {
|
|
143
|
+
paddingVertical: tokens.markerGap,
|
|
144
|
+
paddingHorizontal: tokens.padding,
|
|
145
|
+
marginLeft: tokens.markerGap,
|
|
146
|
+
borderWidth: 1,
|
|
147
|
+
borderColor: primary ? tokens.accent : tokens.border,
|
|
148
|
+
borderRadius: tokens.radius,
|
|
149
|
+
opacity: disabled ? 0.5 : 1,
|
|
150
|
+
...(primary ? { backgroundColor: tokens.accent } : null),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
React.createElement(
|
|
154
|
+
Text,
|
|
155
|
+
{
|
|
156
|
+
style: {
|
|
157
|
+
fontFamily: tokens.bodyFont,
|
|
158
|
+
fontSize: tokens.codeSize,
|
|
159
|
+
fontWeight: "500",
|
|
160
|
+
color: primary ? tokens.onAccent : tokens.color,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
text,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return React.createElement(
|
|
168
|
+
Overlay,
|
|
169
|
+
{
|
|
170
|
+
visible: !!visible,
|
|
171
|
+
onRequestClose: onClose,
|
|
172
|
+
size: "sm",
|
|
173
|
+
accessibilityLabel: "Insert link",
|
|
174
|
+
testID: testID ? `${testID}-dialog` : undefined,
|
|
175
|
+
},
|
|
176
|
+
React.createElement(
|
|
177
|
+
Field,
|
|
178
|
+
{ label: "Link text", tokens },
|
|
179
|
+
React.createElement(TextInput, {
|
|
180
|
+
value: label,
|
|
181
|
+
onChangeText: setLabel,
|
|
182
|
+
placeholder: "Text the reader sees",
|
|
183
|
+
placeholderTextColor: tokens.mutedColor,
|
|
184
|
+
accessibilityLabel: "Link text",
|
|
185
|
+
style: inputStyle,
|
|
186
|
+
testID: testID ? `${testID}-label` : undefined,
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
|
|
190
|
+
// With no pages to offer there is nothing to choose between, so the
|
|
191
|
+
// switcher is hidden rather than shown with one dead option.
|
|
192
|
+
canPickPage
|
|
193
|
+
? React.createElement(
|
|
194
|
+
View,
|
|
195
|
+
{
|
|
196
|
+
accessibilityRole: "tablist",
|
|
197
|
+
style: { flexDirection: "row", marginBottom: tokens.blockGap },
|
|
198
|
+
},
|
|
199
|
+
tab("page", "A page in this app"),
|
|
200
|
+
tab("url", "An external URL"),
|
|
201
|
+
)
|
|
202
|
+
: null,
|
|
203
|
+
|
|
204
|
+
mode === "page" && canPickPage
|
|
205
|
+
? React.createElement(
|
|
206
|
+
Field,
|
|
207
|
+
{ label: "Page", tokens },
|
|
208
|
+
React.createElement(
|
|
209
|
+
ScrollView,
|
|
210
|
+
{
|
|
211
|
+
accessibilityRole: "radiogroup",
|
|
212
|
+
style: {
|
|
213
|
+
maxHeight: PAGE_LIST_MAX_HEIGHT,
|
|
214
|
+
borderWidth: 1,
|
|
215
|
+
borderColor: tokens.border,
|
|
216
|
+
borderRadius: tokens.radius,
|
|
217
|
+
},
|
|
218
|
+
testID: testID ? `${testID}-pages` : undefined,
|
|
219
|
+
},
|
|
220
|
+
pageList.map((page) =>
|
|
221
|
+
React.createElement(
|
|
222
|
+
Pressable,
|
|
223
|
+
{
|
|
224
|
+
key: page.id,
|
|
225
|
+
onPress: () => setPageId(page.id),
|
|
226
|
+
accessibilityRole: "radio",
|
|
227
|
+
"aria-checked": pageId === page.id,
|
|
228
|
+
style: {
|
|
229
|
+
padding: tokens.markerGap,
|
|
230
|
+
...(pageId === page.id
|
|
231
|
+
? { backgroundColor: tokens.surface }
|
|
232
|
+
: null),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
React.createElement(
|
|
236
|
+
Text,
|
|
237
|
+
{
|
|
238
|
+
style: {
|
|
239
|
+
fontFamily: tokens.bodyFont,
|
|
240
|
+
fontSize: tokens.bodySize,
|
|
241
|
+
color: pageId === page.id ? tokens.accent : tokens.color,
|
|
242
|
+
fontWeight: pageId === page.id ? "700" : "400",
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
page.name || page.id,
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
: React.createElement(
|
|
252
|
+
Field,
|
|
253
|
+
{ label: "Address", tokens },
|
|
254
|
+
React.createElement(TextInput, {
|
|
255
|
+
value: url,
|
|
256
|
+
onChangeText: setUrl,
|
|
257
|
+
placeholder: "https://example.com",
|
|
258
|
+
placeholderTextColor: tokens.mutedColor,
|
|
259
|
+
accessibilityLabel: "Link address",
|
|
260
|
+
autoCapitalize: "none",
|
|
261
|
+
autoCorrect: false,
|
|
262
|
+
keyboardType: "url",
|
|
263
|
+
style: inputStyle,
|
|
264
|
+
testID: testID ? `${testID}-url` : undefined,
|
|
265
|
+
}),
|
|
266
|
+
),
|
|
267
|
+
|
|
268
|
+
React.createElement(
|
|
269
|
+
View,
|
|
270
|
+
{
|
|
271
|
+
style: {
|
|
272
|
+
flexDirection: "row",
|
|
273
|
+
justifyContent: "flex-end",
|
|
274
|
+
marginTop: tokens.markerGap,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
action("cancel", "Cancel", { onPress: onClose }),
|
|
278
|
+
// Disabled until there is somewhere to go — an empty target would
|
|
279
|
+
// write `[text]()`, which reads as a link and goes nowhere.
|
|
280
|
+
action("insert", "Insert link", {
|
|
281
|
+
primary: true,
|
|
282
|
+
disabled: !target,
|
|
283
|
+
onPress: submit,
|
|
284
|
+
}),
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
MarkdownLinkDialog.displayName = "MarkdownLinkDialog";
|
|
290
|
+
return MarkdownLinkDialog;
|
|
291
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -11,13 +11,24 @@
|
|
|
11
11
|
// interpreted — legacy rows that still hold markup are STRIPPED, never rendered.
|
|
12
12
|
//
|
|
13
13
|
// The supported subset is deliberately small: `#`/`##`/`###` headings, `-`/`*`
|
|
14
|
-
// bullets, `1.` ordered items, one-line `` images,
|
|
15
|
-
// `**bold**`, `*italic*`/`_italic_`,
|
|
14
|
+
// bullets, `1.` ordered items, one-line `` images, GFM pipe
|
|
15
|
+
// tables, and inline `**bold**`, `*italic*`/`_italic_`, `` `code` `` and
|
|
16
|
+
// `[label](target)` links.
|
|
17
|
+
//
|
|
18
|
+
// sc-7349: a link target is NOT classified here. The host's `navigation.openLink`
|
|
19
|
+
// owns that one decision — page id/slug, external URL, or refuse — so a
|
|
20
|
+
// `javascript:` target can never be performed and the two hosts cannot disagree
|
|
21
|
+
// about what a link means.
|
|
16
22
|
|
|
17
23
|
const HEADING_RE = /^(#{1,3})\s+(.*)$/;
|
|
18
24
|
const BULLET_RE = /^\s*[-*]\s+(.*)$/;
|
|
19
25
|
const ORDERED_RE = /^\s*(\d+)[.)]\s+(.*)$/;
|
|
20
26
|
|
|
27
|
+
// A pipe table needs BOTH a header row and a `---` divider under it, so a line
|
|
28
|
+
// of prose that merely contains a `|` is never mistaken for one.
|
|
29
|
+
const TABLE_ROW_RE = /^\s*\|(.*)\|\s*$/;
|
|
30
|
+
const TABLE_DIVIDER_RE = /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/;
|
|
31
|
+
|
|
21
32
|
// An image is its own block, written on one line with the size in the title
|
|
22
33
|
// slot. Keeping blocks in the SAME markdown column as the prose is what lets
|
|
23
34
|
// content written before images existed keep rendering with no migration.
|
|
@@ -56,13 +67,17 @@ export function normaliseMarkdownImageSize(size) {
|
|
|
56
67
|
|
|
57
68
|
// A caption is the only prose an image-only post has, so `]` must survive the
|
|
58
69
|
// round trip rather than being eaten, to keep the one-line grammar unambiguous.
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
// sc-7349: a link label is escaped by the same rule — both sit in a `[...]`
|
|
71
|
+
// slot, so one escaper keeps them from drifting apart.
|
|
72
|
+
export function escapeMarkdownLabel(value) {
|
|
73
|
+
return String(value || "")
|
|
61
74
|
.replace(/[\r\n]+/g, " ")
|
|
62
75
|
.replace(/([\\\]])/g, "\\$1")
|
|
63
76
|
.trim();
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
const escapeAlt = escapeMarkdownLabel;
|
|
80
|
+
|
|
66
81
|
/** Un-escapes an alt captured by the image grammar. */
|
|
67
82
|
export function readMarkdownAlt(raw) {
|
|
68
83
|
return String(raw || "").replace(/\\(.)/g, "$1");
|
|
@@ -126,22 +141,42 @@ export function stripHtmlToMarkdown(text) {
|
|
|
126
141
|
.trim();
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
|
|
144
|
+
// A link label keeps its own emphasis, so `[**Book now**](booking)` reads bold.
|
|
145
|
+
// The RAW label is re-parsed rather than the unescaped one: by construction it
|
|
146
|
+
// holds no unescaped `]`, so the nested pass cannot form another link and the
|
|
147
|
+
// recursion is exactly one level deep.
|
|
148
|
+
function linkSpans(rawLabel, href) {
|
|
149
|
+
const spans = parseInline(rawLabel).map((span) => ({
|
|
150
|
+
...span,
|
|
151
|
+
text: readMarkdownAlt(span.text),
|
|
152
|
+
href,
|
|
153
|
+
}));
|
|
154
|
+
// `[](https://example.com)` has nothing to show but the target itself.
|
|
155
|
+
return spans.some((span) => span.text) ? spans : [{ text: href, href }];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Splits one line into `{ text, bold?, italic?, code?, href? }` spans. */
|
|
130
159
|
function parseInline(line) {
|
|
131
160
|
const source = typeof line === "string" ? line : "";
|
|
132
161
|
const spans = [];
|
|
133
|
-
// One pass over the
|
|
162
|
+
// One pass over the four delimiters. Alternation order matters: `**` must
|
|
134
163
|
// be tried before `*` or the bold marker is consumed as two italics.
|
|
135
|
-
const pattern =
|
|
164
|
+
const pattern =
|
|
165
|
+
/(!?)\[((?:[^\]\\]|\\.)*)\]\(\s*(\S+?)\s*\)|(\*\*)(.+?)\4|(`)([^`]+?)\6|([*_])(.+?)\8/g;
|
|
136
166
|
let cursor = 0;
|
|
137
167
|
let match = pattern.exec(source);
|
|
138
168
|
while (match) {
|
|
139
169
|
if (match.index > cursor) {
|
|
140
170
|
spans.push({ text: source.slice(cursor, match.index) });
|
|
141
171
|
}
|
|
142
|
-
if (match[
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
if (match[3] !== undefined) {
|
|
173
|
+
// An image is its own whole-line block, so an inline `` stays
|
|
174
|
+
// literal text rather than becoming a link to the image.
|
|
175
|
+
if (match[1]) spans.push({ text: match[0] });
|
|
176
|
+
else for (const span of linkSpans(match[2], match[3])) spans.push(span);
|
|
177
|
+
} else if (match[4]) spans.push({ text: match[5], bold: true });
|
|
178
|
+
else if (match[6]) spans.push({ text: match[7], code: true });
|
|
179
|
+
else spans.push({ text: match[9], italic: true });
|
|
145
180
|
cursor = match.index + match[0].length;
|
|
146
181
|
match = pattern.exec(source);
|
|
147
182
|
}
|
|
@@ -149,10 +184,87 @@ function parseInline(line) {
|
|
|
149
184
|
return spans.length > 0 ? spans : [{ text: source }];
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
// `\|` is how a literal pipe survives inside a cell, so the split has to walk
|
|
188
|
+
// the line rather than use a lookbehind — Hermes does not ship one.
|
|
189
|
+
function splitTableCells(raw) {
|
|
190
|
+
const cells = [];
|
|
191
|
+
let current = "";
|
|
192
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
193
|
+
const char = raw.charAt(i);
|
|
194
|
+
if (char === "\\" && raw.charAt(i + 1) === "|") {
|
|
195
|
+
current += "|";
|
|
196
|
+
i += 1;
|
|
197
|
+
} else if (char === "|") {
|
|
198
|
+
cells.push(current);
|
|
199
|
+
current = "";
|
|
200
|
+
} else {
|
|
201
|
+
current += char;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
cells.push(current);
|
|
205
|
+
return cells.map((cell) => cell.trim());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A divider cell's colons carry the column's alignment: `:--` left, `--:` right,
|
|
209
|
+
// `:-:` centre. Unmarked columns return null so the renderer leaves them alone.
|
|
210
|
+
function readColumnAlignment(cell) {
|
|
211
|
+
const value = cell.trim();
|
|
212
|
+
const left = value.charAt(0) === ":";
|
|
213
|
+
const right = value.length > 1 && value.charAt(value.length - 1) === ":";
|
|
214
|
+
if (left && right) return "center";
|
|
215
|
+
if (right) return "right";
|
|
216
|
+
if (left) return "left";
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reads a pipe table starting at `lines[start]`, or returns null when one does
|
|
222
|
+
* not begin there.
|
|
223
|
+
*
|
|
224
|
+
* @returns {{block: object, next: number} | null} `next` is the first line
|
|
225
|
+
* AFTER the table.
|
|
226
|
+
*/
|
|
227
|
+
function readTable(lines, start) {
|
|
228
|
+
const header = TABLE_ROW_RE.exec(lines[start]);
|
|
229
|
+
if (!header) return null;
|
|
230
|
+
if (!TABLE_DIVIDER_RE.test(lines[start + 1] || "")) return null;
|
|
231
|
+
|
|
232
|
+
const divider = TABLE_ROW_RE.exec(lines[start + 1]);
|
|
233
|
+
const align = splitTableCells(divider[1]).map(readColumnAlignment);
|
|
234
|
+
const rows = [
|
|
235
|
+
{ header: true, cells: splitTableCells(header[1]).map((c) => parseInline(c)) },
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
let index = start + 2;
|
|
239
|
+
while (index < lines.length) {
|
|
240
|
+
const body = TABLE_ROW_RE.exec(lines[index]);
|
|
241
|
+
if (!body) break;
|
|
242
|
+
rows.push({
|
|
243
|
+
header: false,
|
|
244
|
+
cells: splitTableCells(body[1]).map((c) => parseInline(c)),
|
|
245
|
+
});
|
|
246
|
+
index += 1;
|
|
247
|
+
}
|
|
248
|
+
return { block: { type: "table", rows, align }, next: index };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Writes an empty table of `columns` x `rows` body rows back to markdown. */
|
|
252
|
+
export function formatMarkdownTable(columns = 3, rows = 2) {
|
|
253
|
+
const width = Math.min(Math.max(Math.trunc(columns) || 0, 1), 8);
|
|
254
|
+
const height = Math.min(Math.max(Math.trunc(rows) || 0, 1), 20);
|
|
255
|
+
const line = (cells) => `| ${cells.join(" | ")} |`;
|
|
256
|
+
const heads = [];
|
|
257
|
+
for (let i = 0; i < width; i += 1) heads.push(`Column ${i + 1}`);
|
|
258
|
+
const out = [line(heads), line(heads.map(() => "---"))];
|
|
259
|
+
for (let r = 0; r < height; r += 1) out.push(line(heads.map(() => "")));
|
|
260
|
+
return out.join("\n");
|
|
261
|
+
}
|
|
262
|
+
|
|
152
263
|
/**
|
|
153
264
|
* Parses markdown text into renderable blocks:
|
|
154
|
-
* `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }
|
|
155
|
-
*
|
|
265
|
+
* `{ type: "heading"|"paragraph"|"bullet"|"ordered", level?, marker?, spans }`,
|
|
266
|
+
* `{ type: "image", src, alt, size }`, or
|
|
267
|
+
* `{ type: "table", rows: [{ header, cells: spans[] }], align }`.
|
|
156
268
|
*/
|
|
157
269
|
export function parseMarkdown(text) {
|
|
158
270
|
const source = stripHtmlToMarkdown(text);
|
|
@@ -167,13 +279,24 @@ export function parseMarkdown(text) {
|
|
|
167
279
|
paragraph = [];
|
|
168
280
|
};
|
|
169
281
|
|
|
170
|
-
|
|
171
|
-
|
|
282
|
+
// Indexed rather than for-of: a table is only a table if the NEXT line is its
|
|
283
|
+
// divider, so the scan needs one line of lookahead.
|
|
284
|
+
const lines = source.split(/\r?\n/);
|
|
285
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
286
|
+
const line = lines[i].trimEnd();
|
|
172
287
|
if (!line.trim()) {
|
|
173
288
|
flushParagraph();
|
|
174
289
|
continue;
|
|
175
290
|
}
|
|
176
291
|
|
|
292
|
+
const table = readTable(lines, i);
|
|
293
|
+
if (table) {
|
|
294
|
+
flushParagraph();
|
|
295
|
+
blocks.push(table.block);
|
|
296
|
+
i = table.next - 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
177
300
|
const image = parseMarkdownImage(line);
|
|
178
301
|
if (image) {
|
|
179
302
|
flushParagraph();
|
|
@@ -216,14 +339,25 @@ export function parseMarkdown(text) {
|
|
|
216
339
|
return blocks;
|
|
217
340
|
}
|
|
218
341
|
|
|
342
|
+
function spansToText(spans) {
|
|
343
|
+
return spans.map((span) => span.text).join("");
|
|
344
|
+
}
|
|
345
|
+
|
|
219
346
|
/** Plain-text projection — used for search, previews, and a11y labels. */
|
|
220
347
|
export function markdownToPlainText(text) {
|
|
221
348
|
return parseMarkdown(text)
|
|
222
|
-
.map((block) =>
|
|
223
|
-
block.type === "image"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
349
|
+
.map((block) => {
|
|
350
|
+
if (block.type === "image") return block.alt;
|
|
351
|
+
// A table has no `spans` of its own; one line per row keeps a search
|
|
352
|
+
// match and a screen reader reading the cells in their visual order.
|
|
353
|
+
if (block.type === "table") {
|
|
354
|
+
return block.rows
|
|
355
|
+
.map((row) => row.cells.map(spansToText).join(" "))
|
|
356
|
+
.filter((row) => row.trim())
|
|
357
|
+
.join("\n");
|
|
358
|
+
}
|
|
359
|
+
return spansToText(block.spans);
|
|
360
|
+
})
|
|
227
361
|
.filter(Boolean)
|
|
228
362
|
.join("\n");
|
|
229
363
|
}
|
package/dist/richtext-view.js
CHANGED
|
@@ -9,19 +9,22 @@
|
|
|
9
9
|
// hosts cannot drift (CLAUDE.md §3, §8).
|
|
10
10
|
|
|
11
11
|
import React from "react";
|
|
12
|
-
import { useHostTheme } from "./hooks.js";
|
|
12
|
+
import { useHostTheme, useHostNavigation } from "./hooks.js";
|
|
13
13
|
import { parseMarkdown, isHttpImageSrc } from "./markdown.js";
|
|
14
14
|
import { resolveRichTextTokens } from "./richtext-tokens.js";
|
|
15
15
|
|
|
16
16
|
export function makeRichText(rn) {
|
|
17
17
|
const { Text, View, Image } = rn;
|
|
18
18
|
|
|
19
|
-
function Spans({ spans, tokens }) {
|
|
20
|
-
return spans.map((span, i) =>
|
|
21
|
-
|
|
19
|
+
function Spans({ spans, tokens, onLinkPress }) {
|
|
20
|
+
return spans.map((span, i) => {
|
|
21
|
+
const followable = !!span.href && typeof onLinkPress === "function";
|
|
22
|
+
return React.createElement(
|
|
22
23
|
Text,
|
|
23
24
|
{
|
|
24
25
|
key: i,
|
|
26
|
+
onPress: followable ? () => onLinkPress(span.href) : undefined,
|
|
27
|
+
accessibilityRole: followable ? "link" : undefined,
|
|
25
28
|
style: [
|
|
26
29
|
span.bold ? { fontWeight: "700" } : null,
|
|
27
30
|
span.italic ? { fontStyle: "italic" } : null,
|
|
@@ -33,18 +36,113 @@ export function makeRichText(rn) {
|
|
|
33
36
|
color: tokens.mutedColor,
|
|
34
37
|
}
|
|
35
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,
|
|
36
44
|
],
|
|
37
45
|
},
|
|
38
46
|
span.text,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// sc-7349: cells are equal-width flex columns rather than a real table
|
|
52
|
+
// layout — `display: table` has no native equivalent, and a percentage grid
|
|
53
|
+
// is the one shape both hosts resolve identically. Long cell text therefore
|
|
54
|
+
// WRAPS instead of scrolling sideways, which is what a phone wants.
|
|
55
|
+
function TableBlock({ block, tokens, paragraph, onLinkPress }) {
|
|
56
|
+
const align = Array.isArray(block.align) ? block.align : [];
|
|
57
|
+
const rows = (Array.isArray(block.rows) ? block.rows : []).map((row) => ({
|
|
58
|
+
header: !!row.header,
|
|
59
|
+
cells: Array.isArray(row.cells) ? row.cells : [],
|
|
60
|
+
}));
|
|
61
|
+
const columns = rows.reduce(
|
|
62
|
+
(widest, row) => Math.max(widest, row.cells.length),
|
|
63
|
+
0,
|
|
64
|
+
);
|
|
65
|
+
if (columns === 0) return null;
|
|
66
|
+
|
|
67
|
+
return React.createElement(
|
|
68
|
+
View,
|
|
69
|
+
{
|
|
70
|
+
style: {
|
|
71
|
+
borderWidth: 1,
|
|
72
|
+
borderColor: tokens.border,
|
|
73
|
+
borderRadius: tokens.radius,
|
|
74
|
+
overflow: "hidden",
|
|
75
|
+
marginBottom: tokens.blockGap,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
rows.map((row, r) =>
|
|
79
|
+
React.createElement(
|
|
80
|
+
View,
|
|
81
|
+
{
|
|
82
|
+
key: r,
|
|
83
|
+
style: {
|
|
84
|
+
flexDirection: "row",
|
|
85
|
+
borderBottomWidth: r === rows.length - 1 ? 0 : 1,
|
|
86
|
+
borderBottomColor: tokens.border,
|
|
87
|
+
...(row.header ? { backgroundColor: tokens.surface } : null),
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
// Every row is padded out to the widest, so a short row cannot
|
|
91
|
+
// collapse the grid out of alignment.
|
|
92
|
+
Array.from({ length: columns }, (_unused, c) =>
|
|
93
|
+
React.createElement(
|
|
94
|
+
View,
|
|
95
|
+
{
|
|
96
|
+
key: c,
|
|
97
|
+
style: {
|
|
98
|
+
flex: 1,
|
|
99
|
+
padding: tokens.markerGap,
|
|
100
|
+
borderRightWidth: c === columns - 1 ? 0 : 1,
|
|
101
|
+
borderRightColor: tokens.border,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
React.createElement(
|
|
105
|
+
Text,
|
|
106
|
+
{
|
|
107
|
+
style: [
|
|
108
|
+
paragraph,
|
|
109
|
+
{
|
|
110
|
+
marginBottom: 0,
|
|
111
|
+
fontWeight: row.header ? "700" : "400",
|
|
112
|
+
textAlign: align[c] || "left",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
React.createElement(Spans, {
|
|
117
|
+
spans: row.cells[c] || [],
|
|
118
|
+
tokens,
|
|
119
|
+
onLinkPress,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
),
|
|
39
125
|
),
|
|
40
126
|
);
|
|
41
127
|
}
|
|
42
128
|
|
|
43
|
-
function RichText({ value, renderImage, style, testID }) {
|
|
129
|
+
function RichText({ value, renderImage, followLinks = true, style, testID }) {
|
|
44
130
|
const theme = useHostTheme();
|
|
131
|
+
// The non-throwing reader, so RichText stays renderable outside a widget
|
|
132
|
+
// host (a Studio preview, a test) the way DateTimePicker does.
|
|
133
|
+
const navigation = useHostNavigation();
|
|
45
134
|
const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
|
|
46
135
|
const blocks = React.useMemo(() => parseMarkdown(value), [value]);
|
|
47
136
|
|
|
137
|
+
// sc-7349: `openLink` is the platform's ONE link resolver — it decides page
|
|
138
|
+
// / external / refuse, so a stored `javascript:` target can never be
|
|
139
|
+
// performed here. Never Linking.openURL, which performs anything.
|
|
140
|
+
const onLinkPress = React.useMemo(() => {
|
|
141
|
+
if (!followLinks) return null;
|
|
142
|
+
if (!navigation || typeof navigation.openLink !== "function") return null;
|
|
143
|
+
return (href) => navigation.openLink(href);
|
|
144
|
+
}, [followLinks, navigation]);
|
|
145
|
+
|
|
48
146
|
if (blocks.length === 0) return null;
|
|
49
147
|
|
|
50
148
|
const paragraph = {
|
|
@@ -59,7 +157,21 @@ export function makeRichText(rn) {
|
|
|
59
157
|
View,
|
|
60
158
|
{ style, testID },
|
|
61
159
|
blocks.map((block, i) => {
|
|
62
|
-
|
|
160
|
+
if (block.type === "table") {
|
|
161
|
+
return React.createElement(TableBlock, {
|
|
162
|
+
key: i,
|
|
163
|
+
block,
|
|
164
|
+
tokens,
|
|
165
|
+
paragraph,
|
|
166
|
+
onLinkPress,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const spans = React.createElement(Spans, {
|
|
171
|
+
spans: block.spans,
|
|
172
|
+
tokens,
|
|
173
|
+
onLinkPress,
|
|
174
|
+
});
|
|
63
175
|
|
|
64
176
|
if (block.type === "heading") {
|
|
65
177
|
return React.createElement(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.134.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",
|