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