a tool for shared writing and social publishing
at main 6.0 kB view raw
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 { BlockLayout, 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 123 <BlockLayout 124 isSelected={focusedBlock} 125 hasBackground="accent" 126 borderOnHover 127 className="p-0! min-h-[48px]" 128 > 129 {focusedBlock && permissions.write ? ( 130 <BaseTextareaBlock 131 placeholder="write some code…" 132 data-editable-block 133 data-entityid={props.entityID} 134 id={elementId.block(props.entityID).input} 135 block={props} 136 rep={rep} 137 permissionSet={entity_set.set} 138 spellCheck={false} 139 autoCapitalize="none" 140 autoCorrect="off" 141 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2 sm:p-3" 142 value={content?.data.value} 143 onChange={async (e) => { 144 // Update the entity with the new value 145 await rep?.mutate.assertFact({ 146 attribute: "block/code", 147 entity: props.entityID, 148 data: { type: "string", value: e.target.value }, 149 }); 150 }} 151 /> 152 ) : !html ? ( 153 <pre 154 onClick={onClick} 155 onMouseDown={(e) => e.stopPropagation()} 156 className="codeBlockRendered overflow-auto! font-mono p-2 sm:p-3 w-full h-full" 157 > 158 {content?.data.value === "" || content?.data.value === undefined ? ( 159 <div className="text-tertiary italic">write some code</div> 160 ) : ( 161 content?.data.value 162 )} 163 </pre> 164 ) : ( 165 <div 166 onMouseDown={(e) => e.stopPropagation()} 167 onClick={onClick} 168 data-lang={lang} 169 className="contents" 170 dangerouslySetInnerHTML={{ __html: html || "" }} 171 /> 172 )} 173 </BlockLayout> 174 </div> 175 ); 176}