Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/slate/mostly-static-markdown.tsx
1691 views
1
/*
2
Mostly static markdown, but with some minimal dynamic editable content, e.g., checkboxes,
3
and maybe some other nice features, but much less than a full slate editor!
4
5
This is used a lot in the frontend app, whereas the fully static one is used a lot in the next.js app.
6
7
Extras include:
8
9
- checkboxes
10
11
- support for clicking on a hashtag being detected (e.g., used by task lists).
12
13
This is a react component that renders markdown text using Unlike the
14
component defined in editable-markdown.tsx, this component is *static* -- you
15
can't edit it. Moreover, it can be fully rendered on node.js for use in Next.js,
16
i.e., it doesn't depend on running in a browser.
17
18
What does this have to do with editors/slate? There's a lot of excellent code
19
in here for:
20
21
- Parsing markdown that is enhanced with math, checkboxes, and any other
22
enhancements we use in CoCalc to a JSON format.
23
24
- Converting that parsed markdown to React components.
25
26
What Slate does is provide an interactive framework to manipulate that parsed
27
JSON object on which we build a WYSIWYG editor. However, the inputs above also
28
lead to a powerful and extensible way of rendering markdown text using React,
29
where we can use React components for rendering, rather than HTML. This is more
30
robust, secure, etc. Also, it's **possible** to use virtuoso to do windowing
31
and hence render very large documents, which isn't possible using straight HTML,
32
and we can do other things like section folding and table of contents in a natural
33
way with good code use!
34
35
- We also optionally support very very minimal editing of static markdown right now:
36
- namely, you can click checkboxes. That's it.
37
Editing preserves as much as it can about your original source markdown.
38
*/
39
40
import { CSSProperties, useEffect, useRef, useMemo, useState } from "react";
41
import "./elements/init-ssr";
42
import { getStaticRender } from "./elements/register";
43
import { markdown_to_slate as markdownToSlate } from "./markdown-to-slate";
44
import { slate_to_markdown as slateToMarkdown } from "./slate-to-markdown";
45
import Leaf from "./leaf";
46
import Hashtag from "./elements/hashtag/component";
47
import Highlighter from "react-highlight-words";
48
import { ChangeContext } from "./use-change";
49
50
const HIGHLIGHT_STYLE = {
51
padding: 0,
52
backgroundColor: "#feff03", // to match what chrome browser users.
53
};
54
55
interface Props {
56
value: string;
57
className?: string;
58
style?: CSSProperties;
59
onChange?: (string) => void; // if given support some very minimal amount of editing, e.g., checkboxes; onChange is called with modified markdown.
60
selectedHashtags?: Set<string>; // assumed lower case!
61
toggleHashtag?: (string) => void;
62
searchWords?: Set<string> | string[]; // highlight text that matches anything in here
63
}
64
65
export default function MostlyStaticMarkdown({
66
value,
67
className,
68
style,
69
onChange,
70
selectedHashtags,
71
toggleHashtag,
72
searchWords,
73
}: Props) {
74
// Convert markdown to our slate JSON object representation.
75
const syncCacheRef = useRef<any>({});
76
const valueRef = useRef<string>(value);
77
const [editor, setEditor] = useState({
78
children: markdownToSlate(value, false, syncCacheRef.current),
79
});
80
const handleChange = useMemo(() => {
81
if (onChange == null) return; // nothing
82
return (element, change) => {
83
// Make a new slate value via setEditor, and also
84
// report new markdown string via onChange.
85
const editor1 = { children: [...editor.children] };
86
if (mutateEditor(editor1.children, element, change)) {
87
// actual change
88
onChange(
89
slateToMarkdown(editor1.children, { cache: syncCacheRef.current }),
90
);
91
setEditor(editor1);
92
}
93
};
94
}, [editor, onChange]);
95
96
const [change, setChange] = useState<number>(0);
97
useEffect(() => {
98
if (value == valueRef.current) return;
99
valueRef.current = value;
100
setEditor({
101
children: markdownToSlate(value, false, syncCacheRef.current),
102
});
103
setChange(change + 1);
104
}, [value]);
105
106
if (searchWords != null && searchWords["filter"] == null) {
107
// convert from Set<string> to string[], as required by the Highlighter component.
108
searchWords = Array.from(searchWords);
109
}
110
111
return (
112
<ChangeContext.Provider
113
value={{
114
change,
115
editor: editor as any,
116
setEditor: (editor) => {
117
setEditor(editor);
118
setChange(change + 1);
119
},
120
}}
121
>
122
<div style={{ width: "100%", ...style }} className={className}>
123
{editor.children.map((element, n) => (
124
<RenderElement
125
key={n}
126
element={element}
127
handleChange={handleChange}
128
selectedHashtags={selectedHashtags}
129
toggleHashtag={toggleHashtag}
130
searchWords={searchWords}
131
/>
132
))}
133
</div>
134
</ChangeContext.Provider>
135
);
136
}
137
138
function RenderElement({
139
element,
140
handleChange,
141
selectedHashtags,
142
toggleHashtag,
143
searchWords,
144
}) {
145
let children: React.JSX.Element[] = [];
146
if (element["children"]) {
147
let n = 0;
148
for (const child of element["children"]) {
149
children.push(
150
<RenderElement
151
key={n}
152
element={child}
153
handleChange={handleChange}
154
selectedHashtags={selectedHashtags}
155
toggleHashtag={toggleHashtag}
156
searchWords={searchWords}
157
/>,
158
);
159
n += 1;
160
}
161
}
162
const type = element["type"];
163
if (type) {
164
if (selectedHashtags != null && type == "hashtag") {
165
return (
166
<Hashtag
167
value={element.content}
168
selected={selectedHashtags.has(element.content?.toLowerCase())}
169
onClick={
170
toggleHashtag != null
171
? () => {
172
toggleHashtag(element.content?.toLowerCase());
173
}
174
: undefined
175
}
176
/>
177
);
178
}
179
180
const C = getStaticRender(element.type);
181
return (
182
<C
183
children={children}
184
element={element}
185
attributes={{} as any}
186
setElement={
187
handleChange == null
188
? undefined
189
: (change) => handleChange(element, change)
190
}
191
/>
192
);
193
}
194
// It's text
195
return (
196
<Leaf leaf={element} text={{} as any} attributes={{} as any}>
197
{searchWords != null ? (
198
<HighlightText searchWords={searchWords} text={element["text"]} />
199
) : (
200
element["text"]
201
)}
202
</Leaf>
203
);
204
}
205
206
export function HighlightText({ text, searchWords }) {
207
searchWords = Array.from(searchWords);
208
if (searchWords.length == 0) {
209
return <>{text}</>;
210
}
211
return (
212
<Highlighter
213
highlightStyle={HIGHLIGHT_STYLE}
214
searchWords={searchWords}
215
/* autoEscape: since otherwise partial matches in parts of words add weird spaces in the word itself.*/
216
autoEscape={true}
217
textToHighlight={text}
218
/>
219
);
220
}
221
222
function mutateEditor(children: any[], element, change): boolean {
223
for (const elt of children) {
224
if (elt === element) {
225
for (const key in change) {
226
elt[key] = change[key];
227
}
228
return true;
229
}
230
if (elt.children != null) {
231
// recurse
232
if (mutateEditor(elt.children, element, change)) {
233
return true;
234
}
235
}
236
}
237
return false;
238
}
239
240