@colixsystems/widget-sdk 0.275.2 → 0.277.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 +13 -3
- package/dist/bundled-assets.cjs +6 -2
- package/dist/bundled-assets.js +0 -1
- package/dist/contract.cjs +13 -9
- package/dist/contract.js +10 -7
- package/dist/define-widget.js +8 -1
- package/dist/index.d.ts +15 -16
- package/dist/linter.cjs +279 -210
- package/dist/lucideIconName.cjs +14 -5
- package/dist/manifest.cjs +7 -15
- package/dist/manifest.js +10 -69
- 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/navigation.cjs +31 -11
- package/dist/richtext-tokens.js +1 -25
- package/dist/source-mask.cjs +169 -0
- package/dist/theme-appearance.cjs +7 -5
- package/dist/theme-appearance.js +1 -1
- package/dist/theme-background.cjs +3 -8
- package/dist/theme-components.cjs +13 -10
- package/package.json +63 -3
- package/src/contract.js +10 -7
- 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/dist/lucideIconName.cjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// algorithm, same behaviour. See lucideIconName.js for the rationale.
|
|
1
|
+
// GENERATED from lucideIconName.js by scripts/generate-cjs.js — DO NOT EDIT.
|
|
2
|
+
// Change lucideIconName.js, then run `npm run generate:cjs` in packages/widget-sdk.
|
|
4
3
|
"use strict";
|
|
5
4
|
|
|
5
|
+
// Normalize a human-typed Lucide icon name to the PascalCase form Lucide
|
|
6
|
+
// exports its components under. The lucide.dev gallery shows kebab-case
|
|
7
|
+
// (`arrow-right`) and its copy button hands you kebab-case, but
|
|
8
|
+
// `lucide-react` / `lucide-react-native` key their components in PascalCase
|
|
9
|
+
// (`ArrowRight`). This bridges the two so any of kebab / snake / spaced /
|
|
10
|
+
// camel / PascalCase resolves: `arrow-right`, `arrow_right`, `arrow right`,
|
|
11
|
+
// `arrowRight`, and `ArrowRight` all become `ArrowRight`; `building-2` becomes
|
|
12
|
+
// `Building2`. Non-strings and blanks return "" so callers fall back to a
|
|
13
|
+
// placeholder. Already-PascalCase names pass through unchanged.
|
|
6
14
|
function normalizeLucideIconName(name) {
|
|
7
15
|
if (typeof name !== "string") return "";
|
|
8
16
|
const trimmed = name.trim();
|
|
@@ -13,5 +21,6 @@ function normalizeLucideIconName(name) {
|
|
|
13
21
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
|
14
22
|
.join("");
|
|
15
23
|
}
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
module.exports = {
|
|
25
|
+
normalizeLucideIconName,
|
|
26
|
+
};
|
package/dist/manifest.cjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// re-exports the same functions defined here so there is exactly one
|
|
5
|
-
// implementation.
|
|
1
|
+
// GENERATED from manifest.js by scripts/generate-cjs.js — DO NOT EDIT.
|
|
2
|
+
// Change manifest.js, then run `npm run generate:cjs` in packages/widget-sdk.
|
|
3
|
+
"use strict";
|
|
6
4
|
|
|
7
5
|
const { CONTRACT } = require("./contract.cjs");
|
|
8
6
|
|
|
7
|
+
// Structural validation of a widget manifest, shared by the CLI, hosts and backend.
|
|
8
|
+
|
|
9
|
+
|
|
9
10
|
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
11
|
|
|
11
12
|
// sc-4505 — a payload / input field map: { <field>: { type, label? } }. This is
|
|
@@ -120,16 +121,7 @@ function validateManifestTranslations(translations, manifestId, errors) {
|
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
// REQ-MKT-ACTION (sc-4879):
|
|
124
|
-
//
|
|
125
|
-
// `manifest.actions` was how an automation shipped before Actions existed as
|
|
126
|
-
// their own marketplace deliverable — a widget was two products in one package,
|
|
127
|
-
// a component that renders and a script that never renders at all. It is now
|
|
128
|
-
// REFUSED rather than ignored: an author who declares it believes the
|
|
129
|
-
// automation will ship, and silence would publish that misunderstanding.
|
|
130
|
-
//
|
|
131
|
-
// The replacement is a marketplace Action (`@colixsystems/action-sdk`), which
|
|
132
|
-
// the workspace installs and configures on its own.
|
|
124
|
+
// REQ-MKT-ACTION (sc-4879): refused, not ignored — the author expects it to ship.
|
|
133
125
|
function validateManifestActions(_actions, errors) {
|
|
134
126
|
errors.push(
|
|
135
127
|
"manifest.actions is no longer supported — ship the automation as a " +
|
package/dist/manifest.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
// `validateManifest(...)` without a dynamic-import detour. The two files
|
|
3
|
-
// share their constants by mutual import via createRequire — manifest.js
|
|
4
|
-
// re-exports the same functions defined here so there is exactly one
|
|
5
|
-
// implementation.
|
|
1
|
+
// Structural validation of a widget manifest, shared by the CLI, hosts and backend.
|
|
6
2
|
|
|
7
|
-
import { CONTRACT
|
|
3
|
+
import { CONTRACT } from "./contract.js";
|
|
8
4
|
|
|
9
5
|
const PAYLOAD_VALUE_TYPES = CONTRACT.payloadValueTypes;
|
|
10
6
|
|
|
@@ -39,20 +35,6 @@ const VALID_CATEGORIES = new Set([
|
|
|
39
35
|
]);
|
|
40
36
|
const VALID_PLATFORMS = new Set(["web", "native"]);
|
|
41
37
|
|
|
42
|
-
// REQ-WIDGET-ACTION — structural validation for manifest-declared server
|
|
43
|
-
// actions. Mirrors the backend action.service caps; the runner re-validates
|
|
44
|
-
// the cron expression (node-cron) and binds the tenant-local API key + table
|
|
45
|
-
// at enable time, so we only check shape + size here (cross-env: no Buffer).
|
|
46
|
-
const VALID_ACTION_TRIGGERS = new Set([
|
|
47
|
-
"schedule",
|
|
48
|
-
"record_created",
|
|
49
|
-
"record_updated",
|
|
50
|
-
"record_deleted",
|
|
51
|
-
]);
|
|
52
|
-
const ACTION_SCRIPT_MAX_BYTES = 200 * 1024;
|
|
53
|
-
const ACTION_TIMEOUT_MIN_MS = 100;
|
|
54
|
-
const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
|
|
55
|
-
|
|
56
38
|
// REQ-L10N-WIDGET — caps + patterns for manifest-declared translations.
|
|
57
39
|
// The relative key pattern is deliberately tighter than the dictionary's
|
|
58
40
|
// own KEY_RE: once the host namespaces it (`widget.<id>.<key>`) the result
|
|
@@ -134,54 +116,13 @@ function validateManifestTranslations(translations, manifestId, errors) {
|
|
|
134
116
|
}
|
|
135
117
|
}
|
|
136
118
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (a === null || typeof a !== "object") {
|
|
145
|
-
errors.push("manifest.actions entries must be objects");
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
if (!isNonEmptyString(a.key)) {
|
|
149
|
-
errors.push("manifest.actions[].key must be a non-empty string");
|
|
150
|
-
} else if (seenKeys.has(a.key)) {
|
|
151
|
-
errors.push(`manifest.actions[].key "${a.key}" is duplicated`);
|
|
152
|
-
} else {
|
|
153
|
-
seenKeys.add(a.key);
|
|
154
|
-
}
|
|
155
|
-
pushIf(errors, isNonEmptyString(a.name), "manifest.actions[].name must be a non-empty string");
|
|
156
|
-
const triggerTypes = normaliseActionTriggerTypes(a);
|
|
157
|
-
if (triggerTypes === null) {
|
|
158
|
-
errors.push(
|
|
159
|
-
`manifest.actions[].triggerTypes must be a non-empty array of unique values from ${[...VALID_ACTION_TRIGGERS].join(", ")}`,
|
|
160
|
-
);
|
|
161
|
-
} else if (triggerTypes.includes("schedule")) {
|
|
162
|
-
pushIf(
|
|
163
|
-
errors,
|
|
164
|
-
isNonEmptyString(a.scheduleCron),
|
|
165
|
-
"manifest.actions[].scheduleCron is required when triggerTypes contains 'schedule'",
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
if (!isNonEmptyString(a.scriptSource)) {
|
|
169
|
-
errors.push("manifest.actions[].scriptSource must be a non-empty string");
|
|
170
|
-
} else if (utf8ByteLength(a.scriptSource) > ACTION_SCRIPT_MAX_BYTES) {
|
|
171
|
-
errors.push("manifest.actions[].scriptSource exceeds 200 KiB");
|
|
172
|
-
}
|
|
173
|
-
if (a.timeoutMs !== undefined) {
|
|
174
|
-
const t = Number(a.timeoutMs);
|
|
175
|
-
if (!Number.isFinite(t) || t < ACTION_TIMEOUT_MIN_MS || t > ACTION_TIMEOUT_MAX_MS) {
|
|
176
|
-
errors.push("manifest.actions[].timeoutMs must be between 100 and 300000");
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (a.triggerTableId !== undefined || a.apiKeyId !== undefined) {
|
|
180
|
-
errors.push(
|
|
181
|
-
"manifest.actions[] must not include triggerTableId or apiKeyId — those are tenant-local and bound after install",
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
119
|
+
// REQ-MKT-ACTION (sc-4879): refused, not ignored — the author expects it to ship.
|
|
120
|
+
function validateManifestActions(_actions, errors) {
|
|
121
|
+
errors.push(
|
|
122
|
+
"manifest.actions is no longer supported — ship the automation as a " +
|
|
123
|
+
"marketplace Action (@colixsystems/action-sdk) instead of declaring it " +
|
|
124
|
+
"on a widget",
|
|
125
|
+
);
|
|
185
126
|
}
|
|
186
127
|
|
|
187
128
|
function canonicalCategory(c) {
|
|
@@ -361,7 +302,7 @@ function validateManifest(m) {
|
|
|
361
302
|
}
|
|
362
303
|
}
|
|
363
304
|
|
|
364
|
-
//
|
|
305
|
+
// Present at all is now an error (sc-4879) — see validateManifestActions.
|
|
365
306
|
if (manifest.actions !== undefined) {
|
|
366
307
|
validateManifestActions(manifest.actions, errors);
|
|
367
308
|
}
|
|
@@ -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
|
+
}
|