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