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 spellCheck={false}
127 autoCapitalize="none"
128 autoCorrect="off"
129 className="codeBlockEditor whitespace-nowrap! overflow-auto! font-mono p-2"
130 value={content?.data.value}
131 onChange={async (e) => {
132 // Update the entity with the new value
133 await rep?.mutate.assertFact({
134 attribute: "block/code",
135 entity: props.entityID,
136 data: { type: "string", value: e.target.value },
137 });
138 }}
139 />
140 ) : !html ? (
141 <pre
142 onClick={onClick}
143 onMouseDown={(e) => e.stopPropagation()}
144 className="codeBlockRendered overflow-auto! font-mono p-2 w-full h-full"
145 >
146 {content?.data.value}
147 </pre>
148 ) : (
149 <div
150 onMouseDown={(e) => e.stopPropagation()}
151 onClick={onClick}
152 data-lang={lang}
153 className="contents"
154 dangerouslySetInnerHTML={{ __html: html || "" }}
155 />
156 )}
157 </div>
158 </div>
159 );
160}