Path: blob/master/src/packages/frontend/editors/markdown-input/component.tsx
1691 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Markdown editor7*/89import * as CodeMirror from "codemirror";10import { debounce, isEqual } from "lodash";11import {12CSSProperties,13MutableRefObject,14ReactNode,15RefObject,16useCallback,17useEffect,18useMemo,19useRef,20useState,21} from "react";22import { alert_message } from "@cocalc/frontend/alerts";23import { redux, useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";24import { SubmitMentionsRef } from "@cocalc/frontend/chat/types";25import { A } from "@cocalc/frontend/components";26import { IS_MOBILE } from "@cocalc/frontend/feature";27import { Dropzone, BlobUpload } from "@cocalc/frontend/file-upload";28import { Cursors, CursorsType } from "@cocalc/frontend/jupyter/cursors";29import Fragment, { FragmentId } from "@cocalc/frontend/misc/fragment-id";30import { useProjectHasInternetAccess } from "@cocalc/frontend/project/settings/has-internet-access-hook";31import { len, trunc, trunc_middle } from "@cocalc/util/misc";32import { Complete, Item } from "./complete";33import { useMentionableUsers } from "./mentionable-users";34import { submit_mentions } from "./mentions";35import { EditorFunctions } from "./multimode";36import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";3738type EventHandlerFunction = (cm: CodeMirror.Editor) => void;3940// This code depends on codemirror being initialized.41import "@cocalc/frontend/codemirror/init";4243export const BLURED_STYLE: CSSProperties = {44border: "1px solid rgb(204,204,204)", // focused will be rgb(112, 178, 230);45borderRadius: "5px",46} as const;4748export const FOCUSED_STYLE: CSSProperties = {49outline: "none !important",50boxShadow: "0px 0px 5px #719ECE",51borderRadius: "5px",52border: "1px solid #719ECE",53} as const;5455const PADDING_TOP = 6;5657const MENTION_CSS =58"color:#7289da; background:rgba(114,137,218,.1); border-radius: 3px; padding: 0 2px;";5960interface Props {61project_id?: string; // must be set if enableUpload or enableMentions is set (todo: enforce via typescript)62path?: string; // must be set if enableUpload or enableMentions is set (todo: enforce via typescript)63value?: string;64onChange?: (value: string) => void;65saveDebounceMs?: number; // if given, calls to onChange are debounced by this param66getValueRef?: MutableRefObject<() => string>;67enableUpload?: boolean; // if true, enable drag-n-drop and pasted files68onUploadStart?: () => void;69onUploadEnd?: () => void;70enableMentions?: boolean;71submitMentionsRef?: SubmitMentionsRef;72style?: CSSProperties;73onShiftEnter?: (value: string) => void; // also ctrl/alt/cmd-enter call this; see https://github.com/sagemathinc/cocalc/issues/191474onEscape?: () => void;75onBlur?: (value: string) => void;76onFocus?: () => void;77isFocused?: boolean; // see docs in multimode.tsx78placeholder?: string;79height?: string;80instructionsStyle?: CSSProperties;81extraHelp?: ReactNode;82hideHelp?: boolean;83fontSize?: number;84styleActiveLine?: boolean;85lineWrapping?: boolean;86lineNumbers?: boolean;87autoFocus?: boolean;88cmOptions?: { [key: string]: any }; // if given, use this for CodeMirror options, taking precedence over anything derived from other inputs, e.g., lineNumbers, above and account settings.89selectionRef?: MutableRefObject<{90setSelection: Function;91getSelection: Function;92} | null>;93onUndo?: () => void; // user requests undo -- if given, codemirror's internal undo is not used94onRedo?: () => void; // user requests redo95onSave?: () => void; // user requests save96onCursors?: (cursors: { x: number; y: number }[]) => void; // cursor location(s).97cursors?: CursorsType;98divRef?: RefObject<HTMLDivElement>;99onCursorTop?: () => void;100onCursorBottom?: () => void;101registerEditor?: (editor: EditorFunctions) => void;102unregisterEditor?: () => void;103refresh?: any; // refresh codemirror if this changes104compact?: boolean;105dirtyRef?: MutableRefObject<boolean>;106}107108export function MarkdownInput(props: Props) {109const {110autoFocus,111cmOptions,112compact,113cursors,114dirtyRef,115divRef,116enableMentions,117enableUpload,118extraHelp,119fontSize,120getValueRef,121height,122hideHelp,123instructionsStyle,124isFocused,125onBlur,126onChange,127onCursorBottom,128onCursors,129onCursorTop,130onEscape,131onFocus,132onRedo,133onSave,134onShiftEnter,135onUndo,136onUploadEnd,137onUploadStart,138path,139placeholder,140project_id,141refresh,142registerEditor,143saveDebounceMs,144selectionRef,145style,146submitMentionsRef,147unregisterEditor,148value,149} = props;150const { actions, isVisible } = useFrameContext();151const cm = useRef<CodeMirror.Editor | undefined>(undefined);152const textarea_ref = useRef<HTMLTextAreaElement | null>(null);153const editor_settings = useRedux(["account", "editor_settings"]);154const options = useMemo(() => {155return {156indentUnit: 2,157indentWithTabs: false,158autoCloseBrackets: editor_settings.get("auto_close_brackets", false),159lineWrapping: editor_settings.get("line_wrapping", true),160lineNumbers: editor_settings.get("line_numbers", false),161matchBrackets: editor_settings.get("match_brackets", false),162styleActiveLine: editor_settings.get("style_active_line", true),163theme: editor_settings.get("theme", "default"),164...cmOptions,165};166}, [editor_settings, cmOptions]);167168const defaultFontSize = useTypedRedux("account", "font_size");169170const dropzone_ref = useRef<Dropzone>(null);171const upload_close_preview_ref = useRef<Function | null>(null);172const current_uploads_ref = useRef<{ [name: string]: boolean } | null>(null);173const [isFocusedStyle, setIsFocusedStyle] = useState<boolean>(!!autoFocus);174const isFocusedRef = useRef<boolean>(!!autoFocus);175176const [mentions, set_mentions] = useState<undefined | Item[]>(undefined);177const [mentions_offset, set_mentions_offset] = useState<178undefined | { left: number; top: number }179>(undefined);180const [mentions_search, set_mentions_search] = useState<string>("");181const mentions_cursor_ref = useRef<{182cursor: EventHandlerFunction;183change: EventHandlerFunction;184from: { line: number; ch: number };185} | undefined>(undefined);186187const mentionableUsers = useMentionableUsers();188189const focus = useCallback(() => {190if (isFocusedRef.current) return; // already focused191const ed = cm.current;192if (ed == null) return;193ed.getInputField().focus({ preventScroll: true });194}, []);195196const blur = useCallback(() => {197if (!isFocusedRef.current) return; // already blured198const ed = cm.current;199if (ed == null) return;200ed.getInputField().blur();201}, []);202203useEffect(() => {204if (isFocusedRef.current == null || cm.current == null) return;205206if (isFocused && !isFocusedRef.current) {207focus();208} else if (!isFocused && isFocusedRef.current) {209blur();210}211}, [isFocused]);212213useEffect(() => {214cm.current?.refresh();215}, [refresh]);216217useEffect(() => {218// initialize the codemirror editor219const node = textarea_ref.current;220if (node == null) {221// maybe unmounted right as this happened.222return;223}224const extraKeys: CodeMirror.KeyMap = {};225if (onShiftEnter != null) {226const f = (cm) => onShiftEnter(cm.getValue());227extraKeys["Shift-Enter"] = f;228extraKeys["Ctrl-Enter"] = f;229extraKeys["Alt-Enter"] = f;230extraKeys["Cmd-Enter"] = f;231}232if (onEscape != null) {233extraKeys["Esc"] = () => {234if (mentions_cursor_ref.current == null) {235onEscape();236}237};238}239extraKeys["Enter"] = (cm) => {240// We only allow enter when mentions isn't in use241if (mentions_cursor_ref.current == null) {242cm.execCommand("newlineAndIndent");243}244};245246if (onCursorTop != null) {247extraKeys["Up"] = (cm) => {248const cur = cm.getCursor();249if (cur?.line === cm.firstLine() && cur?.ch === 0) {250onCursorTop();251} else {252CodeMirror.commands.goLineUp(cm);253}254};255}256if (onCursorBottom != null) {257extraKeys["Down"] = (cm) => {258const cur = cm.getCursor();259const n = cm.lastLine();260const cur_line = cur?.line;261const cur_ch = cur?.ch;262const line = cm.getLine(n);263const line_length = line?.length;264if (cur_line === n && cur_ch === line_length) {265onCursorBottom();266} else {267CodeMirror.commands.goLineDown(cm);268}269};270}271272cm.current = CodeMirror.fromTextArea(node, {273...options,274// dragDrop=false: instead of useless codemirror dnd, we upload file and make link.275// Note that for the md editor or other full code editors, we DO want dragDrop true,276// since, e.g., you can select some text, then drag it around, which is useful. For277// a simple chat message or tiny bit of markdown (like this is for), that's not so278// useful and drag-n-drop file upload is way better.279dragDrop: false,280// IMPORTANT: there is a useEffect involving options below281// where the following four properties must be explicitly excluded!282inputStyle: "contenteditable" as "contenteditable", // needed for spellcheck to work!283spellcheck: true,284mode: { name: "gfm" },285});286// gives this highest precedence:287cm.current.addKeyMap(extraKeys);288289if (getValueRef != null) {290getValueRef.current = cm.current.getValue.bind(cm.current);291}292// UNCOMMENT FOR DEBUGGING ONLY293// (window as any).cm = cm.current;294cm.current.setValue(value ?? "");295cm.current.on("change", saveValue);296297if (dirtyRef != null) {298cm.current.on("change", () => {299dirtyRef.current = true;300});301}302303if (onBlur != null) {304cm.current.on("blur", (editor) => onBlur(editor.getValue()));305}306if (onFocus != null) {307cm.current.on("focus", onFocus);308}309310cm.current.on("blur", () => {311isFocusedRef.current = false;312setIsFocusedStyle(false);313});314cm.current.on("focus", () => {315isFocusedRef.current = true;316setIsFocusedStyle(true);317cm.current?.refresh();318});319if (onCursors != null) {320cm.current.on("cursorActivity", () => {321if (cm.current == null || !isFocusedRef.current) return;322if (ignoreChangeRef.current) return;323onCursors(324cm.current325.getDoc()326.listSelections()327.map((c) => ({ x: c.anchor.ch, y: c.anchor.line })),328);329});330}331332if (onUndo != null) {333cm.current.undo = () => {334if (cm.current == null) return;335saveValue();336onUndo();337};338}339if (onRedo != null) {340cm.current.redo = () => {341if (cm.current == null) return;342saveValue();343onRedo();344};345}346if (onSave != null) {347// This funny cocalc_actions is just how this is setup348// elsewhere in cocalc... Basically the global349// CodeMirror.commands.save350// is set to use this at the bottom of src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx351// @ts-ignore352cm.current.cocalc_actions = { save: onSave };353}354355if (enableUpload) {356// as any because the @types for codemirror are WRONG in this case.357cm.current.on("paste", handle_paste_event as any);358}359360const e: any = cm.current.getWrapperElement();361let s = `height:${height}; font-family:sans-serif !important;`;362if (compact) {363s += "padding:0";364} else {365s += !options.lineNumbers ? `padding:${PADDING_TOP}px 12px` : "";366}367e.setAttribute("style", s);368369if (enableMentions) {370cm.current.on("change", (cm, changeObj) => {371if (changeObj.text[0] == "@") {372const before = cm373.getLine(changeObj.to.line)374.slice(changeObj.to.ch - 1, changeObj.to.ch)375?.trim();376// If previous character is whitespace or nothing, then activate mentions:377if (!before || before == "(" || before == "[") {378show_mentions();379}380}381});382}383384if (submitMentionsRef != null) {385submitMentionsRef.current = (386fragmentId?: FragmentId,387onlyValue = false,388) => {389if (project_id == null || path == null) {390throw Error(391"project_id and path must be set if enableMentions is set.",392);393}394const fragment_id = Fragment.encode(fragmentId);395const mentions: {396account_id: string;397description: string;398fragment_id: string;399}[] = [];400if (cm.current == null) return;401// Get lines here, since we modify the doc as we go below.402const doc = (cm.current.getDoc() as any).linkedDoc();403doc.unlinkDoc(cm.current.getDoc());404const marks = cm.current.getAllMarks();405marks.reverse();406for (const mark of marks) {407if (mark == null) continue;408const { attributes } = mark as any;409if (attributes == null) continue; // some other sort of mark?410const { account_id } = attributes;411if (account_id == null) continue;412const loc = mark.find();413if (loc == null) continue;414let from, to;415if (loc["from"]) {416// @ts-ignore417({ from, to } = loc);418} else {419from = to = loc;420}421const text = `<span class="user-mention" account-id=${account_id} >${cm.current.getRange(422from,423to,424)}</span>`;425const description = trunc(cm.current.getLine(from.line).trim(), 160);426doc.replaceRange(text, from, to);427mentions.push({ account_id, description, fragment_id });428}429const value = doc.getValue();430if (!onlyValue) {431submit_mentions(project_id, path, mentions);432}433return value;434};435}436437if (autoFocus) {438cm.current.focus();439}440441if (selectionRef != null) {442selectionRef.current = {443setSelection: (selection: any) => {444cm.current?.setSelections(selection);445},446getSelection: () => {447return cm.current?.listSelections();448},449};450}451452if (registerEditor != null) {453registerEditor({454set_cursor: (pos: { x?: number; y?: number }) => {455if (cm.current == null) return;456let { x = 0, y = 0 } = pos; // must be defined!457if (y < 0) {458// for getting last line...459y += cm.current.lastLine() + 1;460}461cm.current.setCursor({ line: y, ch: x });462},463get_cursor: () => {464if (cm.current == null) return { x: 0, y: 0 };465const { line, ch } = cm.current.getCursor();466return { y: line, x: ch };467},468});469}470471setTimeout(() => {472cm.current?.refresh();473}, 0);474475// clean up476return () => {477if (cm.current == null) return;478unregisterEditor?.();479cm.current.getWrapperElement().remove();480cm.current = undefined;481};482}, []);483484useEffect(() => {485const bindings = editor_settings.get("bindings");486if (bindings == null || bindings == "standard") {487cm.current?.setOption("keyMap", "default");488} else {489cm.current?.setOption("keyMap", bindings);490}491}, [editor_settings.get("bindings")]);492493useEffect(() => {494if (cm.current == null) return;495for (const key in options) {496if (497key == "inputStyle" ||498key == "spellcheck" ||499key == "mode" ||500key == "extraKeys"501)502continue;503const opt = options[key];504if (!isEqual(cm.current.options[key], opt)) {505if (opt != null) {506cm.current.setOption(key as any, opt);507}508}509}510}, [options]);511512const ignoreChangeRef = useRef<boolean>(false);513// use valueRef since we can't just refer to value in saveValue514// below, due to not wanted to regenerate the saveValue function515// every time, due to debouncing, etc.516const valueRef = useRef<string | undefined>(value);517valueRef.current = value;518const saveValue = useMemo(() => {519// save value to owner via onChange520if (onChange == null) return () => {}; // no op521const f = () => {522if (cm.current == null) return;523if (ignoreChangeRef.current) return;524if (current_uploads_ref.current != null) {525// IMPORTANT: we do NOT report the latest version back while526// uploading files. Otherwise, if more than one is being527// uploaded at once, then we end up with an infinite loop528// of updates. In any case, once all the uploads finish529// we'll start reporting changes again. This is fine530// since you don't want to submit input *during* uploads anyways.531return;532}533const newValue = cm.current.getValue();534if (valueRef.current !== newValue) {535onChange(newValue);536}537};538if (saveDebounceMs) {539return debounce(f, saveDebounceMs);540} else {541return f;542}543}, []);544545const setValueNoJump = useCallback((newValue: string | undefined) => {546if (547newValue == null ||548cm.current == null ||549cm.current.getValue() === newValue550) {551return;552}553ignoreChangeRef.current = true;554cm.current.setValueNoJump(newValue);555ignoreChangeRef.current = false;556}, []);557558useEffect(() => {559setValueNoJump(value);560if (upload_close_preview_ref.current != null) {561upload_close_preview_ref.current(true);562}563}, [value]);564565function upload_sending(file: { name: string }): void {566if (project_id == null || path == null) {567throw Error("path must be set if enableUploads is set.");568}569570// console.log("upload_sending", file);571if (current_uploads_ref.current == null) {572current_uploads_ref.current = { [file.name]: true };573onUploadStart?.();574} else {575current_uploads_ref.current[file.name] = true;576}577if (cm.current == null) return;578const input = cm.current.getValue();579const s = upload_temp_link(file);580if (input.indexOf(s) != -1) {581// already have link.582return;583}584cm.current.replaceRange(s, cm.current.getCursor());585saveValue();586}587588function upload_complete(file): void {589if (path == null) {590throw Error("path must be set if enableUploads is set.");591}592const filename = file.name ?? file.upload.filename;593594if (current_uploads_ref.current != null) {595delete current_uploads_ref.current[filename];596if (len(current_uploads_ref.current) == 0) {597current_uploads_ref.current = null;598onUploadEnd?.();599}600}601602if (cm.current == null) return;603const input = cm.current.getValue();604const s0 = upload_temp_link(file);605let s1: string;606if (file.status == "error") {607s1 = "";608alert_message({ type: "error", message: "Error uploading file." });609} else if (file.status == "canceled") {610// users can cancel files when they are being uploaded.611s1 = "";612} else {613s1 = upload_link(file);614}615const newValue = input.replace(s0, s1);616setValueNoJump(newValue);617saveValue();618}619620function handle_paste_event(_, e): void {621const items = e.clipboardData.items;622for (let i = 0; i < items.length; i++) {623const item = items[i];624if (item != null && item.type.startsWith("image/")) {625e.preventDefault();626const file = item.getAsFile();627if (file != null) {628const blob = file.slice(0, -1, item.type);629dropzone_ref.current?.addFile(630new File([blob], `paste-${Math.random()}`, { type: item.type }),631);632}633return;634}635}636}637638function render_mention_email(): React.JSX.Element | undefined {639if (project_id == null) {640throw Error("project_id and path must be set if enableMentions is set.");641}642return <EnableInternetAccess project_id={project_id} />;643}644645function render_mobile_instructions() {646if (hideHelp) {647return <div style={{ height: "24px", ...instructionsStyle }}></div>;648}649return (650<div651style={{652color: "#767676",653fontSize: "12px",654padding: "2.5px 15px",655background: "white",656...instructionsStyle,657}}658>659{render_mention_instructions()}660{render_mention_email()}. Use{" "}661<A href="https://help.github.com/articles/getting-started-with-writing-and-formatting-on-github/">662Markdown663</A>{" "}664and <A href="https://en.wikibooks.org/wiki/LaTeX/Mathematics">LaTeX</A>.{" "}665{render_upload_instructions()}666{extraHelp}667</div>668);669}670671function render_desktop_instructions() {672if (hideHelp)673return <div style={{ height: "24px", ...instructionsStyle }}></div>;674return (675<div676style={{677color: "#767676",678fontSize: "12px",679padding: "3px 15px",680background: "white",681...instructionsStyle,682}}683>684<A href="https://help.github.com/articles/getting-started-with-writing-and-formatting-on-github/">685Markdown686</A>687{" and "}688<A href="https://en.wikibooks.org/wiki/LaTeX/Mathematics">689LaTeX formulas690</A>691. {render_mention_instructions()}692{render_upload_instructions()}693{extraHelp}694</div>695);696// I removed the emoticons list; it should really be a dropdown that697// appears like with github... Emoticons: {emoticons}.698}699700function render_mention_instructions(): React.JSX.Element | undefined {701if (!enableMentions) return;702return (703<>704{" "}705Use @name to mention people706{render_mention_email()}.{" "}707</>708);709}710711function render_upload_instructions(): React.JSX.Element | undefined {712if (!enableUpload) return;713const text = IS_MOBILE ? (714<a>Tap here to upload images.</a>715) : (716<>717Attach images by drag & drop, <a>select</a> or paste.718</>719);720return (721<>722{" "}723<span724style={{ cursor: "pointer" }}725onClick={() => {726// I could not get the clickable config to work,727// but reading the source code I found that this does:728dropzone_ref.current?.hiddenFileInput?.click();729}}730>731{text}732</span>{" "}733</>734);735}736737function render_instructions() {738return IS_MOBILE739? render_mobile_instructions()740: render_desktop_instructions();741}742743// Show the mentions popup selector. We *do* allow mentioning ourself,744// since Discord and Github both do, and maybe it's just one of those745// "symmetry" things (like liking your own post) that people feel is right.746function show_mentions() {747if (cm.current == null) return;748if (project_id == null) {749throw Error("project_id and path must be set if enableMentions is set.");750}751const v = mentionableUsers(undefined, { avatarLLMSize: 16 });752if (v.length == 0) {753// nobody to mention (e.g., admin doesn't have this)754return;755}756set_mentions(v);757set_mentions_search("");758759const cursor = cm.current.getCursor();760const pos = cm.current.cursorCoords(cursor, "local");761const scrollOffset = cm.current.getScrollInfo().top;762const top = pos.bottom - scrollOffset + PADDING_TOP;763// gutter is empty right now, but let's include this in case764// we implement line number support...765const gutter = $(cm.current.getGutterElement()).width() ?? 0;766const left = pos.left + gutter;767set_mentions_offset({ left, top });768769let last_cursor = cursor;770mentions_cursor_ref.current = {771from: { line: cursor.line, ch: cursor.ch - 1 },772cursor: (cm) => {773const pos = cm.getCursor();774// The hitSide and sticky attributes of pos below775// are set when you manually move the cursor, rather than776// it moving due to typing. We check them to avoid777// confusion such as778// https://github.com/sagemathinc/cocalc/issues/4833779// and in that case move the cursor back.780if (781pos.line != last_cursor.line ||782(pos as { hitSide?: boolean }).hitSide ||783(pos as { sticky?: string }).sticky != null784) {785cm.setCursor(last_cursor);786} else {787last_cursor = pos;788}789},790change: (cm) => {791const pos = cm.getCursor();792const search = cm.getRange(cursor, pos);793set_mentions_search(search.trim().toLowerCase());794},795};796cm.current.on("cursorActivity", mentions_cursor_ref.current.cursor);797cm.current.on("change", mentions_cursor_ref.current.change);798}799800function close_mentions() {801set_mentions(undefined);802if (cm.current != null) {803if (mentions_cursor_ref.current != null) {804cm.current.off("cursorActivity", mentions_cursor_ref.current.cursor);805cm.current.off("change", mentions_cursor_ref.current.change);806mentions_cursor_ref.current = undefined;807}808cm.current.focus();809}810}811812// make sure that mentions is closed if we switch to another tab.813useEffect(() => {814if (mentions && !isVisible) {815close_mentions();816}817}, [isVisible]);818819function render_mentions_popup() {820if (mentions == null || mentions_offset == null) return;821822const items: Item[] = [];823for (const item of mentions) {824if (item.search?.indexOf(mentions_search) != -1) {825items.push(item);826}827}828if (items.length == 0) {829if (mentions.length == 0) {830// See https://github.com/sagemathinc/cocalc/issues/4909831close_mentions();832return;833}834}835836return (837<Complete838items={items}839onCancel={close_mentions}840onSelect={(account_id) => {841if (mentions_cursor_ref.current == null) return;842const text =843"@" +844trunc_middle(redux.getStore("users").get_name(account_id), 64);845if (cm.current == null) return;846const from = mentions_cursor_ref.current.from;847const to = cm.current.getCursor();848cm.current.replaceRange(text + " ", from, to);849cm.current.markText(850from,851{ line: from.line, ch: from.ch + text.length },852{853atomic: true,854css: MENTION_CSS,855attributes: { account_id },856} as CodeMirror.TextMarkerOptions /* @types are out of date */,857);858close_mentions(); // must be after use of mentions_cursor_ref above.859cm.current.focus();860}}861offset={mentions_offset}862/>863);864}865866const showInstructions = !!value?.trim();867868let body: React.JSX.Element = (869<div style={{ height: showInstructions ? "calc(100% - 22px)" : "100%" }}>870{showInstructions ? render_instructions() : undefined}871<div872ref={divRef}873style={{874...(isFocusedStyle ? FOCUSED_STYLE : BLURED_STYLE),875...style,876...{877fontSize: `${fontSize ? fontSize : defaultFontSize}px`,878height,879},880}}881>882{render_mentions_popup()}883{cursors != null && cm.current != null && (884<Cursors cursors={cursors} codemirror={cm.current} />885)}886<textarea887style={{ display: "none" }}888ref={textarea_ref}889placeholder={placeholder}890/>891</div>892</div>893);894if (enableUpload) {895const event_handlers = {896complete: upload_complete,897sending: upload_sending,898error: (_, message) => {899actions?.set_error(`${message}`);900},901};902if (project_id == null || path == null) {903throw Error("project_id and path must be set if enableUploads is set.");904}905body = (906<BlobUpload907show_upload={false}908project_id={project_id}909event_handlers={event_handlers}910style={{ height: "100%", width: "100%" }}911dropzone_ref={dropzone_ref}912close_preview_ref={upload_close_preview_ref}913>914{body}915</BlobUpload>916);917}918919return body;920}921922function upload_temp_link(file): string {923return `[Uploading...]\(${file.name ?? file.upload?.filename ?? ""}\)`;924}925926function upload_link(file): string {927const { url, dataURL, height, upload } = file;928if (!height && !dataURL?.startsWith("data:image")) {929return `[${upload.filename ? upload.filename : "file"}](${url})`;930} else {931return ``;932}933}934935function EnableInternetAccess({ project_id }: { project_id: string }) {936const haveInternetAccess = useProjectHasInternetAccess(project_id);937938if (!haveInternetAccess) {939return <span> (enable the Internet Access upgrade to send emails)</span>;940} else {941return null;942}943}944945946