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