Path: blob/master/src/packages/frontend/editors/slate/mostly-static-markdown.tsx
1691 views
/*1Mostly static markdown, but with some minimal dynamic editable content, e.g., checkboxes,2and maybe some other nice features, but much less than a full slate editor!34This is used a lot in the frontend app, whereas the fully static one is used a lot in the next.js app.56Extras include:78- checkboxes910- support for clicking on a hashtag being detected (e.g., used by task lists).1112This is a react component that renders markdown text using Unlike the13component defined in editable-markdown.tsx, this component is *static* -- you14can't edit it. Moreover, it can be fully rendered on node.js for use in Next.js,15i.e., it doesn't depend on running in a browser.1617What does this have to do with editors/slate? There's a lot of excellent code18in here for:1920- Parsing markdown that is enhanced with math, checkboxes, and any other21enhancements we use in CoCalc to a JSON format.2223- Converting that parsed markdown to React components.2425What Slate does is provide an interactive framework to manipulate that parsed26JSON object on which we build a WYSIWYG editor. However, the inputs above also27lead to a powerful and extensible way of rendering markdown text using React,28where we can use React components for rendering, rather than HTML. This is more29robust, secure, etc. Also, it's **possible** to use virtuoso to do windowing30and hence render very large documents, which isn't possible using straight HTML,31and we can do other things like section folding and table of contents in a natural32way with good code use!3334- We also optionally support very very minimal editing of static markdown right now:35- namely, you can click checkboxes. That's it.36Editing preserves as much as it can about your original source markdown.37*/3839import { CSSProperties, useEffect, useRef, useMemo, useState } from "react";40import "./elements/init-ssr";41import { getStaticRender } from "./elements/register";42import { markdown_to_slate as markdownToSlate } from "./markdown-to-slate";43import { slate_to_markdown as slateToMarkdown } from "./slate-to-markdown";44import Leaf from "./leaf";45import Hashtag from "./elements/hashtag/component";46import Highlighter from "react-highlight-words";47import { ChangeContext } from "./use-change";4849const HIGHLIGHT_STYLE = {50padding: 0,51backgroundColor: "#feff03", // to match what chrome browser users.52};5354interface Props {55value: string;56className?: string;57style?: CSSProperties;58onChange?: (string) => void; // if given support some very minimal amount of editing, e.g., checkboxes; onChange is called with modified markdown.59selectedHashtags?: Set<string>; // assumed lower case!60toggleHashtag?: (string) => void;61searchWords?: Set<string> | string[]; // highlight text that matches anything in here62}6364export default function MostlyStaticMarkdown({65value,66className,67style,68onChange,69selectedHashtags,70toggleHashtag,71searchWords,72}: Props) {73// Convert markdown to our slate JSON object representation.74const syncCacheRef = useRef<any>({});75const valueRef = useRef<string>(value);76const [editor, setEditor] = useState({77children: markdownToSlate(value, false, syncCacheRef.current),78});79const handleChange = useMemo(() => {80if (onChange == null) return; // nothing81return (element, change) => {82// Make a new slate value via setEditor, and also83// report new markdown string via onChange.84const editor1 = { children: [...editor.children] };85if (mutateEditor(editor1.children, element, change)) {86// actual change87onChange(88slateToMarkdown(editor1.children, { cache: syncCacheRef.current }),89);90setEditor(editor1);91}92};93}, [editor, onChange]);9495const [change, setChange] = useState<number>(0);96useEffect(() => {97if (value == valueRef.current) return;98valueRef.current = value;99setEditor({100children: markdownToSlate(value, false, syncCacheRef.current),101});102setChange(change + 1);103}, [value]);104105if (searchWords != null && searchWords["filter"] == null) {106// convert from Set<string> to string[], as required by the Highlighter component.107searchWords = Array.from(searchWords);108}109110return (111<ChangeContext.Provider112value={{113change,114editor: editor as any,115setEditor: (editor) => {116setEditor(editor);117setChange(change + 1);118},119}}120>121<div style={{ width: "100%", ...style }} className={className}>122{editor.children.map((element, n) => (123<RenderElement124key={n}125element={element}126handleChange={handleChange}127selectedHashtags={selectedHashtags}128toggleHashtag={toggleHashtag}129searchWords={searchWords}130/>131))}132</div>133</ChangeContext.Provider>134);135}136137function RenderElement({138element,139handleChange,140selectedHashtags,141toggleHashtag,142searchWords,143}) {144let children: React.JSX.Element[] = [];145if (element["children"]) {146let n = 0;147for (const child of element["children"]) {148children.push(149<RenderElement150key={n}151element={child}152handleChange={handleChange}153selectedHashtags={selectedHashtags}154toggleHashtag={toggleHashtag}155searchWords={searchWords}156/>,157);158n += 1;159}160}161const type = element["type"];162if (type) {163if (selectedHashtags != null && type == "hashtag") {164return (165<Hashtag166value={element.content}167selected={selectedHashtags.has(element.content?.toLowerCase())}168onClick={169toggleHashtag != null170? () => {171toggleHashtag(element.content?.toLowerCase());172}173: undefined174}175/>176);177}178179const C = getStaticRender(element.type);180return (181<C182children={children}183element={element}184attributes={{} as any}185setElement={186handleChange == null187? undefined188: (change) => handleChange(element, change)189}190/>191);192}193// It's text194return (195<Leaf leaf={element} text={{} as any} attributes={{} as any}>196{searchWords != null ? (197<HighlightText searchWords={searchWords} text={element["text"]} />198) : (199element["text"]200)}201</Leaf>202);203}204205export function HighlightText({ text, searchWords }) {206searchWords = Array.from(searchWords);207if (searchWords.length == 0) {208return <>{text}</>;209}210return (211<Highlighter212highlightStyle={HIGHLIGHT_STYLE}213searchWords={searchWords}214/* autoEscape: since otherwise partial matches in parts of words add weird spaces in the word itself.*/215autoEscape={true}216textToHighlight={text}217/>218);219}220221function mutateEditor(children: any[], element, change): boolean {222for (const elt of children) {223if (elt === element) {224for (const key in change) {225elt[key] = change[key];226}227return true;228}229if (elt.children != null) {230// recurse231if (mutateEditor(elt.children, element, change)) {232return true;233}234}235}236return false;237}238239240