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 { 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}