@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.
@@ -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
- });