@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// sc-15080 — the ONE rich-text editor behind `<MarkdownInput>`.
|
|
2
|
+
//
|
|
3
|
+
// The web host mounts it directly; the native host runs the same module inside
|
|
4
|
+
// a WebView (./markdown-editor-webview.js), because React Native has no
|
|
5
|
+
// editable rich-text surface. Both therefore edit with identical rules.
|
|
6
|
+
//
|
|
7
|
+
// The schema is limited to the markdown subset `<RichText>` renders, so the
|
|
8
|
+
// editor cannot hold anything the stored markdown would lose. Bold, italic and
|
|
9
|
+
// code exclude each other because the grammar has no nested emphasis.
|
|
10
|
+
|
|
11
|
+
import { Editor } from "@tiptap/core";
|
|
12
|
+
import { Document } from "@tiptap/extension-document";
|
|
13
|
+
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
14
|
+
import { Text } from "@tiptap/extension-text";
|
|
15
|
+
import { Bold } from "@tiptap/extension-bold";
|
|
16
|
+
import { Italic } from "@tiptap/extension-italic";
|
|
17
|
+
import { Code } from "@tiptap/extension-code";
|
|
18
|
+
import { Heading } from "@tiptap/extension-heading";
|
|
19
|
+
import { BulletList, OrderedList, ListItem, ListKeymap } from "@tiptap/extension-list";
|
|
20
|
+
import { Link } from "@tiptap/extension-link";
|
|
21
|
+
import { CodeBlock } from "@tiptap/extension-code-block";
|
|
22
|
+
import { Image } from "@tiptap/extension-image";
|
|
23
|
+
import { Table, TableRow, TableHeader, TableCell } from "@tiptap/extension-table";
|
|
24
|
+
import { Placeholder, UndoRedo, Gapcursor, TrailingNode } from "@tiptap/extensions";
|
|
25
|
+
import { markdownToDoc, docToMarkdown } from "./markdown-doc.js";
|
|
26
|
+
import { isHttpImageSrc } from "./markdown.js";
|
|
27
|
+
|
|
28
|
+
const UNSAFE_HREF_RE = /^\s*(javascript|data|vbscript):/i;
|
|
29
|
+
|
|
30
|
+
// A filestore id is not a URL, so only http(s) sources load in the editor.
|
|
31
|
+
const EditorImage = Image.extend({
|
|
32
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
33
|
+
if (isHttpImageSrc(node.attrs.src)) return ["img", HTMLAttributes];
|
|
34
|
+
return ["div", { class: "md-image-placeholder" }, node.attrs.alt || "Image"];
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const EditorTable = Table.extend({
|
|
39
|
+
addAttributes() {
|
|
40
|
+
return { ...this.parent?.(), align: { default: [], rendered: false } };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const singleParagraph = { content: "paragraph" };
|
|
45
|
+
|
|
46
|
+
function extensions(placeholder) {
|
|
47
|
+
return [
|
|
48
|
+
Document,
|
|
49
|
+
Paragraph,
|
|
50
|
+
Text,
|
|
51
|
+
Bold.extend({ excludes: "italic code" }),
|
|
52
|
+
Italic.extend({ excludes: "bold code" }),
|
|
53
|
+
Code,
|
|
54
|
+
Heading.configure({ levels: [1, 2, 3] }),
|
|
55
|
+
BulletList,
|
|
56
|
+
OrderedList,
|
|
57
|
+
ListItem.extend(singleParagraph),
|
|
58
|
+
ListKeymap,
|
|
59
|
+
Link.configure({
|
|
60
|
+
openOnClick: false,
|
|
61
|
+
autolink: false,
|
|
62
|
+
isAllowedUri: (url) => !UNSAFE_HREF_RE.test(String(url)),
|
|
63
|
+
HTMLAttributes: { target: null, rel: "noopener noreferrer nofollow" },
|
|
64
|
+
}),
|
|
65
|
+
CodeBlock,
|
|
66
|
+
EditorImage.configure({ inline: false, allowBase64: false }),
|
|
67
|
+
EditorTable.configure({ resizable: false }),
|
|
68
|
+
TableRow,
|
|
69
|
+
TableHeader.extend(singleParagraph),
|
|
70
|
+
TableCell.extend(singleParagraph),
|
|
71
|
+
Gapcursor,
|
|
72
|
+
TrailingNode,
|
|
73
|
+
UndoRedo,
|
|
74
|
+
Placeholder.configure({ placeholder: placeholder || "" }),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const COMMANDS = {
|
|
79
|
+
bold: (chain) => chain.toggleBold(),
|
|
80
|
+
italic: (chain) => chain.toggleItalic(),
|
|
81
|
+
code: (chain) => chain.toggleCode(),
|
|
82
|
+
heading2: (chain) => chain.toggleHeading({ level: 2 }),
|
|
83
|
+
heading3: (chain) => chain.toggleHeading({ level: 3 }),
|
|
84
|
+
bullet: (chain) => chain.toggleBulletList(),
|
|
85
|
+
ordered: (chain) => chain.toggleOrderedList(),
|
|
86
|
+
table: (chain) => chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }),
|
|
87
|
+
addRow: (chain) => chain.addRowAfter(),
|
|
88
|
+
addColumn: (chain) => chain.addColumnAfter(),
|
|
89
|
+
removeRow: (chain) => chain.deleteRow(),
|
|
90
|
+
removeColumn: (chain) => chain.deleteColumn(),
|
|
91
|
+
removeTable: (chain) => chain.deleteTable(),
|
|
92
|
+
undo: (chain) => chain.undo(),
|
|
93
|
+
redo: (chain) => chain.redo(),
|
|
94
|
+
link: (chain, { label, target } = {}, editor) => {
|
|
95
|
+
const href = String(target || "").trim();
|
|
96
|
+
if (!href || UNSAFE_HREF_RE.test(href)) return chain;
|
|
97
|
+
const text = String(label || "").trim();
|
|
98
|
+
const { from, to, empty } = editor.state.selection;
|
|
99
|
+
// An unchanged label links the selection in place, keeping its emphasis.
|
|
100
|
+
if (!empty && (!text || text === editor.state.doc.textBetween(from, to, " ").trim())) {
|
|
101
|
+
return chain.setLink({ href });
|
|
102
|
+
}
|
|
103
|
+
return chain.insertContent({ type: "text", text: text || href, marks: [{ type: "link", attrs: { href } }] });
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The tool keys `<MarkdownInput>`'s toolbar may send. */
|
|
108
|
+
export const MARKDOWN_EDITOR_COMMANDS = Object.freeze(Object.keys(COMMANDS));
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Mounts the editor in `element`. `onChange(markdown)` fires only for the
|
|
112
|
+
* author's edits, never for `setMarkdown` or a plugin's own normalising, so
|
|
113
|
+
* an untouched value is never rewritten. `onState({ inTable })` follows the
|
|
114
|
+
* selection, for the toolbar's table controls.
|
|
115
|
+
*/
|
|
116
|
+
export function createMarkdownEditor({
|
|
117
|
+
element,
|
|
118
|
+
markdown,
|
|
119
|
+
placeholder,
|
|
120
|
+
label,
|
|
121
|
+
onChange,
|
|
122
|
+
onFocusChange,
|
|
123
|
+
onState,
|
|
124
|
+
}) {
|
|
125
|
+
let current = typeof markdown === "string" ? markdown : "";
|
|
126
|
+
const editor = new Editor({
|
|
127
|
+
element,
|
|
128
|
+
extensions: extensions(placeholder),
|
|
129
|
+
content: markdownToDoc(current, { stripLegacyHtml: false }),
|
|
130
|
+
editorProps: {
|
|
131
|
+
attributes: {
|
|
132
|
+
role: "textbox",
|
|
133
|
+
"aria-multiline": "true",
|
|
134
|
+
...(label ? { "aria-label": label } : null),
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
// Focusing appends a trailing paragraph after a final table or list; only a
|
|
139
|
+
// transaction the author made counts as an edit.
|
|
140
|
+
editor.on("update", ({ transaction }) => {
|
|
141
|
+
if (!transaction.docChanged) return;
|
|
142
|
+
const next = docToMarkdown(editor.getJSON());
|
|
143
|
+
if (next === current) return;
|
|
144
|
+
current = next;
|
|
145
|
+
if (typeof onChange === "function") onChange(current);
|
|
146
|
+
});
|
|
147
|
+
if (typeof onState === "function") {
|
|
148
|
+
let inTable = false;
|
|
149
|
+
editor.on("transaction", () => {
|
|
150
|
+
const next = editor.isActive("table");
|
|
151
|
+
if (next !== inTable) onState({ inTable: (inTable = next) });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (typeof onFocusChange === "function") {
|
|
155
|
+
editor.on("focus", () => onFocusChange(true));
|
|
156
|
+
editor.on("blur", () => onFocusChange(false));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
/** Replaces the content unless it already reads as `markdown`. */
|
|
161
|
+
setMarkdown(next) {
|
|
162
|
+
const text = typeof next === "string" ? next : "";
|
|
163
|
+
if (text === current) return;
|
|
164
|
+
current = text;
|
|
165
|
+
editor.commands.setContent(markdownToDoc(text, { stripLegacyHtml: false }), { emitUpdate: false });
|
|
166
|
+
},
|
|
167
|
+
command(name, args) {
|
|
168
|
+
const build = COMMANDS[name];
|
|
169
|
+
if (!build) return false;
|
|
170
|
+
return build(editor.chain().focus(), args, editor).run();
|
|
171
|
+
},
|
|
172
|
+
selectedText() {
|
|
173
|
+
const { from, to } = editor.state.selection;
|
|
174
|
+
return editor.state.doc.textBetween(from, to, " ");
|
|
175
|
+
},
|
|
176
|
+
focus() {
|
|
177
|
+
editor.commands.focus();
|
|
178
|
+
},
|
|
179
|
+
destroy() {
|
|
180
|
+
editor.destroy();
|
|
181
|
+
},
|
|
182
|
+
editor,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// sc-15080 — the stylesheet `<MarkdownInput>`'s editor is painted with.
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from the editor module so the web host can style the field
|
|
4
|
+
// before the lazily loaded editor arrives, and the native WebView page can
|
|
5
|
+
// paint the same rules from the tokens it is sent.
|
|
6
|
+
|
|
7
|
+
// Theme values are written into a stylesheet, so nothing may close the rule.
|
|
8
|
+
const clean = (value) => String(value == null ? "" : value).replace(/[{}<>;\\]/g, "");
|
|
9
|
+
const px = (value) => (typeof value === "number" ? `${value}px` : clean(value));
|
|
10
|
+
// A theme font is already a CSS stack, such as `Inter, system-ui, sans-serif`.
|
|
11
|
+
const font = (family) => clean(family) || "system-ui, sans-serif";
|
|
12
|
+
|
|
13
|
+
/** The editor's stylesheet, scoped under `scope`, painted from richtext tokens. */
|
|
14
|
+
export function markdownEditorCss(tokens, scope, { fontSize, minHeight, framed = false } = {}) {
|
|
15
|
+
const t = Object.fromEntries(
|
|
16
|
+
Object.entries(tokens).map(([key, value]) => [key, typeof value === "string" ? clean(value) : value]),
|
|
17
|
+
);
|
|
18
|
+
const size = fontSize || t.bodySize;
|
|
19
|
+
const s = scope;
|
|
20
|
+
const frame = framed
|
|
21
|
+
? `${s}{border:1px solid ${t.border};border-radius:${px(t.radius)};background-color:${t.surface}}\n${s}:focus-within{border-color:${t.accent}}\n`
|
|
22
|
+
: "";
|
|
23
|
+
return `${frame}
|
|
24
|
+
${s} .ProseMirror{outline:none;min-height:${px(minHeight || 0)};padding:${px(t.padding)};box-sizing:border-box;font-family:${font(t.bodyFont)};font-size:${px(size)};line-height:${px(Math.round(size * 1.6))};color:${t.color};white-space:pre-wrap;overflow-wrap:anywhere}
|
|
25
|
+
${s} .ProseMirror>*:first-child{margin-top:0}
|
|
26
|
+
${s} .ProseMirror>*:last-child{margin-bottom:0}
|
|
27
|
+
${s} .ProseMirror p{margin:0 0 ${px(t.blockGap)}}
|
|
28
|
+
${s} .ProseMirror h1,${s} .ProseMirror h2,${s} .ProseMirror h3{font-family:${font(t.headingFont)};font-weight:700;margin:0 0 ${px(t.blockGap)}}
|
|
29
|
+
${s} .ProseMirror h1{font-size:${px(t.headingSizes[1])};line-height:1.3}
|
|
30
|
+
${s} .ProseMirror h2{font-size:${px(t.headingSizes[2])};line-height:1.3}
|
|
31
|
+
${s} .ProseMirror h3{font-size:${px(t.headingSizes[3])};line-height:1.3}
|
|
32
|
+
${s} .ProseMirror ul,${s} .ProseMirror ol{margin:0 0 ${px(t.blockGap)};padding-left:1.4em}
|
|
33
|
+
${s} .ProseMirror li p{margin:0}
|
|
34
|
+
${s} .ProseMirror code{font-family:ui-monospace,Menlo,monospace;font-size:${px(t.codeSize)};color:${t.mutedColor}}
|
|
35
|
+
${s} .ProseMirror pre{margin:0 0 ${px(t.blockGap)};padding:${px(t.blockGap)};border:1px solid ${t.border};border-radius:${px(t.radius)};white-space:pre-wrap}
|
|
36
|
+
${s} .ProseMirror a{color:${t.accent};text-decoration:underline}
|
|
37
|
+
${s} .ProseMirror table{border-collapse:collapse;width:100%;margin:0 0 ${px(t.blockGap)};table-layout:fixed}
|
|
38
|
+
${s} .ProseMirror th,${s} .ProseMirror td{border:1px solid ${t.border};padding:4px 8px;vertical-align:top;text-align:left}
|
|
39
|
+
${s} .ProseMirror th{font-weight:700}
|
|
40
|
+
${s} .ProseMirror .selectedCell{background:${t.border}}
|
|
41
|
+
${s} .ProseMirror img{display:block;max-width:100%;height:auto;margin:0 0 ${px(t.blockGap)};border-radius:${px(t.radius)}}
|
|
42
|
+
${s} .ProseMirror img.ProseMirror-selectednode{outline:2px solid ${t.accent}}
|
|
43
|
+
${s} .ProseMirror .md-image-placeholder{margin:0 0 ${px(t.blockGap)};padding:${px(t.padding)};border:1px dashed ${t.border};border-radius:${px(t.radius)};color:${t.mutedColor}}
|
|
44
|
+
${s} .ProseMirror p.is-editor-empty:first-child::before{content:attr(data-placeholder);color:${t.mutedColor};float:left;height:0;pointer-events:none}
|
|
45
|
+
${s} .ProseMirror-gapcursor{display:none;pointer-events:none;position:absolute}
|
|
46
|
+
${s} .ProseMirror-gapcursor:after{content:"";display:block;position:absolute;top:-2px;width:20px;border-top:1px solid ${t.color};animation:md-cursor-blink 1.1s steps(2,start) infinite}
|
|
47
|
+
${s} .ProseMirror-focused .ProseMirror-gapcursor{display:block}
|
|
48
|
+
@keyframes md-cursor-blink{to{visibility:hidden}}
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// sc-15080 — `<MarkdownInput>`'s editing surface (web).
|
|
2
|
+
//
|
|
3
|
+
// Mounts the shared editor (./markdown-editor-core.js) straight into the page.
|
|
4
|
+
// The editor is loaded on first use, so a widget without a `<MarkdownInput>`
|
|
5
|
+
// never downloads it. The native mirror is ./markdown-editor-field.native.js.
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { markdownEditorCss } from "./markdown-editor-css.js";
|
|
9
|
+
import { withTouchInputFontFloor } from "./touch-input-font-floor.js";
|
|
10
|
+
|
|
11
|
+
function hasCoarsePointer() {
|
|
12
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
13
|
+
? window.matchMedia("(pointer: coarse)").matches
|
|
14
|
+
: false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function touchFontSize(size) {
|
|
18
|
+
const floored = withTouchInputFontFloor({ fontSize: size }, hasCoarsePointer());
|
|
19
|
+
return Array.isArray(floored) ? floored[1].fontSize : size;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MarkdownEditorField = React.forwardRef(function MarkdownEditorField(
|
|
23
|
+
{ value, onChangeText, onStateChange, placeholder, accessibilityLabel, tokens, minHeight, maxHeight, testID },
|
|
24
|
+
ref,
|
|
25
|
+
) {
|
|
26
|
+
const host = React.useRef(null);
|
|
27
|
+
const editor = React.useRef(null);
|
|
28
|
+
const latest = React.useRef({ value, onChangeText, onStateChange });
|
|
29
|
+
latest.current = { value, onChangeText, onStateChange };
|
|
30
|
+
const [failed, setFailed] = React.useState(false);
|
|
31
|
+
const scope = `md-editor-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
import("./markdown-editor-core.js")
|
|
36
|
+
.then(({ createMarkdownEditor }) => {
|
|
37
|
+
if (cancelled || !host.current) return;
|
|
38
|
+
editor.current = createMarkdownEditor({
|
|
39
|
+
element: host.current,
|
|
40
|
+
markdown: latest.current.value,
|
|
41
|
+
placeholder,
|
|
42
|
+
label: accessibilityLabel,
|
|
43
|
+
onChange: (markdown) => {
|
|
44
|
+
const onChange = latest.current.onChangeText;
|
|
45
|
+
if (typeof onChange === "function") onChange(markdown);
|
|
46
|
+
},
|
|
47
|
+
onState: (state) => {
|
|
48
|
+
const onState = latest.current.onStateChange;
|
|
49
|
+
if (typeof onState === "function") onState(state);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
// The parent may have replaced the value while the editor loaded.
|
|
53
|
+
editor.current.setMarkdown(latest.current.value);
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
if (!cancelled) setFailed(true);
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
if (editor.current) editor.current.destroy();
|
|
61
|
+
editor.current = null;
|
|
62
|
+
};
|
|
63
|
+
// The editor keeps its undo history for the life of the field.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (editor.current) editor.current.setMarkdown(value);
|
|
69
|
+
}, [value]);
|
|
70
|
+
|
|
71
|
+
React.useImperativeHandle(
|
|
72
|
+
ref,
|
|
73
|
+
() => ({
|
|
74
|
+
command: (name, args) => (editor.current ? editor.current.command(name, args) : false),
|
|
75
|
+
selectedText: () => (editor.current ? editor.current.selectedText() : ""),
|
|
76
|
+
}),
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const css = React.useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
markdownEditorCss(tokens, `.${scope}`, {
|
|
83
|
+
fontSize: touchFontSize(tokens.bodySize),
|
|
84
|
+
minHeight,
|
|
85
|
+
framed: true,
|
|
86
|
+
}),
|
|
87
|
+
[tokens, scope, minHeight],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return React.createElement(
|
|
91
|
+
"div",
|
|
92
|
+
{
|
|
93
|
+
className: scope,
|
|
94
|
+
"data-testid": testID,
|
|
95
|
+
style: {
|
|
96
|
+
minHeight,
|
|
97
|
+
maxHeight,
|
|
98
|
+
overflowY: "auto",
|
|
99
|
+
boxSizing: "border-box",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
React.createElement("style", null, css),
|
|
103
|
+
failed
|
|
104
|
+
? React.createElement(
|
|
105
|
+
"div",
|
|
106
|
+
{ role: "alert", style: { padding: tokens.padding, color: tokens.mutedColor } },
|
|
107
|
+
"The editor could not load. Check your connection and reload the page.",
|
|
108
|
+
)
|
|
109
|
+
: React.createElement("div", { ref: host }),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// sc-15080 — `<MarkdownInput>`'s editing surface (iOS and Android): the shared
|
|
2
|
+
// editor in a WebView, through ./markdown-editor-bridge.js. The web mirror is
|
|
3
|
+
// ./markdown-editor-field.js.
|
|
4
|
+
|
|
5
|
+
import { View } from "react-native";
|
|
6
|
+
import { WebView } from "react-native-webview";
|
|
7
|
+
import { makeMarkdownEditorField } from "./markdown-editor-bridge.js";
|
|
8
|
+
|
|
9
|
+
export const MarkdownEditorField = makeMarkdownEditorField({ View, WebView });
|