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 blueskyclients = ["blacksky.community/", "bsky.app/", "witchsky.app/"];
322
323const BlockifyLink = (props: {
324 entityID: string;
325 editorState: EditorState | undefined;
326}) => {
327 let [loading, setLoading] = useState(false);
328 let { editorState } = props;
329 let rep = useReplicache();
330 let smoker = useSmoker();
331 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
332
333 let isBlueskyPost =
334 blueskyclients.some((client) =>
335 editorState?.doc.textContent.includes(client),
336 ) && editorState?.doc.textContent.includes("post");
337 // only if the line starts with http or https and doesn't have other content
338 // if its bluesky, change text to embed post
339
340 if (
341 focused &&
342 editorState &&
343 betterIsUrl(editorState.doc.textContent) &&
344 !editorState.doc.textContent.includes(" ")
345 ) {
346 return (
347 <button
348 onClick={async (e) => {
349 if (!rep.rep) return;
350 rep.undoManager.startGroup();
351 if (isBlueskyPost) {
352 let success = await addBlueskyPostBlock(
353 editorState.doc.textContent,
354 props.entityID,
355 rep.rep,
356 );
357 if (!success)
358 smoker({
359 error: true,
360 text: "post not found!",
361 position: {
362 x: e.clientX + 12,
363 y: e.clientY,
364 },
365 });
366 } else {
367 setLoading(true);
368 await addLinkBlock(
369 editorState.doc.textContent,
370 props.entityID,
371 rep.rep,
372 );
373 setLoading(false);
374 }
375 rep.undoManager.endGroup();
376 }}
377 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 "
378 >
379 {loading ? <DotLoader /> : "embed"}
380 </button>
381 );
382 } else return null;
383};
384
385const CommandOptions = (props: BlockProps & { className?: string }) => {
386 let rep = useReplicache();
387 let entity_set = useEntitySetContext();
388 let { data: pub } = useLeafletPublicationData();
389
390 return (
391 <div
392 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]"}`}
393 >
394 <TooltipButton
395 className={props.className}
396 onMouseDown={async () => {
397 let command = blockCommands.find((f) => f.name === "Image");
398 if (!rep.rep) return;
399 await command?.onSelect(
400 rep.rep,
401 { ...props, entity_set: entity_set.set },
402 rep.undoManager,
403 );
404 }}
405 side="bottom"
406 tooltipContent={
407 <div className="flex gap-1 font-bold">Add an Image</div>
408 }
409 >
410 <BlockImageSmall className="hover:text-accent-contrast text-border" />
411 </TooltipButton>
412
413 {!pub && (
414 <TooltipButton
415 className={props.className}
416 onMouseDown={async () => {
417 let command = blockCommands.find((f) => f.name === "New Page");
418 if (!rep.rep) return;
419 await command?.onSelect(
420 rep.rep,
421 { ...props, entity_set: entity_set.set },
422 rep.undoManager,
423 );
424 }}
425 side="bottom"
426 tooltipContent={
427 <div className="flex gap-1 font-bold">Add a Subpage</div>
428 }
429 >
430 <BlockDocPageSmall className="hover:text-accent-contrast text-border" />
431 </TooltipButton>
432 )}
433
434 <TooltipButton
435 className={props.className}
436 onMouseDown={(e) => {
437 e.preventDefault();
438 let editor = useEditorStates.getState().editorStates[props.entityID];
439
440 let editorState = editor?.editor;
441 if (editorState) {
442 editor?.view?.focus();
443 let tr = editorState.tr.insertText("/", 1);
444 tr.setSelection(TextSelection.create(tr.doc, 2));
445 useEditorStates.setState((s) => ({
446 editorStates: {
447 ...s.editorStates,
448 [props.entityID]: {
449 ...s.editorStates[props.entityID]!,
450 editor: editorState!.apply(tr),
451 },
452 },
453 }));
454 }
455 focusBlock(
456 {
457 type: props.type,
458 value: props.entityID,
459 parent: props.parent,
460 },
461 { type: "end" },
462 );
463 }}
464 side="bottom"
465 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>}
466 >
467 <div className="w-6 h-6 flex place-items-center justify-center">
468 <AddTiny className="text-accent-contrast" />
469 </div>
470 </TooltipButton>
471 </div>
472 );
473};
474
475const useMentionState = (entityID: string) => {
476 let view = useEditorStates((s) => s.editorStates[entityID])?.view;
477 let viewRef = useRef(view || null);
478 viewRef.current = view || null;
479
480 const [mentionOpen, setMentionOpen] = useState(false);
481 const [mentionCoords, setMentionCoords] = useState<{
482 top: number;
483 left: number;
484 } | null>(null);
485 const [mentionInsertPos, setMentionInsertPos] = useState<number | null>(null);
486
487 // Close autocomplete when this block is no longer focused
488 const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID);
489 useEffect(() => {
490 if (!isFocused) {
491 setMentionOpen(false);
492 setMentionCoords(null);
493 setMentionInsertPos(null);
494 }
495 }, [isFocused]);
496
497 const openMentionAutocomplete = useCallback(() => {
498 const view = useEditorStates.getState().editorStates[entityID]?.view;
499 if (!view) return;
500
501 // Get the position right after the @ we just inserted
502 const pos = view.state.selection.from;
503 setMentionInsertPos(pos);
504
505 // Get coordinates for the popup relative to the positioned parent
506 const coords = view.coordsAtPos(pos - 1); // Position of the @
507
508 // Find the relative positioned parent container
509 const editorEl = view.dom;
510 const container = editorEl.closest(".relative") as HTMLElement | null;
511
512 if (container) {
513 const containerRect = container.getBoundingClientRect();
514 setMentionCoords({
515 top: coords.bottom - containerRect.top,
516 left: coords.left - containerRect.left,
517 });
518 } else {
519 setMentionCoords({
520 top: coords.bottom,
521 left: coords.left,
522 });
523 }
524 setMentionOpen(true);
525 }, [entityID]);
526
527 const handleMentionSelect = useCallback(
528 (mention: Mention) => {
529 const view = useEditorStates.getState().editorStates[entityID]?.view;
530 if (!view || mentionInsertPos === null) return;
531
532 // The @ is at mentionInsertPos - 1, we need to replace it with the mention
533 const from = mentionInsertPos - 1;
534 const to = mentionInsertPos;
535
536 addMentionToEditor(mention, { from, to }, view);
537 view.focus();
538 },
539 [entityID, mentionInsertPos],
540 );
541
542 const handleMentionOpenChange = useCallback((open: boolean) => {
543 setMentionOpen(open);
544 if (!open) {
545 setMentionCoords(null);
546 setMentionInsertPos(null);
547 }
548 }, []);
549
550 return {
551 viewRef,
552 mentionOpen,
553 mentionCoords,
554 openMentionAutocomplete,
555 handleMentionSelect,
556 handleMentionOpenChange,
557 };
558};