@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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|