a tool for shared writing and social publishing
1"use client";
2
3import { Fact, useEntity, useReplicache } from "src/replicache";
4
5import { useUIState } from "src/useUIState";
6import { useBlocks } from "src/hooks/queries/useBlocks";
7import { useEditorStates } from "src/state/useEditorState";
8import { useEntitySetContext } from "components/EntitySetProvider";
9
10import { isTextBlock } from "src/utils/isTextBlock";
11import { focusBlock } from "src/utils/focusBlock";
12import { elementId } from "src/utils/elementId";
13import { generateKeyBetween } from "fractional-indexing";
14import { v7 } from "uuid";
15
16import { Block } from "./Block";
17import { useEffect } from "react";
18import { addShortcut } from "src/shortcuts";
19import { QuoteEmbedBlock } from "./QuoteEmbedBlock";
20
21export function Blocks(props: { entityID: string }) {
22 let rep = useReplicache();
23 let isPageFocused = useUIState((s) => {
24 let focusedElement = s.focusedEntity;
25 let focusedPageID =
26 focusedElement?.entityType === "page"
27 ? focusedElement.entityID
28 : focusedElement?.parent;
29 return focusedPageID === props.entityID;
30 });
31 let { permissions } = useEntitySetContext();
32 let entity_set = useEntitySetContext();
33 let blocks = useBlocks(props.entityID);
34 let foldedBlocks = useUIState((s) => s.foldedBlocks);
35 useEffect(() => {
36 if (!isPageFocused) return;
37 return addShortcut([
38 {
39 altKey: true,
40 metaKey: true,
41 key: "ArrowUp",
42 shift: true,
43 handler: () => {
44 let allParents = blocks.reduce((acc, block) => {
45 if (!block.listData) return acc;
46 block.listData.path.forEach((p) =>
47 !acc.includes(p.entity) ? acc.push(p.entity) : null,
48 );
49 return acc;
50 }, [] as string[]);
51 useUIState.setState((s) => {
52 let foldedBlocks = [...s.foldedBlocks];
53 allParents.forEach((p) => {
54 if (!foldedBlocks.includes(p)) foldedBlocks.push(p);
55 });
56 return { foldedBlocks };
57 });
58 },
59 },
60 {
61 altKey: true,
62 metaKey: true,
63 key: "ArrowDown",
64 shift: true,
65 handler: () => {
66 let allParents = blocks.reduce((acc, block) => {
67 if (!block.listData) return acc;
68 block.listData.path.forEach((p) =>
69 !acc.includes(p.entity) ? acc.push(p.entity) : null,
70 );
71 return acc;
72 }, [] as string[]);
73 useUIState.setState((s) => {
74 let foldedBlocks = [...s.foldedBlocks].filter(
75 (f) => !allParents.includes(f),
76 );
77 return { foldedBlocks };
78 });
79 },
80 },
81 ]);
82 }, [blocks, isPageFocused]);
83
84 let lastRootBlock = blocks.findLast(
85 (f) => !f.listData || f.listData.depth === 1,
86 );
87
88 let lastVisibleBlock = blocks.findLast(
89 (f) =>
90 !f.listData ||
91 !f.listData.path.find(
92 (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity,
93 ),
94 );
95
96 return (
97 <div
98 className={`blocks w-full flex flex-col outline-hidden h-fit min-h-full`}
99 onClick={async (e) => {
100 if (!permissions.write) return;
101 if (useUIState.getState().selectedBlocks.length > 1) return;
102 if (e.target === e.currentTarget) {
103 if (
104 !lastVisibleBlock ||
105 (lastVisibleBlock.type !== "text" &&
106 lastVisibleBlock.type !== "heading")
107 ) {
108 let newEntityID = v7();
109 await rep.rep?.mutate.addBlock({
110 parent: props.entityID,
111 factID: v7(),
112 permission_set: entity_set.set,
113 type: "text",
114 position: generateKeyBetween(
115 lastRootBlock?.position || null,
116 null,
117 ),
118 newEntityID,
119 });
120
121 setTimeout(() => {
122 document
123 .getElementById(elementId.block(newEntityID).text)
124 ?.focus();
125 }, 10);
126 } else {
127 lastVisibleBlock && focusBlock(lastVisibleBlock, { type: "end" });
128 }
129 }
130 }}
131 >
132 {blocks
133 .filter(
134 (f) =>
135 !f.listData ||
136 !f.listData.path.find(
137 (path) =>
138 foldedBlocks.includes(path.entity) && f.value !== path.entity,
139 ),
140 )
141 .map((f, index, arr) => {
142 let nextBlock = arr[index + 1];
143 let depth = f.listData?.depth || 1;
144 let nextDepth = nextBlock?.listData?.depth || 1;
145 let nextPosition: string | null;
146 if (depth === nextDepth) nextPosition = nextBlock?.position || null;
147 else nextPosition = null;
148 return (
149 <Block
150 pageType="doc"
151 {...f}
152 key={f.value}
153 entityID={f.value}
154 parent={props.entityID}
155 previousBlock={arr[index - 1] || null}
156 nextBlock={arr[index + 1] || null}
157 nextPosition={nextPosition}
158 />
159 );
160 })}
161 <NewBlockButton
162 lastBlock={lastRootBlock || null}
163 entityID={props.entityID}
164 />
165
166 <BlockListBottom
167 lastVisibleBlock={lastVisibleBlock || undefined}
168 lastRootBlock={lastRootBlock || undefined}
169 entityID={props.entityID}
170 />
171 </div>
172 );
173}
174
175function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) {
176 let { rep } = useReplicache();
177 let entity_set = useEntitySetContext();
178 let editorState = useEditorStates((s) =>
179 props.lastBlock?.type === "text"
180 ? s.editorStates[props.lastBlock.value]
181 : null,
182 );
183
184 let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked");
185 if (!entity_set.permissions.write) return null;
186 if (
187 ((props.lastBlock?.type === "text" && !isLocked?.data.value) ||
188 props.lastBlock?.type === "heading") &&
189 (!editorState?.editor || editorState.editor.doc.content.size <= 2)
190 )
191 return null;
192 return (
193 <div className="flex items-center justify-between group/text px-3 sm:px-4">
194 <div
195 className="h-6 hover:cursor-text italic text-tertiary grow"
196 onMouseDown={async () => {
197 let newEntityID = v7();
198 await rep?.mutate.addBlock({
199 parent: props.entityID,
200 type: "text",
201 factID: v7(),
202 permission_set: entity_set.set,
203 position: generateKeyBetween(
204 props.lastBlock?.position || null,
205 null,
206 ),
207 newEntityID,
208 });
209
210 setTimeout(() => {
211 document.getElementById(elementId.block(newEntityID).text)?.focus();
212 }, 10);
213 }}
214 >
215 {/* this is here as a fail safe, in case a new page is created and there are no blocks in it yet,
216 we render a newblockbutton with a textblock-like placeholder instead of a proper first block. */}
217 {!props.lastBlock ? (
218 <div className="pt-2 sm:pt-3">write something...</div>
219 ) : (
220 " "
221 )}
222 </div>
223 </div>
224 );
225}
226
227const BlockListBottom = (props: {
228 lastRootBlock: Block | undefined;
229 lastVisibleBlock: Block | undefined;
230 entityID: string;
231}) => {
232 let { rep } = useReplicache();
233 let entity_set = useEntitySetContext();
234
235 if (!entity_set.permissions.write) return;
236 return (
237 <div
238 className="blockListClickableBottomArea shrink-0 h-[50vh]"
239 onClick={() => {
240 let newEntityID = v7();
241 if (
242 // if the last visible(not-folded) block is a text block, focus it
243 props.lastRootBlock &&
244 props.lastVisibleBlock &&
245 isTextBlock[props.lastVisibleBlock.type]
246 ) {
247 focusBlock(
248 { ...props.lastVisibleBlock, type: "text" },
249 { type: "end" },
250 );
251 } else {
252 // else add a new text block at the end and focus it
253 rep?.mutate.addBlock({
254 permission_set: entity_set.set,
255 factID: v7(),
256 parent: props.entityID,
257 type: "text",
258 position: generateKeyBetween(
259 props.lastRootBlock?.position || null,
260 null,
261 ),
262 newEntityID,
263 });
264
265 setTimeout(() => {
266 document.getElementById(elementId.block(newEntityID).text)?.focus();
267 }, 10);
268 }
269 }}
270 />
271 );
272};