@colixsystems/widget-sdk 0.275.2 → 0.276.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 +7 -3
- package/dist/contract.cjs +8 -5
- package/dist/contract.js +8 -5
- package/dist/index.d.ts +13 -13
- package/dist/markdown-doc.js +209 -0
- package/dist/markdown-editor-bridge.js +162 -0
- package/dist/markdown-editor-core.js +184 -0
- package/dist/markdown-editor-css.js +50 -0
- package/dist/markdown-editor-field.js +111 -0
- package/dist/markdown-editor-field.native.js +9 -0
- package/dist/markdown-editor-html.js +4 -0
- package/dist/markdown-editor-webview.js +70 -0
- package/dist/markdown-input-view.js +43 -101
- package/dist/markdown-input.js +4 -6
- package/dist/markdown-input.native.js +3 -5
- package/dist/markdown.js +1 -112
- package/dist/richtext-tokens.js +1 -25
- package/package.json +62 -2
- package/src/contract.js +8 -5
- package/dist/markdown-edit.js +0 -164
- package/dist/markdown-live-field.js +0 -312
- package/dist/markdown-live-field.native.js +0 -36
package/README.md
CHANGED
|
@@ -86,7 +86,11 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
86
86
|
|
|
87
87
|
## Status
|
|
88
88
|
|
|
89
|
-
`v0.
|
|
89
|
+
`v0.276.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.276.0 (contract 1.226.0)
|
|
92
|
+
|
|
93
|
+
**`<MarkdownInput>` edits the formatted result directly.** The field is now a rich-text editor: headings, bold, italic, code, links, bulleted and numbered lists and real tables appear as they will read, with no markdown markers in view. The toolbar applies formatting to the selection, the Table button inserts a real table, and while the caret is in one the toolbar adds buttons to add or remove rows and columns or delete it, and typing `## `, `- `, `1. ` or `**bold**` formats as you type. The value is still markdown in the `<RichText>` grammar, so stored content and `<RichText>` output are unchanged; loading a value never rewrites it, and an edit writes each construct in its canonical spelling. On iOS and Android the editor runs in a WebView from a page bundled into the SDK, so it needs no network. `showPreview`, `previewLabel` and `renderImage` are still accepted and ignored. Nothing to change in a widget that uses it.
|
|
90
94
|
|
|
91
95
|
### What's new in 0.275.2
|
|
92
96
|
|
|
@@ -2180,7 +2184,7 @@ Content is **markdown text** now: one primitive authors it, the other renders it
|
|
|
2180
2184
|
<RichText value={post.body} />
|
|
2181
2185
|
```
|
|
2182
2186
|
|
|
2183
|
-
`<MarkdownInput>` is a
|
|
2187
|
+
`<MarkdownInput>` is a rich-text field where the author edits the formatted result directly — headings, bold, italic, code, links, bulleted and numbered lists and real tables, with no markdown markers in view — plus a toolbar (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), a Table button and a Link button. The value it reads and writes is markdown in the `<RichText>` grammar below; loading a value never rewrites it. `minHeight` (150) and `maxHeight` (280) bound the field: it grows with its content between them, then scrolls inside its box instead of growing past its card. Don't put a `<RichText>` preview under it — the field already shows the result. Reach for it instead of a bare `TextInput` plus hand-rolled formatting buttons, which can only splice literal tags into the text.
|
|
2184
2188
|
|
|
2185
2189
|
`<RichText>` parses the subset — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, and one-line `` images — and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. Colour, type scale and leading come from the workspace theme; never re-style them. Pass `renderImage` (`({ src, alt, size }) => node`) when images are filestore ids, because resolving one needs the scopes your own widget holds — without it only absolute `http(s)` URLs render. HTML is never interpreted: stored text that still holds markup is stripped to plain text rather than shown as tags.
|
|
2186
2190
|
|
|
@@ -3348,7 +3352,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
3348
3352
|
- `ConfirmDialog` — the standard confirmation prompt, a preset of `Modal` and `Button`. `<ConfirmDialog visible={asking} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel="Delete" onCancel={() => setAsking(false)} onConfirm={removeOrder} />` asks before a delete or any other action that cannot be undone. Cancel, the close button, the backdrop, Escape and the Android back button fire `onCancel`; a promise returned from `onConfirm` keeps it busy (buttons disabled, a second press ignored) until it settles. Props: `visible`, `title`, `message`, `confirmLabel`, `cancelLabel`, `tone` (`default` | `destructive`), `busy`, `onConfirm`, `onCancel`, `closeAccessibilityLabel`, `testID`. Use it instead of building a confirmation from `Modal` yourself.
|
|
3349
3353
|
- `Modal` — the standard dialog, built on `Overlay`. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<Button title="Save" onPress={save} />}>…</Modal>` draws a title, a close button, a body that scrolls when it is long and a footer action row laid out below it. Its background, corner radius, padding, scrim, shadow and title colour come from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in an app matches. Props: `visible`, `onRequestClose`, `dismissible`, `title`, `footer`, `presentation` (`dialog` | `sheet`), `size` (`sm` | `md` | `lg` | `full`), `dismissOnBackdropPress`, `role`, `initialFocusRef`, `closeAccessibilityLabel`, `accessibilityLabel`, `testID`. Use it for every dialog instead of hand-building a header and an action row inside `Overlay`.
|
|
3350
3354
|
- `RichText` — the formatted-content renderer. `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
|
|
3351
|
-
- `MarkdownInput` — the authoring half of `<RichText>`. `<MarkdownInput value={draft} onChange={setDraft} />` is a
|
|
3355
|
+
- `MarkdownInput` — the authoring half of `<RichText>`. `<MarkdownInput value={draft} onChange={setDraft} />` is a rich-text field where the author edits the formatted result directly (headings, emphasis, code, links, lists and real tables), with a toolbar, a Table button and a Link button — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. The value stays markdown. Props: `value`, `onChange(next)`, `placeholder`, `pages` (offered by name in the Link form), `minHeight` (150) and `maxHeight` (280) — it grows between them, then scrolls — `accessibilityLabel`, `style`, `testID`. `showPreview`, `previewLabel` and `renderImage` are accepted and ignored.
|
|
3352
3356
|
- `parseMarkdown`, `markdownToPlainText`, `parseMarkdownImage`, `formatMarkdownImage`, `stripHtmlToMarkdown`, `MARKDOWN_IMAGE_SIZES` — the same grammar as pure helpers, for content you inspect rather than render: the block list, a marker-free projection (list previews, search, a11y labels), the one-line image form read and written, a legacy-HTML row normalised to markdown on read, and the closed size list (`small` | `medium` | `large` | `full`).
|
|
3353
3357
|
- `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
|
|
3354
3358
|
- `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Use them by default for record rows and a table's header; build your own row when it needs what `ListRow` cannot do (a swipe-to-delete row, a row that expands in place).
|
package/dist/contract.cjs
CHANGED
|
@@ -3467,13 +3467,13 @@ const PRIMITIVES = [
|
|
|
3467
3467
|
rnComponent: null,
|
|
3468
3468
|
docsUrl: null,
|
|
3469
3469
|
},
|
|
3470
|
-
// sc-6970 — formatted content, authored. The authoring half of `<RichText
|
|
3471
|
-
// a
|
|
3472
|
-
//
|
|
3470
|
+
// sc-6970 — formatted content, authored. The authoring half of `<RichText>`,
|
|
3471
|
+
// so a generated widget never has to splice HTML tags into a plain TextInput
|
|
3472
|
+
// again. sc-15080: the field edits the formatted result directly.
|
|
3473
3473
|
{
|
|
3474
3474
|
name: "MarkdownInput",
|
|
3475
3475
|
description:
|
|
3476
|
-
|
|
3476
|
+
"Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a rich-text field where the user edits the FORMATTED RESULT directly — headings, bold, italic, code, links, bulleted and numbered lists and real tables are shown as they will read, with no markdown markers in view — plus a toolbar (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a real table, with buttons to add or remove rows and columns or delete the table while the caret is in one, and a Link button that opens a form for the link text plus its target. Typing `## `, `- `, `1. `, `**bold**` or `*italic*` formats as you type. Use it rather than a bare `TextInput` plus formatting buttons of your own, and do NOT add a `<RichText>` preview under it: the field already shows the result; 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. The stored value is markdown text in the `<RichText>` grammar; loading a value never rewrites it, and an edit writes each construct in its canonical spelling. On iOS and Android the field runs in a WebView bundled into the SDK (no network). Props: `value`, `onChange(next)`, `placeholder`, `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name; 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) — the field grows with its content between them, then scrolls — `accessibilityLabel`, `style`, `testID`. `showPreview`, `previewLabel` and `renderImage` are still accepted and ignored. Images show inside the field when their source is an http(s) URL and as a labelled placeholder when it is a filestore id. Links in the field are inert, so a tap while editing cannot navigate away from the draft.",
|
|
3477
3477
|
rnComponent: null,
|
|
3478
3478
|
docsUrl: null,
|
|
3479
3479
|
},
|
|
@@ -6573,7 +6573,10 @@ const CONTRACT = deepFreeze({
|
|
|
6573
6573
|
// ships a hand-written `sv` value beside `en`.
|
|
6574
6574
|
// 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
|
|
6575
6575
|
// live; `showPreview` now defaults to false.
|
|
6576
|
-
|
|
6576
|
+
// 1.226.0: behaviour (sc-15080) — `<MarkdownInput>` is a rich-text editor
|
|
6577
|
+
// showing the formatted result; `showPreview`, `previewLabel` and
|
|
6578
|
+
// `renderImage` are accepted and ignored.
|
|
6579
|
+
version: "1.226.0",
|
|
6577
6580
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6578
6581
|
hooks: HOOKS,
|
|
6579
6582
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -3464,13 +3464,13 @@ const PRIMITIVES = [
|
|
|
3464
3464
|
rnComponent: null,
|
|
3465
3465
|
docsUrl: null,
|
|
3466
3466
|
},
|
|
3467
|
-
// sc-6970 — formatted content, authored. The authoring half of `<RichText
|
|
3468
|
-
// a
|
|
3469
|
-
//
|
|
3467
|
+
// sc-6970 — formatted content, authored. The authoring half of `<RichText>`,
|
|
3468
|
+
// so a generated widget never has to splice HTML tags into a plain TextInput
|
|
3469
|
+
// again. sc-15080: the field edits the formatted result directly.
|
|
3470
3470
|
{
|
|
3471
3471
|
name: "MarkdownInput",
|
|
3472
3472
|
description:
|
|
3473
|
-
|
|
3473
|
+
"Formatted-content editor. `<MarkdownInput value={draft} onChange={setDraft} />`. The default way to let an app USER write formatted text: a rich-text field where the user edits the FORMATTED RESULT directly — headings, bold, italic, code, links, bulleted and numbered lists and real tables are shown as they will read, with no markdown markers in view — plus a toolbar (bold, italic, code, H2, H3, bullets, numbered list), a Table button that inserts a real table, with buttons to add or remove rows and columns or delete the table while the caret is in one, and a Link button that opens a form for the link text plus its target. Typing `## `, `- `, `1. `, `**bold**` or `*italic*` formats as you type. Use it rather than a bare `TextInput` plus formatting buttons of your own, and do NOT add a `<RichText>` preview under it: the field already shows the result; 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. The stored value is markdown text in the `<RichText>` grammar; loading a value never rewrites it, and an edit writes each construct in its canonical spelling. On iOS and Android the field runs in a WebView bundled into the SDK (no network). Props: `value`, `onChange(next)`, `placeholder`, `pages` (optional `[{ id, name }]` — supply it and the Link form offers those pages to link to by name; 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) — the field grows with its content between them, then scrolls — `accessibilityLabel`, `style`, `testID`. `showPreview`, `previewLabel` and `renderImage` are still accepted and ignored. Images show inside the field when their source is an http(s) URL and as a labelled placeholder when it is a filestore id. Links in the field are inert, so a tap while editing cannot navigate away from the draft.",
|
|
3474
3474
|
rnComponent: null,
|
|
3475
3475
|
docsUrl: null,
|
|
3476
3476
|
},
|
|
@@ -6570,7 +6570,10 @@ const CONTRACT = deepFreeze({
|
|
|
6570
6570
|
// ships a hand-written `sv` value beside `en`.
|
|
6571
6571
|
// 1.225.0: behaviour (sc-14974) — `<MarkdownInput>` styles its own field
|
|
6572
6572
|
// live; `showPreview` now defaults to false.
|
|
6573
|
-
|
|
6573
|
+
// 1.226.0: behaviour (sc-15080) — `<MarkdownInput>` is a rich-text editor
|
|
6574
|
+
// showing the formatted result; `showPreview`, `previewLabel` and
|
|
6575
|
+
// `renderImage` are accepted and ignored.
|
|
6576
|
+
version: "1.226.0",
|
|
6574
6577
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6575
6578
|
hooks: HOOKS,
|
|
6576
6579
|
primitives: PRIMITIVES,
|
package/dist/index.d.ts
CHANGED
|
@@ -5711,12 +5711,13 @@ export const RichText: (props: {
|
|
|
5711
5711
|
}) => any;
|
|
5712
5712
|
|
|
5713
5713
|
/**
|
|
5714
|
-
* sc-6970 — the authoring half of `<RichText
|
|
5715
|
-
*
|
|
5716
|
-
*
|
|
5717
|
-
*
|
|
5718
|
-
*
|
|
5719
|
-
*
|
|
5714
|
+
* sc-6970 — the authoring half of `<RichText>`. sc-15080: a rich-text field
|
|
5715
|
+
* where the author edits the formatted result directly — headings, emphasis,
|
|
5716
|
+
* code, links, lists and real tables — with a formatting toolbar, while the
|
|
5717
|
+
* value stays markdown in the `<RichText>` grammar. Loading a value never
|
|
5718
|
+
* rewrites it. On iOS and Android the field runs in a bundled WebView. Use it
|
|
5719
|
+
* instead of a bare `TextInput` plus hand-rolled formatting buttons, which can
|
|
5720
|
+
* only splice literal tags into the text.
|
|
5720
5721
|
*
|
|
5721
5722
|
* @example
|
|
5722
5723
|
* <MarkdownInput value={draft} onChange={setDraft} placeholder="Write…" />
|
|
@@ -5727,14 +5728,13 @@ export const MarkdownInput: (props: {
|
|
|
5727
5728
|
/** Receives the next markdown string on every edit. */
|
|
5728
5729
|
onChange?: (next: string) => void;
|
|
5729
5730
|
placeholder?: string;
|
|
5730
|
-
/**
|
|
5731
|
+
/** The app's pages, offered by name in the Link form. */
|
|
5732
|
+
pages?: ReadonlyArray<{ id: string; name: string }>;
|
|
5733
|
+
/** @deprecated Ignored: the field shows the formatted result itself. */
|
|
5731
5734
|
previewLabel?: string;
|
|
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
|
-
*/
|
|
5735
|
+
/** @deprecated Ignored: the field shows the formatted result itself. */
|
|
5736
5736
|
showPreview?: boolean;
|
|
5737
|
-
/**
|
|
5737
|
+
/** @deprecated Ignored: the field shows the formatted result itself. */
|
|
5738
5738
|
renderImage?: (block: {
|
|
5739
5739
|
src: string;
|
|
5740
5740
|
alt: string;
|
|
@@ -5742,7 +5742,7 @@ export const MarkdownInput: (props: {
|
|
|
5742
5742
|
}) => ReactNode;
|
|
5743
5743
|
/** Field floor, so it opens at a usable height. Defaults to 150. */
|
|
5744
5744
|
minHeight?: number;
|
|
5745
|
-
/**
|
|
5745
|
+
/** The field grows with its content up to this, then scrolls. Defaults to 280. */
|
|
5746
5746
|
maxHeight?: number;
|
|
5747
5747
|
accessibilityLabel?: string;
|
|
5748
5748
|
style?: any;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// sc-15080 — the bridge between the markdown subset and the editor's document.
|
|
2
|
+
//
|
|
3
|
+
// `<MarkdownInput>` edits a rich document but stores markdown, so `<RichText>`,
|
|
4
|
+
// saved rows and every widget's `value`/`onChange` stay as they were. Reading
|
|
5
|
+
// goes through `parseMarkdown`, the SAME grammar `<RichText>` renders, so the
|
|
6
|
+
// editor can never show a construct differently from the reader (CLAUDE.md §3).
|
|
7
|
+
// Writing emits the canonical spelling of each construct; a value the author
|
|
8
|
+
// never edits is never re-written.
|
|
9
|
+
//
|
|
10
|
+
// Pure and DOM-free: both hosts' editors and the unit tests import it.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
parseMarkdown,
|
|
14
|
+
formatMarkdownImage,
|
|
15
|
+
escapeMarkdownLabel,
|
|
16
|
+
} from "./markdown.js";
|
|
17
|
+
|
|
18
|
+
const textNode = (text, marks) =>
|
|
19
|
+
marks.length > 0 ? { type: "text", text, marks } : { type: "text", text };
|
|
20
|
+
|
|
21
|
+
function inlineContent(spans) {
|
|
22
|
+
const nodes = [];
|
|
23
|
+
for (const span of spans || []) {
|
|
24
|
+
if (!span.text) continue;
|
|
25
|
+
const marks = [];
|
|
26
|
+
if (span.bold) marks.push({ type: "bold" });
|
|
27
|
+
if (span.italic) marks.push({ type: "italic" });
|
|
28
|
+
if (span.code) marks.push({ type: "code" });
|
|
29
|
+
if (span.href) marks.push({ type: "link", attrs: { href: span.href } });
|
|
30
|
+
nodes.push(textNode(span.text, marks));
|
|
31
|
+
}
|
|
32
|
+
return nodes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const paragraph = (spans) => {
|
|
36
|
+
const content = inlineContent(spans);
|
|
37
|
+
return content.length > 0 ? { type: "paragraph", content } : { type: "paragraph" };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function tableNode(block) {
|
|
41
|
+
const width = Math.max(1, ...block.rows.map((row) => row.cells.length));
|
|
42
|
+
const rows = block.rows.map((row) => {
|
|
43
|
+
const cells = [];
|
|
44
|
+
for (let i = 0; i < width; i += 1) {
|
|
45
|
+
cells.push({
|
|
46
|
+
type: row.header ? "tableHeader" : "tableCell",
|
|
47
|
+
content: [paragraph(row.cells[i])],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return { type: "tableRow", content: cells };
|
|
51
|
+
});
|
|
52
|
+
const align = [];
|
|
53
|
+
for (let i = 0; i < width; i += 1) align.push(block.align[i] || null);
|
|
54
|
+
return { type: "table", attrs: { align }, content: rows };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ordinal = (marker) => Number.parseInt(marker, 10) || 1;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Markdown text → the editor's ProseMirror JSON document. `options` go to
|
|
61
|
+
* `parseMarkdown`; the editor passes `stripLegacyHtml: false`, because the
|
|
62
|
+
* strip would delete text like `Promise<void>` the author typed.
|
|
63
|
+
*/
|
|
64
|
+
export function markdownToDoc(text, options) {
|
|
65
|
+
const content = [];
|
|
66
|
+
let list = null;
|
|
67
|
+
for (const block of parseMarkdown(text, options)) {
|
|
68
|
+
const listType = block.type === "bullet" ? "bulletList" : block.type === "ordered" ? "orderedList" : null;
|
|
69
|
+
if (listType) {
|
|
70
|
+
if (!list || list.type !== listType) {
|
|
71
|
+
list = { type: listType, content: [] };
|
|
72
|
+
if (listType === "orderedList") list.attrs = { start: ordinal(block.marker) };
|
|
73
|
+
content.push(list);
|
|
74
|
+
}
|
|
75
|
+
list.content.push({ type: "listItem", content: [paragraph(block.spans)] });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
list = null;
|
|
79
|
+
if (block.type === "heading") {
|
|
80
|
+
content.push({ type: "heading", attrs: { level: block.level }, content: inlineContent(block.spans) });
|
|
81
|
+
} else if (block.type === "paragraph") {
|
|
82
|
+
content.push(paragraph(block.spans));
|
|
83
|
+
} else if (block.type === "table") {
|
|
84
|
+
content.push(tableNode(block));
|
|
85
|
+
} else if (block.type === "image") {
|
|
86
|
+
content.push({ type: "image", attrs: { src: block.src, alt: block.alt, title: block.size } });
|
|
87
|
+
} else if (block.type === "code") {
|
|
88
|
+
content.push({
|
|
89
|
+
type: "codeBlock",
|
|
90
|
+
attrs: { language: block.lang || null },
|
|
91
|
+
...(block.text ? { content: [{ type: "text", text: block.text }] } : null),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { type: "doc", content: content.length > 0 ? content : [{ type: "paragraph" }] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const markOf = (node, type) => (node.marks || []).find((mark) => mark.type === type);
|
|
99
|
+
|
|
100
|
+
// The grammar's emphasis cannot open or close next to whitespace, so edge
|
|
101
|
+
// spaces move outside the markers instead of breaking the construct.
|
|
102
|
+
function wrap(text, marker) {
|
|
103
|
+
const lead = /^\s*/.exec(text)[0];
|
|
104
|
+
const trail = /\s*$/.exec(text.slice(lead.length))[0];
|
|
105
|
+
const core = text.slice(lead.length, text.length - trail.length);
|
|
106
|
+
return core ? `${lead}${marker}${core}${marker}${trail}` : text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function styledText(node, inTable) {
|
|
110
|
+
let text = node.text || "";
|
|
111
|
+
if (inTable) text = text.replace(/\|/g, "\\|");
|
|
112
|
+
if (markOf(node, "code")) return wrap(text.replace(/`/g, "'"), "`");
|
|
113
|
+
if (markOf(node, "bold")) return wrap(text, "**");
|
|
114
|
+
if (markOf(node, "italic")) return wrap(text, "*");
|
|
115
|
+
return text;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The grammar ends a target at whitespace or `)`, and a table cell at `|`.
|
|
119
|
+
const linkTarget = (href) =>
|
|
120
|
+
String(href || "").replace(/[\s|()<>]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
121
|
+
|
|
122
|
+
function inlineMarkdown(nodes, inTable) {
|
|
123
|
+
let out = "";
|
|
124
|
+
let link = null;
|
|
125
|
+
let label = "";
|
|
126
|
+
const closeLink = () => {
|
|
127
|
+
if (link) out += `[${escapeMarkdownLabel(label)}](${linkTarget(link)})`;
|
|
128
|
+
link = null;
|
|
129
|
+
label = "";
|
|
130
|
+
};
|
|
131
|
+
for (const node of nodes || []) {
|
|
132
|
+
if (node.type !== "text") continue;
|
|
133
|
+
const href = markOf(node, "link")?.attrs?.href || null;
|
|
134
|
+
if (href !== link) closeLink();
|
|
135
|
+
if (href) {
|
|
136
|
+
link = href;
|
|
137
|
+
label += styledText(node, inTable);
|
|
138
|
+
} else {
|
|
139
|
+
out += styledText(node, inTable);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
closeLink();
|
|
143
|
+
return out.replace(/\n/g, " ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const cellMarkdown = (cell) =>
|
|
147
|
+
(cell.content || []).map((child) => inlineMarkdown(child.content, true)).join(" ").trim();
|
|
148
|
+
|
|
149
|
+
function alignDivider(align) {
|
|
150
|
+
if (align === "center") return ":---:";
|
|
151
|
+
if (align === "right") return "---:";
|
|
152
|
+
if (align === "left") return ":---";
|
|
153
|
+
return "---";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tableMarkdown(node) {
|
|
157
|
+
const rows = (node.content || []).map((row) => (row.content || []).map(cellMarkdown));
|
|
158
|
+
if (rows.length === 0) return "";
|
|
159
|
+
const width = rows[0].length;
|
|
160
|
+
const align = node.attrs?.align || [];
|
|
161
|
+
const line = (cells) => `| ${cells.join(" | ")} |`;
|
|
162
|
+
const divider = [];
|
|
163
|
+
for (let i = 0; i < width; i += 1) divider.push(alignDivider(align[i]));
|
|
164
|
+
return [line(rows[0]), line(divider), ...rows.slice(1).map(line)].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const listItemText = (item) =>
|
|
168
|
+
(item.content || []).map((child) => inlineMarkdown(child.content, false)).join(" ").trim();
|
|
169
|
+
|
|
170
|
+
// An empty item or heading would read back as a stray `3.` or `##` paragraph.
|
|
171
|
+
const filledItems = (list) => (list.content || []).map(listItemText).filter((text) => text !== "");
|
|
172
|
+
|
|
173
|
+
function blockMarkdown(node) {
|
|
174
|
+
switch (node.type) {
|
|
175
|
+
case "heading": {
|
|
176
|
+
const text = inlineMarkdown(node.content, false).trim();
|
|
177
|
+
return text ? `${"#".repeat(node.attrs?.level || 1)} ${text}` : "";
|
|
178
|
+
}
|
|
179
|
+
case "paragraph":
|
|
180
|
+
return inlineMarkdown(node.content, false);
|
|
181
|
+
case "bulletList":
|
|
182
|
+
return filledItems(node).map((text) => `- ${text}`).join("\n");
|
|
183
|
+
case "orderedList": {
|
|
184
|
+
const start = node.attrs?.start || 1;
|
|
185
|
+
return filledItems(node).map((text, i) => `${start + i}. ${text}`).join("\n");
|
|
186
|
+
}
|
|
187
|
+
case "table":
|
|
188
|
+
return tableMarkdown(node);
|
|
189
|
+
case "image":
|
|
190
|
+
// An unsafe source was emptied on read; written back it would be prose.
|
|
191
|
+
return node.attrs?.src
|
|
192
|
+
? formatMarkdownImage({ src: node.attrs.src, alt: node.attrs.alt, size: node.attrs.title })
|
|
193
|
+
: "";
|
|
194
|
+
case "codeBlock": {
|
|
195
|
+
const body = (node.content || []).map((child) => child.text || "").join("");
|
|
196
|
+
return `\`\`\`${node.attrs?.language || ""}\n${body}\n\`\`\``;
|
|
197
|
+
}
|
|
198
|
+
default:
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The editor's ProseMirror JSON document → markdown text. */
|
|
204
|
+
export function docToMarkdown(doc) {
|
|
205
|
+
return (doc?.content || [])
|
|
206
|
+
.map(blockMarkdown)
|
|
207
|
+
.filter((block) => block.trim() !== "")
|
|
208
|
+
.join("\n\n");
|
|
209
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// sc-15080 — the bridge between `<MarkdownInput>`'s native field and the editor
|
|
2
|
+
// page it hosts in a WebView (./markdown-editor-webview.js).
|
|
3
|
+
//
|
|
4
|
+
// React Native has no editable rich-text view, so the shared editor runs in a
|
|
5
|
+
// WebView from a page bundled into the SDK: no network, no remote code. The
|
|
6
|
+
// field grows with its content between `minHeight` and `maxHeight`, then the
|
|
7
|
+
// page scrolls. The primitives are passed in, so the bridge is testable
|
|
8
|
+
// without a device; ./markdown-editor-field.native.js binds the real ones.
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { MARKDOWN_EDITOR_HTML } from "./markdown-editor-html.js";
|
|
12
|
+
|
|
13
|
+
const SOURCE = { html: MARKDOWN_EDITOR_HTML };
|
|
14
|
+
|
|
15
|
+
/** The script that hands `message` to the page; JSON twice makes it a literal. */
|
|
16
|
+
export function editorMessageScript(message) {
|
|
17
|
+
return `window.mdEditor&&window.mdEditor.receive(${JSON.stringify(JSON.stringify(message))});true;`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readMessage(event) {
|
|
21
|
+
try {
|
|
22
|
+
const message = JSON.parse(event.nativeEvent.data);
|
|
23
|
+
return message && typeof message.type === "string" ? message : null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
30
|
+
|
|
31
|
+
/** Builds the field from React Native's `View` and the WebView component. */
|
|
32
|
+
export function makeMarkdownEditorField({ View, WebView }) {
|
|
33
|
+
return React.forwardRef(function MarkdownEditorField(
|
|
34
|
+
{
|
|
35
|
+
value,
|
|
36
|
+
onChangeText,
|
|
37
|
+
onStateChange,
|
|
38
|
+
placeholder,
|
|
39
|
+
accessibilityLabel,
|
|
40
|
+
tokens,
|
|
41
|
+
minHeight,
|
|
42
|
+
maxHeight,
|
|
43
|
+
testID,
|
|
44
|
+
},
|
|
45
|
+
ref,
|
|
46
|
+
) {
|
|
47
|
+
const web = React.useRef(null);
|
|
48
|
+
const loaded = React.useRef(false);
|
|
49
|
+
const ready = React.useRef(false);
|
|
50
|
+
const shown = React.useRef(value);
|
|
51
|
+
const selection = React.useRef("");
|
|
52
|
+
const [height, setHeight] = React.useState(minHeight);
|
|
53
|
+
const [focused, setFocused] = React.useState(false);
|
|
54
|
+
|
|
55
|
+
const send = React.useCallback((message) => {
|
|
56
|
+
if (ready.current && web.current)
|
|
57
|
+
web.current.injectJavaScript(editorMessageScript(message));
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (value === shown.current) return;
|
|
62
|
+
shown.current = value;
|
|
63
|
+
send({ type: "set", markdown: value });
|
|
64
|
+
}, [value, send]);
|
|
65
|
+
|
|
66
|
+
// A theme switch repaints the page, as it does the web field.
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
send({ type: "style", tokens, minHeight });
|
|
69
|
+
}, [tokens, minHeight, send]);
|
|
70
|
+
|
|
71
|
+
// The OS may kill the page's process; reload it and re-send the value.
|
|
72
|
+
const restart = React.useCallback(() => {
|
|
73
|
+
ready.current = false;
|
|
74
|
+
loaded.current = false;
|
|
75
|
+
if (web.current) web.current.reload();
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
React.useImperativeHandle(
|
|
79
|
+
ref,
|
|
80
|
+
() => ({
|
|
81
|
+
command: (name, args) => {
|
|
82
|
+
send({ type: "command", name, args });
|
|
83
|
+
return ready.current;
|
|
84
|
+
},
|
|
85
|
+
selectedText: () => selection.current,
|
|
86
|
+
}),
|
|
87
|
+
[send],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const onMessage = (event) => {
|
|
91
|
+
const message = readMessage(event);
|
|
92
|
+
if (!message) return;
|
|
93
|
+
if (message.type === "ready") {
|
|
94
|
+
ready.current = true;
|
|
95
|
+
send({
|
|
96
|
+
type: "init",
|
|
97
|
+
markdown: shown.current,
|
|
98
|
+
placeholder,
|
|
99
|
+
label: accessibilityLabel,
|
|
100
|
+
tokens,
|
|
101
|
+
minHeight,
|
|
102
|
+
});
|
|
103
|
+
} else if (
|
|
104
|
+
message.type === "change" &&
|
|
105
|
+
typeof message.markdown === "string"
|
|
106
|
+
) {
|
|
107
|
+
shown.current = message.markdown;
|
|
108
|
+
if (typeof onChangeText === "function") onChangeText(message.markdown);
|
|
109
|
+
} else if (message.type === "height" && Number.isFinite(message.height)) {
|
|
110
|
+
setHeight(clamp(message.height, minHeight, maxHeight));
|
|
111
|
+
} else if (message.type === "focus") {
|
|
112
|
+
setFocused(message.focused === true);
|
|
113
|
+
} else if (
|
|
114
|
+
message.type === "selection" &&
|
|
115
|
+
typeof message.text === "string"
|
|
116
|
+
) {
|
|
117
|
+
selection.current = message.text;
|
|
118
|
+
} else if (message.type === "state" && typeof onStateChange === "function") {
|
|
119
|
+
onStateChange({ inTable: message.inTable === true });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return React.createElement(
|
|
124
|
+
View,
|
|
125
|
+
{
|
|
126
|
+
testID,
|
|
127
|
+
style: {
|
|
128
|
+
height,
|
|
129
|
+
borderWidth: 1,
|
|
130
|
+
borderColor: focused ? tokens.accent : tokens.border,
|
|
131
|
+
borderRadius: tokens.radius,
|
|
132
|
+
backgroundColor: tokens.surface,
|
|
133
|
+
overflow: "hidden",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
React.createElement(WebView, {
|
|
137
|
+
ref: web,
|
|
138
|
+
source: SOURCE,
|
|
139
|
+
originWhitelist: ["*"],
|
|
140
|
+
// The page is the editor; nothing in it may navigate the field away.
|
|
141
|
+
onShouldStartLoadWithRequest: () => !loaded.current,
|
|
142
|
+
onLoadEnd: () => {
|
|
143
|
+
loaded.current = true;
|
|
144
|
+
},
|
|
145
|
+
onMessage,
|
|
146
|
+
onContentProcessDidTerminate: restart,
|
|
147
|
+
onRenderProcessGone: restart,
|
|
148
|
+
javaScriptEnabled: true,
|
|
149
|
+
setSupportMultipleWindows: false,
|
|
150
|
+
keyboardDisplayRequiresUserAction: false,
|
|
151
|
+
hideKeyboardAccessoryView: true,
|
|
152
|
+
automaticallyAdjustContentInsets: false,
|
|
153
|
+
textZoom: 100,
|
|
154
|
+
scrollEnabled: height >= maxHeight,
|
|
155
|
+
// Android hands a parent ScrollView's gesture to the page only with this.
|
|
156
|
+
nestedScrollEnabled: true,
|
|
157
|
+
style: { backgroundColor: "transparent" },
|
|
158
|
+
testID: testID ? `${testID}-webview` : undefined,
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|