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}