forked from
leaflet.pub/leaflet
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 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 let handleDrop = useHandleDrop({
235 parent: props.entityID,
236 position: props.lastRootBlock?.position || null,
237 nextPosition: null,
238 });
239
240 if (!entity_set.permissions.write) return;
241 return (
242 <div
243 className="blockListClickableBottomArea shrink-0 h-[50vh]"
244 onClick={() => {
245 let newEntityID = v7();
246 if (
247 // if the last visible(not-folded) block is a text block, focus it
248 props.lastRootBlock &&
249 props.lastVisibleBlock &&
250 isTextBlock[props.lastVisibleBlock.type]
251 ) {
252 focusBlock(
253 { ...props.lastVisibleBlock, type: "text" },
254 { type: "end" },
255 );
256 } else {
257 // else add a new text block at the end and focus it
258 rep?.mutate.addBlock({
259 permission_set: entity_set.set,
260 factID: v7(),
261 parent: props.entityID,
262 type: "text",
263 position: generateKeyBetween(
264 props.lastRootBlock?.position || null,
265 null,
266 ),
267 newEntityID,
268 });
269
270 setTimeout(() => {
271 document.getElementById(elementId.block(newEntityID).text)?.focus();
272 }, 10);
273 }
274 }}
275 onDragOver={(e) => {
276 e.preventDefault();
277 e.stopPropagation();
278 }}
279 onDrop={handleDrop}
280 />
281 );
282};