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 { useHandleDrop } from "./useHandleDrop";
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 if (!entity_set.permissions.write) return null;
185 if (
186 (props.lastBlock?.type === "text" || props.lastBlock?.type === "heading") &&
187 (!editorState?.editor || editorState.editor.doc.content.size <= 2)
188 )
189 return null;
190 return (
191 <div className="flex items-center justify-between group/text px-3 sm:px-4">
192 <div
193 className="h-6 hover:cursor-text italic text-tertiary grow"
194 onMouseDown={async () => {
195 let newEntityID = v7();
196 await rep?.mutate.addBlock({
197 parent: props.entityID,
198 type: "text",
199 factID: v7(),
200 permission_set: entity_set.set,
201 position: generateKeyBetween(
202 props.lastBlock?.position || null,
203 null,
204 ),
205 newEntityID,
206 });
207
208 setTimeout(() => {
209 document.getElementById(elementId.block(newEntityID).text)?.focus();
210 }, 10);
211 }}
212 >
213 {/* this is here as a fail safe, in case a new page is created and there are no blocks in it yet,
214 we render a newblockbutton with a textblock-like placeholder instead of a proper first block. */}
215 {!props.lastBlock ? (
216 <div className="pt-2 sm:pt-3">write something...</div>
217 ) : (
218 " "
219 )}
220 </div>
221 </div>
222 );
223}
224
225const BlockListBottom = (props: {
226 lastRootBlock: Block | undefined;
227 lastVisibleBlock: Block | undefined;
228 entityID: string;
229}) => {
230 let { rep } = useReplicache();
231 let entity_set = useEntitySetContext();
232 let handleDrop = useHandleDrop({
233 parent: props.entityID,
234 position: props.lastRootBlock?.position || null,
235 nextPosition: null,
236 });
237
238 if (!entity_set.permissions.write) return;
239 return (
240 <div
241 className="blockListClickableBottomArea shrink-0 h-[50vh]"
242 onClick={() => {
243 let newEntityID = v7();
244 if (
245 // if the last visible(not-folded) block is a text block, focus it
246 props.lastRootBlock &&
247 props.lastVisibleBlock &&
248 isTextBlock[props.lastVisibleBlock.type]
249 ) {
250 focusBlock(
251 { ...props.lastVisibleBlock, type: "text" },
252 { type: "end" },
253 );
254 } else {
255 // else add a new text block at the end and focus it
256 rep?.mutate.addBlock({
257 permission_set: entity_set.set,
258 factID: v7(),
259 parent: props.entityID,
260 type: "text",
261 position: generateKeyBetween(
262 props.lastRootBlock?.position || null,
263 null,
264 ),
265 newEntityID,
266 });
267
268 setTimeout(() => {
269 document.getElementById(elementId.block(newEntityID).text)?.focus();
270 }, 10);
271 }
272 }}
273 onDragOver={(e) => {
274 e.preventDefault();
275 e.stopPropagation();
276 }}
277 onDrop={handleDrop}
278 />
279 );
280};