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