a tool for shared writing and social publishing
1import {
2 Header1Small,
3 Header2Small,
4 Header3Small,
5} from "components/Icons/BlockTextSmall";
6import { Props } from "components/Icons/Props";
7import { ShortcutKey } from "components/Layout";
8import { ToolbarButton } from "components/Toolbar";
9import { TextSelection } from "prosemirror-state";
10import { useCallback } from "react";
11import { useEntity, useReplicache } from "src/replicache";
12import { useEditorStates } from "src/state/useEditorState";
13import { useUIState } from "src/useUIState";
14
15export const TextBlockTypeToolbar = (props: {
16 onClose: () => void;
17 className?: string;
18}) => {
19 let focusedBlock = useUIState((s) => s.focusedEntity);
20 let blockType = useEntity(focusedBlock?.entityID || null, "block/type");
21 let headingLevel = useEntity(
22 focusedBlock?.entityID || null,
23 "block/heading-level",
24 );
25 let { rep } = useReplicache();
26
27 let setLevel = useCallback(
28 async (level: number) => {
29 if (!focusedBlock) return;
30 let entityID = focusedBlock.entityID;
31 if (
32 blockType?.data.value !== "text" &&
33 blockType?.data.value !== "heading"
34 ) {
35 return;
36 }
37 await rep?.mutate.assertFact({
38 entity: entityID,
39 attribute: "block/heading-level",
40 data: { type: "number", value: level },
41 });
42 if (blockType.data.value === "text") {
43 await rep?.mutate.assertFact({
44 entity: entityID,
45 attribute: "block/type",
46 data: { type: "block-type-union", value: "heading" },
47 });
48 }
49 },
50 [rep, focusedBlock, blockType],
51 );
52 return (
53 // This Toolbar should close once the user starts typing again
54 <div className="flex w-full justify-between items-center gap-4">
55 <div className="flex items-center gap-[6px]">
56 <ToolbarButton
57 className={props.className}
58 onClick={() => {
59 setLevel(1);
60 }}
61 active={
62 blockType?.data.value === "heading" &&
63 headingLevel?.data.value === 1
64 }
65 tooltipContent={
66 <div className="flex flex-col justify-center">
67 <div className="font-bold text-center">Title</div>
68 <div className="flex gap-1 font-normal">
69 start line with
70 <ShortcutKey>#</ShortcutKey>
71 </div>
72 </div>
73 }
74 >
75 <Header1Small />
76 </ToolbarButton>
77 <ToolbarButton
78 className={props.className}
79 onClick={() => {
80 setLevel(2);
81 }}
82 active={
83 blockType?.data.value === "heading" &&
84 headingLevel?.data.value === 2
85 }
86 tooltipContent={
87 <div className="flex flex-col justify-center">
88 <div className="font-bold text-center">Heading</div>
89 <div className="flex gap-1 font-normal">
90 start line with
91 <ShortcutKey>##</ShortcutKey>
92 </div>
93 </div>
94 }
95 >
96 <Header2Small />
97 </ToolbarButton>
98 <ToolbarButton
99 className={props.className}
100 onClick={() => {
101 setLevel(3);
102 }}
103 active={
104 blockType?.data.value === "heading" &&
105 headingLevel?.data.value === 3
106 }
107 tooltipContent={
108 <div className="flex flex-col justify-center">
109 <div className="font-bold text-center">Subheading</div>
110 <div className="flex gap-1 font-normal">
111 start line with
112 <ShortcutKey>###</ShortcutKey>
113 </div>
114 </div>
115 }
116 >
117 <Header3Small />
118 </ToolbarButton>
119 <ToolbarButton
120 className={`px-[6px] ${props.className}`}
121 onClick={async () => {
122 if (headingLevel)
123 await rep?.mutate.retractFact({ factID: headingLevel.id });
124 if (!focusedBlock || !blockType) return;
125 if (blockType.data.value !== "text") {
126 let existingEditor =
127 useEditorStates.getState().editorStates[focusedBlock.entityID];
128 let selection = existingEditor?.editor.selection;
129 await rep?.mutate.assertFact({
130 entity: focusedBlock?.entityID,
131 attribute: "block/type",
132 data: { type: "block-type-union", value: "text" },
133 });
134
135 let newEditor =
136 useEditorStates.getState().editorStates[focusedBlock.entityID];
137 if (!newEditor || !selection) return;
138 newEditor.view?.dispatch(
139 newEditor.editor.tr.setSelection(
140 TextSelection.create(newEditor.editor.doc, selection.anchor),
141 ),
142 );
143
144 newEditor.view?.focus();
145 }
146 }}
147 active={blockType?.data.value === "text"}
148 tooltipContent={<div>Paragraph</div>}
149 >
150 Paragraph
151 </ToolbarButton>
152 </div>
153 </div>
154 );
155};
156
157export function TextBlockTypeButton(props: {
158 setToolbarState: (s: "heading") => void;
159 className?: string;
160}) {
161 return (
162 <ToolbarButton
163 tooltipContent={<div>Text Size</div>}
164 className={`${props.className}`}
165 onClick={() => {
166 props.setToolbarState("heading");
167 }}
168 >
169 <TextSizeSmall />
170 </ToolbarButton>
171 );
172}
173
174const TextSizeSmall = (props: Props) => {
175 return (
176 <svg
177 width="24"
178 height="24"
179 viewBox="0 0 24 24"
180 fill="none"
181 xmlns="http://www.w3.org/2000/svg"
182 {...props}
183 >
184 <path
185 fillRule="evenodd"
186 clipRule="evenodd"
187 d="M14.3435 12.6008C14.4028 12.7825 14.6587 12.7855 14.7222 12.6052L14.8715 12.1816H19.0382L19.8657 14.6444C19.9067 14.7666 20.0212 14.8489 20.15 14.8489H21.6021C21.809 14.8489 21.9538 14.6443 21.885 14.4491L18.2831 4.23212C18.2408 4.11212 18.1274 4.03186 18.0002 4.03186H16.0009C15.8761 4.03186 15.7643 4.10917 15.7203 4.22598L13.5539 9.96923C13.5298 10.0331 13.5282 10.1033 13.5494 10.1682L14.3435 12.6008ZM18.5093 10.6076L17.0588 6.29056C17.0507 6.26644 17.0281 6.25019 17.0027 6.25019C16.9775 6.25019 16.9552 6.26605 16.9468 6.28974L15.4259 10.6076H18.5093ZM4.57075 19.9682C4.69968 19.9682 4.81418 19.8858 4.85518 19.7636L5.98945 16.3815H11.4579L12.5943 19.7637C12.6353 19.8859 12.7498 19.9682 12.8787 19.9682H15.0516C15.2586 19.9682 15.4034 19.7636 15.3346 19.5684L10.4182 5.62298C10.3759 5.50299 10.2625 5.42273 10.1353 5.42273H7.30723C7.17995 5.42273 7.06652 5.50305 7.02425 5.62311L2.11475 19.5686C2.04604 19.7637 2.19084 19.9682 2.39772 19.9682H4.57075ZM10.7468 14.2651L8.79613 8.45953C8.78532 8.42736 8.75517 8.40568 8.72123 8.40568C8.68728 8.40568 8.65712 8.42738 8.64633 8.45957L6.69928 14.2651H10.7468Z"
188 fill="currentColor"
189 />
190 </svg>
191 );
192};