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