a tool for shared writing and social publishing
1import { 2 BundledLanguage, 3 bundledLanguagesInfo, 4 bundledThemesInfo, 5 codeToHtml, 6} from "shiki"; 7import { useEntity, useReplicache } from "src/replicache"; 8import "katex/dist/katex.min.css"; 9import { BlockProps } from "./Block"; 10import { useCallback, useLayoutEffect, useMemo, useState } from "react"; 11import { useUIState } from "src/useUIState"; 12import { BaseTextareaBlock } from "./BaseTextareaBlock"; 13import { useEntitySetContext } from "components/EntitySetProvider"; 14import { flushSync } from "react-dom"; 15import { elementId } from "src/utils/elementId"; 16import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 17 18export function CodeBlock(props: BlockProps) { 19 let { rep, rootEntity } = useReplicache(); 20 let content = useEntity(props.entityID, "block/code"); 21 let lang = 22 useEntity(props.entityID, "block/code-language")?.data.value || "plaintext"; 23 24 let theme = 25 useEntity(rootEntity, "theme/code-theme")?.data.value || "github-light"; 26 let focusedBlock = useUIState( 27 (s) => s.focusedEntity?.entityID === props.entityID, 28 ); 29 let entity_set = useEntitySetContext(); 30 let { permissions } = entity_set; 31 const [html, setHTML] = useState<string | null>(null); 32 33 useLayoutEffect(() => { 34 if (!content) return; 35 void codeToHtml(content.data.value, { 36 lang, 37 theme, 38 structure: "classic", 39 }).then((h) => { 40 setHTML(h.replaceAll("<br>", "\n")); 41 }); 42 }, [content, lang, theme]); 43 44 const onClick = useCallback((e: React.MouseEvent<HTMLElement>) => { 45 let selection = window.getSelection(); 46 if (!selection || selection.rangeCount === 0) return; 47 let range = selection.getRangeAt(0); 48 if (!range) return; 49 let length = range.toString().length; 50 range.setStart(e.currentTarget, 0); 51 let end = range.toString().length; 52 let start = end - length; 53 54 flushSync(() => { 55 useUIState.getState().setSelectedBlock(props); 56 useUIState.getState().setFocusedBlock({ 57 entityType: "block", 58 entityID: props.value, 59 parent: props.parent, 60 }); 61 }); 62 let el = document.getElementById( 63 elementId.block(props.entityID).input, 64 ) as HTMLTextAreaElement; 65 if (!el) return; 66 el.focus(); 67 el.setSelectionRange(start, end); 68 }, []); 69 return ( 70 <div className="codeBlock w-full flex flex-col rounded-md gap-0.5 "> 71 {permissions.write && ( 72 <div className="text-sm text-tertiary flex justify-between"> 73 <div className="flex gap-1"> 74 Theme:{" "} 75 <select 76 className="codeBlockLang text-left bg-transparent pr-1 sm:max-w-none max-w-24" 77 onClick={(e) => { 78 e.preventDefault(); 79 e.stopPropagation(); 80 }} 81 value={theme} 82 onChange={async (e) => { 83 await rep?.mutate.assertFact({ 84 attribute: "theme/code-theme", 85 entity: rootEntity, 86 data: { type: "string", value: e.target.value }, 87 }); 88 }} 89 > 90 {bundledThemesInfo.map((t) => ( 91 <option key={t.id} value={t.id}> 92 {t.displayName} 93 </option> 94 ))} 95 </select> 96 </div> 97 <select 98 className="codeBlockLang text-right bg-transparent pr-1 sm:max-w-none max-w-24" 99 onClick={(e) => { 100 e.preventDefault(); 101 e.stopPropagation(); 102 }} 103 value={lang} 104 onChange={async (e) => { 105 localStorage.setItem(LAST_USED_CODE_LANGUAGE_KEY, e.target.value); 106 await rep?.mutate.assertFact({ 107 attribute: "block/code-language", 108 entity: props.entityID, 109 data: { type: "string", value: e.target.value }, 110 }); 111 }} 112 > 113 <option value="plaintext">Plaintext</option> 114 {bundledLanguagesInfo.map((l) => ( 115 <option key={l.id} value={l.id}> 116 {l.name} 117 </option> 118 ))} 119 </select> 120 </div> 121 )} 122 <div className="w-full min-h-[42px] rounded-md border-border-light outline-border-light selected-outline"> 123 {focusedBlock && permissions.write ? ( 124 <BaseTextareaBlock 125 data-editable-block 126 data-entityid={props.entityID} 127 id={elementId.block(props.entityID).input} 128 block={props} 129 rep={rep} 130 permissionSet={entity_set.set} 131 spellCheck={false} 132 autoCapitalize="none" 133 autoCorrect="off" 134 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2" 135 value={content?.data.value} 136 onChange={async (e) => { 137 // Update the entity with the new value 138 await rep?.mutate.assertFact({ 139 attribute: "block/code", 140 entity: props.entityID, 141 data: { type: "string", value: e.target.value }, 142 }); 143 }} 144 /> 145 ) : !html ? ( 146 <pre 147 onClick={onClick} 148 onMouseDown={(e) => e.stopPropagation()} 149 className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full" 150 > 151 {content?.data.value} 152 </pre> 153 ) : ( 154 <div 155 onMouseDown={(e) => e.stopPropagation()} 156 onClick={onClick} 157 data-lang={lang} 158 className="contents" 159 dangerouslySetInnerHTML={{ __html: html || "" }} 160 /> 161 )} 162 </div> 163 </div> 164 ); 165}