@colixsystems/widget-sdk 0.275.1 → 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 +11 -3
- package/dist/contract.cjs +8 -5
- package/dist/contract.js +8 -5
- package/dist/index.d.ts +13 -13
- package/dist/linter.cjs +444 -162
- package/dist/linter.js +444 -164
- 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/dist/markdown-edit.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// sc-6970 — the selection edits `<MarkdownInput>`'s toolbar performs.
|
|
2
|
-
//
|
|
3
|
-
// Pure string maths, kept out of the component the way `richtext-tokens.js`
|
|
4
|
-
// keeps the theme maths out: a toolbar button is only correct if it wraps the
|
|
5
|
-
// author's SELECTION rather than appending at the end, and that is a thing
|
|
6
|
-
// tests can pin without a renderer.
|
|
7
|
-
|
|
8
|
-
import { escapeMarkdownLabel, formatMarkdownTable } from "./markdown.js";
|
|
9
|
-
|
|
10
|
-
const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
|
|
11
|
-
|
|
12
|
-
// The literal the inserted table skeleton opens with; the caret lands on it.
|
|
13
|
-
const FIRST_HEADING = "Column 1";
|
|
14
|
-
|
|
15
|
-
const LINE_KINDS = Object.freeze({
|
|
16
|
-
heading2: { marker: "## ", test: /^##\s/ },
|
|
17
|
-
heading3: { marker: "### ", test: /^###\s/ },
|
|
18
|
-
bullet: { marker: "- ", test: /^[-*]\s/ },
|
|
19
|
-
ordered: { marker: null, test: /^\d+[.)]\s/ },
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
function clamp(index, max) {
|
|
23
|
-
const n = Number.isFinite(index) ? Math.trunc(index) : 0;
|
|
24
|
-
return Math.min(Math.max(n, 0), max);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function readRange(text, selection) {
|
|
28
|
-
const src = typeof text === "string" ? text : "";
|
|
29
|
-
const raw = selection && typeof selection === "object" ? selection : {};
|
|
30
|
-
const a = clamp(raw.start, src.length);
|
|
31
|
-
const b = clamp(raw.end, src.length);
|
|
32
|
-
return { src, start: Math.min(a, b), end: Math.max(a, b) };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function wrap(marker) {
|
|
36
|
-
return (text, selection) => {
|
|
37
|
-
const { src, start, end } = readRange(text, selection);
|
|
38
|
-
const selected = src.slice(start, end);
|
|
39
|
-
const pair = marker.length * 2;
|
|
40
|
-
|
|
41
|
-
// Already wrapped — the button toggles the emphasis back off.
|
|
42
|
-
if (selected.length >= pair && selected.startsWith(marker) && selected.endsWith(marker)) {
|
|
43
|
-
const inner = selected.slice(marker.length, selected.length - marker.length);
|
|
44
|
-
return {
|
|
45
|
-
value: src.slice(0, start) + inner + src.slice(end),
|
|
46
|
-
selection: { start, end: start + inner.length },
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
value: src.slice(0, start) + marker + selected + marker + src.slice(end),
|
|
52
|
-
// Leaves the author's own text selected (or the caret between an empty
|
|
53
|
-
// pair), so typing continues inside the emphasis they just asked for.
|
|
54
|
-
selection: { start: start + marker.length, end: end + marker.length },
|
|
55
|
-
};
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function linePrefix(kind) {
|
|
60
|
-
const spec = LINE_KINDS[kind];
|
|
61
|
-
return (text, selection) => {
|
|
62
|
-
const { src, start, end } = readRange(text, selection);
|
|
63
|
-
const blockStart = src.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
|
64
|
-
const newlineAfter = src.indexOf("\n", end);
|
|
65
|
-
const blockEnd = newlineAfter === -1 ? src.length : newlineAfter;
|
|
66
|
-
|
|
67
|
-
const lines = src.slice(blockStart, blockEnd).split("\n");
|
|
68
|
-
const filled = lines.filter((line) => line.trim());
|
|
69
|
-
// Toggled off only when EVERY line already carries this kind, so a mixed
|
|
70
|
-
// selection becomes uniformly marked rather than half-stripped.
|
|
71
|
-
const removing =
|
|
72
|
-
filled.length > 0 &&
|
|
73
|
-
filled.every((line) => spec.test.test(line.replace(/^\s*/, "")));
|
|
74
|
-
|
|
75
|
-
let ordinal = 0;
|
|
76
|
-
const rebuilt = lines.map((line) => {
|
|
77
|
-
const match = LINE_RE.exec(line);
|
|
78
|
-
const indent = match[1];
|
|
79
|
-
const body = match[3];
|
|
80
|
-
if (!line.trim()) return line;
|
|
81
|
-
if (removing) return indent + body;
|
|
82
|
-
ordinal += 1;
|
|
83
|
-
return indent + (spec.marker || `${ordinal}. `) + body;
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
const block = rebuilt.join("\n");
|
|
87
|
-
return {
|
|
88
|
-
value: src.slice(0, blockStart) + block + src.slice(blockEnd),
|
|
89
|
-
selection: { start: blockStart, end: blockStart + block.length },
|
|
90
|
-
};
|
|
91
|
-
};
|
|
92
|
-
}
|
|
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
|
-
|
|
154
|
-
export const markdownEditOps = Object.freeze({
|
|
155
|
-
bold: wrap("**"),
|
|
156
|
-
italic: wrap("*"),
|
|
157
|
-
code: wrap("`"),
|
|
158
|
-
heading2: linePrefix("heading2"),
|
|
159
|
-
heading3: linePrefix("heading3"),
|
|
160
|
-
bullet: linePrefix("bullet"),
|
|
161
|
-
ordered: linePrefix("ordered"),
|
|
162
|
-
link: linkOp(),
|
|
163
|
-
table: tableOp(),
|
|
164
|
-
});
|
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
// sc-14974 — the live-styled field behind `<MarkdownInput>` (web).
|
|
2
|
-
//
|
|
3
|
-
// A `<textarea>` holds one string in one style, so the browser half is a
|
|
4
|
-
// plain-text `contentEditable` instead: the author types into the styled text
|
|
5
|
-
// itself. React cannot own the children of a node the browser is editing, so
|
|
6
|
-
// this element is painted by hand from `markdownLiveSegments` and read back
|
|
7
|
-
// as text. The native mirror is ./markdown-live-field.native.js.
|
|
8
|
-
|
|
9
|
-
import React from "react";
|
|
10
|
-
import { markdownLiveSegments } from "./markdown.js";
|
|
11
|
-
import { markdownLiveRunStyle } from "./richtext-tokens.js";
|
|
12
|
-
import { flattenStyle } from "./flatten-style.js";
|
|
13
|
-
import { withTouchInputFontFloor } from "./touch-input-font-floor.js";
|
|
14
|
-
|
|
15
|
-
const SENTINEL = "data-live-sentinel";
|
|
16
|
-
const HISTORY_LIMIT = 100;
|
|
17
|
-
// Keystrokes closer together than this undo as one step, like a textarea.
|
|
18
|
-
const HISTORY_COALESCE_MS = 500;
|
|
19
|
-
|
|
20
|
-
function hasCoarsePointer() {
|
|
21
|
-
return (
|
|
22
|
-
typeof window !== "undefined" &&
|
|
23
|
-
typeof window.matchMedia === "function" &&
|
|
24
|
-
window.matchMedia("(pointer: coarse)").matches
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isBlock(node) {
|
|
29
|
-
return node.nodeName === "DIV" || node.nodeName === "P";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The browser may split text, add a `<br>` for Enter, or wrap a pasted line
|
|
33
|
-
// in a block; each of those reads back as the newline it stands for.
|
|
34
|
-
function readText(node, dropTrailingBreak = false) {
|
|
35
|
-
const children = [...node.childNodes];
|
|
36
|
-
// A final <br> only holds the last line open, so it is never content.
|
|
37
|
-
const last = children[children.length - 1];
|
|
38
|
-
if (dropTrailingBreak && last && last.nodeName === "BR") children.pop();
|
|
39
|
-
let out = "";
|
|
40
|
-
children.forEach((child, i) => {
|
|
41
|
-
if (child.nodeType === 3) out += child.data;
|
|
42
|
-
else if (child.nodeName === "BR") {
|
|
43
|
-
if (!child.hasAttribute(SENTINEL)) out += "\n";
|
|
44
|
-
} else {
|
|
45
|
-
if (isBlock(child) && i > 0) out += "\n";
|
|
46
|
-
out += readText(child);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
return out;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const fieldText = (root) => readText(root, true);
|
|
53
|
-
|
|
54
|
-
function firstDifference(a, b) {
|
|
55
|
-
let i = 0;
|
|
56
|
-
while (i < a.length && a.charAt(i) === b.charAt(i)) i += 1;
|
|
57
|
-
return i;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function offsetOf(root, node, offset) {
|
|
61
|
-
const range = root.ownerDocument.createRange();
|
|
62
|
-
range.setStart(root, 0);
|
|
63
|
-
range.setEnd(node, offset);
|
|
64
|
-
return readText(range.cloneContents());
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function readSelection(root) {
|
|
68
|
-
const selection = root.ownerDocument.getSelection();
|
|
69
|
-
if (!selection || selection.rangeCount === 0) return null;
|
|
70
|
-
const { anchorNode, focusNode } = selection;
|
|
71
|
-
if (!root.contains(anchorNode) || !root.contains(focusNode)) return null;
|
|
72
|
-
const anchor = offsetOf(root, anchorNode, selection.anchorOffset).length;
|
|
73
|
-
const focus = offsetOf(root, focusNode, selection.focusOffset).length;
|
|
74
|
-
return { start: Math.min(anchor, focus), end: Math.max(anchor, focus) };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Only ever called on a tree `paint` built: text nodes inside spans.
|
|
78
|
-
function locate(root, target) {
|
|
79
|
-
let remaining = target;
|
|
80
|
-
for (const child of root.childNodes) {
|
|
81
|
-
const textNode = child.nodeType === 3 ? child : child.firstChild;
|
|
82
|
-
if (!textNode || textNode.nodeType !== 3) continue;
|
|
83
|
-
if (remaining <= textNode.data.length) return [textNode, remaining];
|
|
84
|
-
remaining -= textNode.data.length;
|
|
85
|
-
}
|
|
86
|
-
return [root, Math.max(root.childNodes.length - 1, 0)];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function writeSelection(root, { start, end }) {
|
|
90
|
-
const selection = root.ownerDocument.getSelection();
|
|
91
|
-
if (!selection) return;
|
|
92
|
-
const range = root.ownerDocument.createRange();
|
|
93
|
-
range.setStart(...locate(root, start));
|
|
94
|
-
range.setEnd(...locate(root, end));
|
|
95
|
-
selection.removeAllRanges();
|
|
96
|
-
selection.addRange(range);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function cssOf(style) {
|
|
100
|
-
const css = {};
|
|
101
|
-
for (const [key, value] of Object.entries(style)) {
|
|
102
|
-
css[key] = typeof value === "number" && key !== "fontWeight" ? `${value}px` : value;
|
|
103
|
-
}
|
|
104
|
-
return css;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function paint(root, value, tokens) {
|
|
108
|
-
const doc = root.ownerDocument;
|
|
109
|
-
root.textContent = "";
|
|
110
|
-
for (const run of markdownLiveSegments(value)) {
|
|
111
|
-
const span = doc.createElement("span");
|
|
112
|
-
span.textContent = run.text;
|
|
113
|
-
Object.assign(span.style, cssOf(markdownLiveRunStyle(run, tokens)));
|
|
114
|
-
root.appendChild(span);
|
|
115
|
-
}
|
|
116
|
-
// Without it a trailing newline has no line to draw, nor a caret to hold.
|
|
117
|
-
const sentinel = doc.createElement("br");
|
|
118
|
-
sentinel.setAttribute(SENTINEL, "");
|
|
119
|
-
root.appendChild(sentinel);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// The field arrives as a React Native style; this element is plain DOM.
|
|
123
|
-
function fieldCss(style) {
|
|
124
|
-
const { lineHeight, ...rest } = flattenStyle(withTouchInputFontFloor(style, hasCoarsePointer()));
|
|
125
|
-
delete rest.textAlignVertical;
|
|
126
|
-
return {
|
|
127
|
-
...rest,
|
|
128
|
-
...(typeof lineHeight === "number" ? { lineHeight: `${lineHeight}px` } : null),
|
|
129
|
-
...(rest.borderWidth ? { borderStyle: "solid" } : null),
|
|
130
|
-
overflowY: "auto",
|
|
131
|
-
whiteSpace: "pre-wrap",
|
|
132
|
-
overflowWrap: "anywhere",
|
|
133
|
-
outlineOffset: 2,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function isUndoKey(event) {
|
|
138
|
-
return (event.metaKey || event.ctrlKey) && !event.altKey;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
export const MarkdownLiveField = React.forwardRef(function MarkdownLiveField(
|
|
143
|
-
{ value, onChangeText, onSelectionChange, selection, placeholder, accessibilityLabel, tokens, style, testID },
|
|
144
|
-
handle,
|
|
145
|
-
) {
|
|
146
|
-
const ref = React.useRef(null);
|
|
147
|
-
const painted = React.useRef(null);
|
|
148
|
-
const emitted = React.useRef(null);
|
|
149
|
-
const announced = React.useRef(false);
|
|
150
|
-
const composing = React.useRef(false);
|
|
151
|
-
const [isComposing, setComposing] = React.useState(false);
|
|
152
|
-
const [, rerender] = React.useReducer((n) => n + 1, 0);
|
|
153
|
-
const history = React.useRef({ past: [], future: [], at: 0 });
|
|
154
|
-
const latest = React.useRef(null);
|
|
155
|
-
|
|
156
|
-
// `coalesce` joins the open step; a typed edit leaves its step open for more.
|
|
157
|
-
const record = React.useCallback((coalesce, typing) => {
|
|
158
|
-
const stack = history.current;
|
|
159
|
-
const now = Date.now();
|
|
160
|
-
if (!coalesce || now - stack.at > HISTORY_COALESCE_MS || stack.past.length === 0) {
|
|
161
|
-
stack.past.push({ value: latest.current.value, selection: latest.current.selection });
|
|
162
|
-
if (stack.past.length > HISTORY_LIMIT) stack.past.shift();
|
|
163
|
-
}
|
|
164
|
-
stack.at = typing ? now : 0;
|
|
165
|
-
stack.future = [];
|
|
166
|
-
}, []);
|
|
167
|
-
|
|
168
|
-
// The toolbar edits the value from outside; this makes that edit undoable.
|
|
169
|
-
React.useImperativeHandle(
|
|
170
|
-
handle,
|
|
171
|
-
() => ({
|
|
172
|
-
recordEdit() {
|
|
173
|
-
record(false);
|
|
174
|
-
announced.current = true;
|
|
175
|
-
},
|
|
176
|
-
}),
|
|
177
|
-
[record],
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
const step = (redo) => {
|
|
181
|
-
const stack = history.current;
|
|
182
|
-
const entry = (redo ? stack.future : stack.past).pop();
|
|
183
|
-
if (!entry) return;
|
|
184
|
-
(redo ? stack.past : stack.future).push({ value, selection });
|
|
185
|
-
stack.at = 0;
|
|
186
|
-
if (typeof onChangeText !== "function") return;
|
|
187
|
-
emitted.current = entry.value;
|
|
188
|
-
onChangeText(entry.value);
|
|
189
|
-
const root = ref.current;
|
|
190
|
-
if (root) {
|
|
191
|
-
paint(root, entry.value, tokens);
|
|
192
|
-
painted.current = { value: entry.value, tokens };
|
|
193
|
-
if (entry.selection) writeSelection(root, entry.selection);
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
latest.current = { value, selection, onSelectionChange, step };
|
|
197
|
-
|
|
198
|
-
// Runs after every render, so an edit the parent did not take reverts.
|
|
199
|
-
React.useLayoutEffect(() => {
|
|
200
|
-
const root = ref.current;
|
|
201
|
-
if (!root) return;
|
|
202
|
-
const unchanged =
|
|
203
|
-
painted.current && painted.current.value === value && painted.current.tokens === tokens;
|
|
204
|
-
if (unchanged && fieldText(root) === value) return;
|
|
205
|
-
const typed = emitted.current === value;
|
|
206
|
-
if (painted.current && !unchanged && !typed && !announced.current) {
|
|
207
|
-
// The parent replaced the text (a reset, another record): old steps no longer apply.
|
|
208
|
-
history.current = { past: [], future: [], at: 0 };
|
|
209
|
-
}
|
|
210
|
-
announced.current = false;
|
|
211
|
-
const caret = readSelection(root);
|
|
212
|
-
paint(root, value, tokens);
|
|
213
|
-
painted.current = { value, tokens };
|
|
214
|
-
if (caret && (typed || unchanged)) writeSelection(root, caret);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
React.useEffect(() => {
|
|
218
|
-
const root = ref.current;
|
|
219
|
-
if (!root) return undefined;
|
|
220
|
-
const doc = root.ownerDocument;
|
|
221
|
-
const report = () => {
|
|
222
|
-
const next = readSelection(root);
|
|
223
|
-
const handler = latest.current.onSelectionChange;
|
|
224
|
-
if (next && typeof handler === "function") handler({ nativeEvent: { selection: next } });
|
|
225
|
-
};
|
|
226
|
-
// The Edit menu, a context menu and shake-to-undo arrive here, not as keys.
|
|
227
|
-
const onBeforeInput = (event) => {
|
|
228
|
-
if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return;
|
|
229
|
-
event.preventDefault();
|
|
230
|
-
latest.current.step(event.inputType === "historyRedo");
|
|
231
|
-
};
|
|
232
|
-
doc.addEventListener("selectionchange", report);
|
|
233
|
-
root.addEventListener("beforeinput", onBeforeInput);
|
|
234
|
-
return () => {
|
|
235
|
-
doc.removeEventListener("selectionchange", report);
|
|
236
|
-
root.removeEventListener("beforeinput", onBeforeInput);
|
|
237
|
-
};
|
|
238
|
-
}, []);
|
|
239
|
-
|
|
240
|
-
const onInput = () => {
|
|
241
|
-
const root = ref.current;
|
|
242
|
-
if (composing.current || !root) return;
|
|
243
|
-
const next = fieldText(root);
|
|
244
|
-
if (next !== value && typeof onChangeText === "function") {
|
|
245
|
-
// A typed space or newline starts a new undo step, as in a textarea.
|
|
246
|
-
const inserted = next.length > value.length ? next.charAt(firstDifference(value, next)) : "";
|
|
247
|
-
record(!/\s/.test(inserted), true);
|
|
248
|
-
emitted.current = next;
|
|
249
|
-
onChangeText(next);
|
|
250
|
-
}
|
|
251
|
-
rerender();
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
// Every repaint replaces the nodes the browser's own undo stack points at.
|
|
255
|
-
const onKeyDown = (event) => {
|
|
256
|
-
if (!isUndoKey(event)) return;
|
|
257
|
-
const key = event.key.toLowerCase();
|
|
258
|
-
if (key !== "z" && key !== "y") return;
|
|
259
|
-
event.preventDefault();
|
|
260
|
-
step(key === "y" || event.shiftKey);
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const css = fieldCss(style);
|
|
264
|
-
return React.createElement(
|
|
265
|
-
"div",
|
|
266
|
-
{ style: { position: "relative" } },
|
|
267
|
-
React.createElement("div", {
|
|
268
|
-
ref,
|
|
269
|
-
contentEditable: "plaintext-only",
|
|
270
|
-
suppressContentEditableWarning: true,
|
|
271
|
-
role: "textbox",
|
|
272
|
-
"aria-multiline": "true",
|
|
273
|
-
"aria-label": accessibilityLabel,
|
|
274
|
-
"aria-placeholder": placeholder,
|
|
275
|
-
"data-testid": testID,
|
|
276
|
-
spellCheck: true,
|
|
277
|
-
style: css,
|
|
278
|
-
onInput,
|
|
279
|
-
onKeyDown,
|
|
280
|
-
onCompositionStart: () => {
|
|
281
|
-
composing.current = true;
|
|
282
|
-
setComposing(true);
|
|
283
|
-
},
|
|
284
|
-
onCompositionEnd: () => {
|
|
285
|
-
composing.current = false;
|
|
286
|
-
setComposing(false);
|
|
287
|
-
onInput();
|
|
288
|
-
},
|
|
289
|
-
}),
|
|
290
|
-
!value && !isComposing && placeholder
|
|
291
|
-
? React.createElement(
|
|
292
|
-
"div",
|
|
293
|
-
{
|
|
294
|
-
"aria-hidden": "true",
|
|
295
|
-
// The field's own box, transparent, so the hint sits where text will.
|
|
296
|
-
style: {
|
|
297
|
-
...css,
|
|
298
|
-
position: "absolute",
|
|
299
|
-
top: 0,
|
|
300
|
-
left: 0,
|
|
301
|
-
right: 0,
|
|
302
|
-
pointerEvents: "none",
|
|
303
|
-
color: tokens.mutedColor,
|
|
304
|
-
backgroundColor: "transparent",
|
|
305
|
-
borderColor: "transparent",
|
|
306
|
-
},
|
|
307
|
-
},
|
|
308
|
-
placeholder,
|
|
309
|
-
)
|
|
310
|
-
: null,
|
|
311
|
-
);
|
|
312
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// sc-14974 — the live-styled field behind `<MarkdownInput>` (native).
|
|
2
|
-
//
|
|
3
|
-
// iOS and Android both accept nested `<Text>` as a `TextInput`'s children and
|
|
4
|
-
// draw them as attributed text, so the formatting appears in the field the
|
|
5
|
-
// author types into. The runs join back to `value` exactly, which is what keeps
|
|
6
|
-
// the input controlled. The web mirror is ./markdown-live-field.js.
|
|
7
|
-
|
|
8
|
-
import React from "react";
|
|
9
|
-
import { Text, TextInput } from "./bounded-text.native.js";
|
|
10
|
-
import { markdownLiveSegments } from "./markdown.js";
|
|
11
|
-
import { markdownLiveRunStyle } from "./richtext-tokens.js";
|
|
12
|
-
|
|
13
|
-
// The OS keeps this field's undo history, so the toolbar has nothing to record.
|
|
14
|
-
export const MarkdownLiveField = React.forwardRef(function MarkdownLiveField(
|
|
15
|
-
{ value, onChangeText, onSelectionChange, placeholder, accessibilityLabel, tokens, style, testID },
|
|
16
|
-
ref,
|
|
17
|
-
) {
|
|
18
|
-
const runs = React.useMemo(() => markdownLiveSegments(value), [value]);
|
|
19
|
-
return React.createElement(
|
|
20
|
-
TextInput,
|
|
21
|
-
{
|
|
22
|
-
ref,
|
|
23
|
-
onChangeText,
|
|
24
|
-
onSelectionChange,
|
|
25
|
-
placeholder,
|
|
26
|
-
placeholderTextColor: tokens.mutedColor,
|
|
27
|
-
accessibilityLabel,
|
|
28
|
-
multiline: true,
|
|
29
|
-
style,
|
|
30
|
-
testID,
|
|
31
|
-
},
|
|
32
|
-
runs.map((run, i) =>
|
|
33
|
-
React.createElement(Text, { key: i, style: markdownLiveRunStyle(run, tokens) }, run.text),
|
|
34
|
-
),
|
|
35
|
-
);
|
|
36
|
-
});
|