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