a tool for shared writing and social publishing
1import { useRef, useEffect, useState } from "react";
2import { elementId } from "src/utils/elementId";
3import { useReplicache, useEntity } from "src/replicache";
4import { isVisible } from "src/utils/isVisible";
5import { EditorState, TextSelection } from "prosemirror-state";
6import { RenderYJSFragment } from "./RenderYJSFragment";
7import { useHasPageLoaded } from "components/InitialPageLoadProvider";
8import { BlockProps } from "../Block";
9import { focusBlock } from "src/utils/focusBlock";
10import { useUIState } from "src/useUIState";
11import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock";
12import { BlockCommandBar } from "components/Blocks/BlockCommandBar";
13import { useEditorStates } from "src/state/useEditorState";
14import { useEntitySetContext } from "components/EntitySetProvider";
15import { TooltipButton } from "components/Buttons";
16import { blockCommands } from "../BlockCommands";
17import { betterIsUrl } from "src/utils/isURL";
18import { useSmoker } from "components/Toast";
19import { AddTiny } from "components/Icons/AddTiny";
20import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
21import { BlockImageSmall } from "components/Icons/BlockImageSmall";
22import { isIOS } from "src/utils/isDevice";
23import { useLeafletPublicationData } from "components/PageSWRDataProvider";
24import { DotLoader } from "components/utils/DotLoader";
25import { useMountProsemirror } from "./mountProsemirror";
26
27const HeadingStyle = {
28 1: "text-xl font-bold",
29 2: "text-lg font-bold",
30 3: "text-base font-bold text-secondary ",
31} as { [level: number]: string };
32
33export function TextBlock(
34 props: BlockProps & {
35 className?: string;
36 preview?: boolean;
37 },
38) {
39 let isLocked = useEntity(props.entityID, "block/is-locked");
40 let initialized = useHasPageLoaded();
41 let first = props.previousBlock === null;
42 let permission = useEntitySetContext().permissions.write;
43
44 return (
45 <>
46 {(!initialized ||
47 !permission ||
48 props.preview ||
49 isLocked?.data.value) && (
50 <RenderedTextBlock
51 type={props.type}
52 entityID={props.entityID}
53 className={props.className}
54 first={first}
55 pageType={props.pageType}
56 previousBlock={props.previousBlock}
57 />
58 )}
59 {permission && !props.preview && !isLocked?.data.value && (
60 <div
61 className={`w-full relative group ${!initialized ? "hidden" : ""}`}
62 >
63 <IOSBS {...props} />
64 <BaseTextBlock {...props} />
65 </div>
66 )}
67 </>
68 );
69}
70
71export function IOSBS(props: BlockProps) {
72 let [initialRender, setInitialRender] = useState(true);
73 useEffect(() => {
74 setInitialRender(false);
75 }, []);
76 if (initialRender || !isIOS()) return null;
77 return (
78 <div
79 className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]"
80 onPointerUp={(e) => {
81 e.preventDefault();
82 focusBlock(props, {
83 type: "coord",
84 top: e.clientY,
85 left: e.clientX,
86 });
87 setTimeout(async () => {
88 let target = document.getElementById(
89 elementId.block(props.entityID).container,
90 );
91 let vis = await isVisible(target as Element);
92 if (!vis) {
93 let parentEl = document.getElementById(
94 elementId.page(props.parent).container,
95 );
96 if (!parentEl) return;
97 parentEl?.scrollBy({
98 top: 250,
99 behavior: "smooth",
100 });
101 }
102 }, 100);
103 }}
104 />
105 );
106}
107
108export function RenderedTextBlock(props: {
109 entityID: string;
110 className?: string;
111 first?: boolean;
112 pageType?: "canvas" | "doc";
113 type: BlockProps["type"];
114 previousBlock?: BlockProps["previousBlock"];
115}) {
116 let initialFact = useEntity(props.entityID, "block/text");
117 let headingLevel = useEntity(props.entityID, "block/heading-level");
118 let alignment =
119 useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
120 let alignmentClass = {
121 left: "text-left",
122 right: "text-right",
123 center: "text-center",
124 justify: "text-justify",
125 }[alignment];
126 let { permissions } = useEntitySetContext();
127
128 let content = <br />;
129 if (!initialFact) {
130 if (permissions.write && (props.first || props.pageType === "canvas"))
131 content = (
132 <div
133 className={`${props.className}
134 pointer-events-none italic text-tertiary flex flex-col `}
135 >
136 {headingLevel?.data.value === 1
137 ? "Title"
138 : headingLevel?.data.value === 2
139 ? "Header"
140 : headingLevel?.data.value === 3
141 ? "Subheader"
142 : "write something..."}
143 <div className=" text-xs font-normal">
144 or type "/" for commands
145 </div>
146 </div>
147 );
148 } else {
149 content = <RenderYJSFragment value={initialFact.data.value} wrapper="p" />;
150 }
151 return (
152 <div
153 style={{ wordBreak: "break-word" }} // better than tailwind break-all!
154 className={`
155 ${alignmentClass}
156 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""}
157 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
158 w-full whitespace-pre-wrap outline-hidden ${props.className} `}
159 >
160 {content}
161 </div>
162 );
163}
164
165export function BaseTextBlock(props: BlockProps & { className?: string }) {
166 let headingLevel = useEntity(props.entityID, "block/heading-level");
167 let alignment =
168 useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
169
170 let rep = useReplicache();
171
172 let selected = useUIState(
173 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
174 );
175 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
176 let alignmentClass = {
177 left: "text-left",
178 right: "text-right",
179 center: "text-center",
180 justify: "text-justify",
181 }[alignment];
182
183 let editorState = useEditorStates(
184 (s) => s.editorStates[props.entityID],
185 )?.editor;
186
187 let { mountRef, actionTimeout } = useMountProsemirror({
188 props,
189 });
190
191 return (
192 <>
193 <div
194 className={`flex items-center justify-between w-full
195 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"}
196 ${
197 props.type === "blockquote"
198 ? props.previousBlock?.type === "blockquote" && !props.listData
199 ? "blockquote pt-3"
200 : "blockquote"
201 : ""
202 }
203
204 `}
205 >
206 <pre
207 data-entityid={props.entityID}
208 onBlur={async () => {
209 if (
210 ["***", "---", "___"].includes(
211 editorState?.doc.textContent.trim() || "",
212 )
213 ) {
214 await rep.rep?.mutate.assertFact({
215 entity: props.entityID,
216 attribute: "block/type",
217 data: { type: "block-type-union", value: "horizontal-rule" },
218 });
219 }
220 if (actionTimeout.current) {
221 rep.undoManager.endGroup();
222 window.clearTimeout(actionTimeout.current);
223 actionTimeout.current = null;
224 }
225 }}
226 onFocus={() => {
227 setTimeout(() => {
228 useUIState.getState().setSelectedBlock(props);
229 useUIState.setState(() => ({
230 focusedEntity: {
231 entityType: "block",
232 entityID: props.entityID,
233 parent: props.parent,
234 },
235 }));
236 }, 5);
237 }}
238 id={elementId.block(props.entityID).text}
239 // unless we break *only* on urls, this is better than tailwind 'break-all'
240 // b/c break-all can cause breaks in the middle of words, but break-word still
241 // forces break if a single text string (e.g. a url) spans more than a full line
242 style={{ wordBreak: "break-word" }}
243 className={`
244 ${alignmentClass}
245 grow resize-none align-top whitespace-pre-wrap bg-transparent
246 outline-hidden
247
248 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
249 ${props.className}`}
250 ref={mountRef}
251 />
252 {editorState?.doc.textContent.length === 0 &&
253 props.previousBlock === null &&
254 props.nextBlock === null ? (
255 // if this is the only block on the page and is empty or is a canvas, show placeholder
256 <div
257 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col
258 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
259 `}
260 >
261 {props.type === "text"
262 ? "write something..."
263 : headingLevel?.data.value === 3
264 ? "Subheader"
265 : headingLevel?.data.value === 2
266 ? "Header"
267 : "Title"}
268 <div className=" text-xs font-normal">
269 or type "/" to add a block
270 </div>
271 </div>
272 ) : editorState?.doc.textContent.length === 0 && focused ? (
273 // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button
274 <CommandOptions {...props} className={props.className} />
275 ) : null}
276
277 {editorState?.doc.textContent.startsWith("/") && selected && (
278 <BlockCommandBar
279 props={props}
280 searchValue={editorState.doc.textContent.slice(1)}
281 />
282 )}
283 </div>
284 <BlockifyLink entityID={props.entityID} editorState={editorState} />
285 </>
286 );
287}
288
289const BlockifyLink = (props: {
290 entityID: string;
291 editorState: EditorState | undefined;
292}) => {
293 let [loading, setLoading] = useState(false);
294 let { editorState } = props;
295 let rep = useReplicache();
296 let smoker = useSmoker();
297 let isLocked = useEntity(props.entityID, "block/is-locked");
298 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
299
300 let isBlueskyPost =
301 editorState?.doc.textContent.includes("bsky.app/") &&
302 editorState?.doc.textContent.includes("post");
303 // only if the line stats with http or https and doesn't have other content
304 // if its bluesky, change text to embed post
305
306 if (
307 !isLocked &&
308 focused &&
309 editorState &&
310 betterIsUrl(editorState.doc.textContent) &&
311 !editorState.doc.textContent.includes(" ")
312 ) {
313 return (
314 <button
315 onClick={async (e) => {
316 if (!rep.rep) return;
317 rep.undoManager.startGroup();
318 if (isBlueskyPost) {
319 let success = await addBlueskyPostBlock(
320 editorState.doc.textContent,
321 props.entityID,
322 rep.rep,
323 );
324 if (!success)
325 smoker({
326 error: true,
327 text: "post not found!",
328 position: {
329 x: e.clientX + 12,
330 y: e.clientY,
331 },
332 });
333 } else {
334 setLoading(true);
335 await addLinkBlock(
336 editorState.doc.textContent,
337 props.entityID,
338 rep.rep,
339 );
340 setLoading(false);
341 }
342 rep.undoManager.endGroup();
343 }}
344 className="absolute right-0 top-0 px-1 py-0.5 text-xs text-tertiary sm:hover:text-accent-contrast border border-border-light sm:hover:border-accent-contrast sm:outline-accent-tertiary rounded-md bg-bg-page selected-outline "
345 >
346 {loading ? <DotLoader /> : "embed"}
347 </button>
348 );
349 } else return null;
350};
351
352const CommandOptions = (props: BlockProps & { className?: string }) => {
353 let rep = useReplicache();
354 let entity_set = useEntitySetContext();
355 let { data: pub } = useLeafletPublicationData();
356
357 return (
358 <div
359 className={`absolute top-0 right-0 w-fit flex gap-[6px] items-center font-bold rounded-md text-sm text-border ${props.pageType === "canvas" && "mr-[6px]"}`}
360 >
361 <TooltipButton
362 className={props.className}
363 onMouseDown={async () => {
364 let command = blockCommands.find((f) => f.name === "Image");
365 if (!rep.rep) return;
366 await command?.onSelect(
367 rep.rep,
368 { ...props, entity_set: entity_set.set },
369 rep.undoManager,
370 );
371 }}
372 side="bottom"
373 tooltipContent={
374 <div className="flex gap-1 font-bold">Add an Image</div>
375 }
376 >
377 <BlockImageSmall className="hover:text-accent-contrast text-border" />
378 </TooltipButton>
379
380 {!pub && (
381 <TooltipButton
382 className={props.className}
383 onMouseDown={async () => {
384 let command = blockCommands.find((f) => f.name === "New Page");
385 if (!rep.rep) return;
386 await command?.onSelect(
387 rep.rep,
388 { ...props, entity_set: entity_set.set },
389 rep.undoManager,
390 );
391 }}
392 side="bottom"
393 tooltipContent={
394 <div className="flex gap-1 font-bold">Add a Subpage</div>
395 }
396 >
397 <BlockDocPageSmall className="hover:text-accent-contrast text-border" />
398 </TooltipButton>
399 )}
400
401 <TooltipButton
402 className={props.className}
403 onMouseDown={(e) => {
404 e.preventDefault();
405 let editor = useEditorStates.getState().editorStates[props.entityID];
406
407 let editorState = editor?.editor;
408 if (editorState) {
409 editor?.view?.focus();
410 let tr = editorState.tr.insertText("/", 1);
411 tr.setSelection(TextSelection.create(tr.doc, 2));
412 useEditorStates.setState((s) => ({
413 editorStates: {
414 ...s.editorStates,
415 [props.entityID]: {
416 ...s.editorStates[props.entityID]!,
417 editor: editorState!.apply(tr),
418 },
419 },
420 }));
421 }
422 focusBlock(
423 {
424 type: props.type,
425 value: props.entityID,
426 parent: props.parent,
427 },
428 { type: "end" },
429 );
430 }}
431 side="bottom"
432 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>}
433 >
434 <div className="w-6 h-6 flex place-items-center justify-center">
435 <AddTiny className="text-accent-contrast" />
436 </div>
437 </TooltipButton>
438 </div>
439 );
440};
441
442const useMentionState = () => {
443 const [editorState, setEditorState] = useState<EditorState | null>(null);
444 const [mentionState, setMentionState] = useState<{
445 active: boolean;
446 range: { from: number; to: number } | null;
447 selectedMention: { handle: string; did: string } | null;
448 }>({ active: false, range: null, selectedMention: null });
449 const mentionStateRef = useRef(mentionState);
450 mentionStateRef.current = mentionState;
451 return { mentionStateRef };
452};