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