a tool for shared writing and social publishing
1import { useRef, useEffect, useState, useLayoutEffect } from "react";
2import { elementId } from "src/utils/elementId";
3import { baseKeymap } from "prosemirror-commands";
4import { keymap } from "prosemirror-keymap";
5import * as Y from "yjs";
6import * as base64 from "base64-js";
7import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache";
8import { isVisible } from "src/utils/isVisible";
9
10import { EditorState, TextSelection } from "prosemirror-state";
11import { EditorView } from "prosemirror-view";
12
13import { ySyncPlugin } from "y-prosemirror";
14import { Replicache } from "replicache";
15import { RenderYJSFragment } from "./RenderYJSFragment";
16import { useInitialPageLoad } from "components/InitialPageLoadProvider";
17import { BlockProps } from "../Block";
18import { focusBlock } from "src/utils/focusBlock";
19import { TextBlockKeymap } from "./keymap";
20import { multiBlockSchema, schema } from "./schema";
21import { useUIState } from "src/useUIState";
22import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock";
23import { BlockCommandBar } from "components/Blocks/BlockCommandBar";
24import { useEditorStates } from "src/state/useEditorState";
25import { useEntitySetContext } from "components/EntitySetProvider";
26import { useHandlePaste } from "./useHandlePaste";
27import { highlightSelectionPlugin } from "./plugins";
28import { inputrules } from "./inputRules";
29import { autolink } from "./autolink-plugin";
30import { TooltipButton } from "components/Buttons";
31import { blockCommands } from "../BlockCommands";
32import { betterIsUrl } from "src/utils/isURL";
33import { useSmoker } from "components/Toast";
34import { AddTiny } from "components/Icons/AddTiny";
35import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
36import { BlockImageSmall } from "components/Icons/BlockImageSmall";
37import { isIOS } from "src/utils/isDevice";
38import { useLeafletPublicationData } from "components/PageSWRDataProvider";
39import { DotLoader } from "components/utils/DotLoader";
40
41const HeadingStyle = {
42 1: "text-xl font-bold",
43 2: "text-lg font-bold",
44 3: "text-base font-bold text-secondary ",
45} as { [level: number]: string };
46
47export function TextBlock(
48 props: BlockProps & {
49 className?: string;
50 preview?: boolean;
51 },
52) {
53 let isLocked = useEntity(props.entityID, "block/is-locked");
54 let initialized = useInitialPageLoad();
55 let first = props.previousBlock === null;
56 let permission = useEntitySetContext().permissions.write;
57
58 return (
59 <>
60 {(!initialized ||
61 !permission ||
62 props.preview ||
63 isLocked?.data.value) && (
64 <RenderedTextBlock
65 type={props.type}
66 entityID={props.entityID}
67 className={props.className}
68 first={first}
69 pageType={props.pageType}
70 />
71 )}
72 {permission && !props.preview && !isLocked?.data.value && (
73 <div
74 className={`w-full relative group ${!initialized ? "hidden" : ""}`}
75 >
76 <IOSBS {...props} />
77 <BaseTextBlock {...props} />
78 </div>
79 )}
80 </>
81 );
82}
83
84export function IOSBS(props: BlockProps) {
85 let [initialRender, setInitialRender] = useState(true);
86 useEffect(() => {
87 setInitialRender(false);
88 }, []);
89 if (initialRender || !isIOS()) return null;
90 return (
91 <div
92 className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]"
93 onPointerUp={(e) => {
94 e.preventDefault();
95 focusBlock(props, {
96 type: "coord",
97 top: e.clientY,
98 left: e.clientX,
99 });
100 setTimeout(async () => {
101 let target = document.getElementById(
102 elementId.block(props.entityID).container,
103 );
104 let vis = await isVisible(target as Element);
105 if (!vis) {
106 let parentEl = document.getElementById(
107 elementId.page(props.parent).container,
108 );
109 if (!parentEl) return;
110 parentEl?.scrollBy({
111 top: 250,
112 behavior: "smooth",
113 });
114 }
115 }, 100);
116 }}
117 />
118 );
119}
120
121export function RenderedTextBlock(props: {
122 entityID: string;
123 className?: string;
124 first?: boolean;
125 pageType?: "canvas" | "doc";
126 type: BlockProps["type"];
127}) {
128 let initialFact = useEntity(props.entityID, "block/text");
129 let headingLevel = useEntity(props.entityID, "block/heading-level");
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 { permissions } = useEntitySetContext();
139
140 let content = <br />;
141 if (!initialFact) {
142 if (permissions.write && (props.first || props.pageType === "canvas"))
143 content = (
144 <div
145 className={`${props.className}
146 pointer-events-none italic text-tertiary flex flex-col `}
147 >
148 {headingLevel?.data.value === 1
149 ? "Title"
150 : headingLevel?.data.value === 2
151 ? "Header"
152 : headingLevel?.data.value === 3
153 ? "Subheader"
154 : "write something..."}
155 <div className=" text-xs font-normal">
156 or type "/" for commands
157 </div>
158 </div>
159 );
160 } else {
161 content = <RenderYJSFragment value={initialFact.data.value} wrapper="p" />;
162 }
163 return (
164 <div
165 style={{ wordBreak: "break-word" }} // better than tailwind break-all!
166 className={`
167 ${alignmentClass}
168 ${props.type === "blockquote" ? " blockquote " : ""}
169 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
170 w-full whitespace-pre-wrap outline-hidden ${props.className} `}
171 >
172 {content}
173 </div>
174 );
175}
176
177export function BaseTextBlock(props: BlockProps & { className?: string }) {
178 let mountRef = useRef<HTMLPreElement | null>(null);
179 let actionTimeout = useRef<number | null>(null);
180 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null);
181 let headingLevel = useEntity(props.entityID, "block/heading-level");
182 let entity_set = useEntitySetContext();
183 let alignment =
184 useEntity(props.entityID, "block/text-alignment")?.data.value || "left";
185 let propsRef = useRef({ ...props, entity_set, alignment });
186 useEffect(() => {
187 propsRef.current = { ...props, entity_set, alignment };
188 }, [props, entity_set, alignment]);
189 let rep = useReplicache();
190 useEffect(() => {
191 repRef.current = rep.rep;
192 }, [rep?.rep]);
193
194 let selected = useUIState(
195 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID),
196 );
197 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
198 let alignmentClass = {
199 left: "text-left",
200 right: "text-right",
201 center: "text-center",
202 justify: "text-justify",
203 }[alignment];
204
205 let value = useYJSValue(props.entityID);
206
207 let editorState = useEditorStates(
208 (s) => s.editorStates[props.entityID],
209 )?.editor;
210 let handlePaste = useHandlePaste(props.entityID, propsRef);
211 useLayoutEffect(() => {
212 if (!mountRef.current) return;
213 let km = TextBlockKeymap(propsRef, repRef, rep.undoManager);
214 let editor = EditorState.create({
215 schema: schema,
216 plugins: [
217 ySyncPlugin(value),
218 keymap(km),
219 inputrules(propsRef, repRef),
220 keymap(baseKeymap),
221 highlightSelectionPlugin,
222 autolink({
223 type: schema.marks.link,
224 shouldAutoLink: () => true,
225 defaultProtocol: "https",
226 }),
227 ],
228 });
229
230 let unsubscribe = useEditorStates.subscribe((s) => {
231 let editorState = s.editorStates[props.entityID];
232 if (editorState?.initial) return;
233 if (editorState?.editor)
234 editorState.view?.updateState(editorState.editor);
235 });
236 let view = new EditorView(
237 { mount: mountRef.current },
238 {
239 state: editor,
240 handlePaste,
241 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => {
242 if (!direct) return;
243 if (node.nodeSize - 2 <= _pos) return;
244 let mark =
245 node
246 .nodeAt(_pos - 1)
247 ?.marks.find((f) => f.type === schema.marks.link) ||
248 node
249 .nodeAt(Math.max(_pos - 2, 0))
250 ?.marks.find((f) => f.type === schema.marks.link);
251 if (mark) {
252 window.open(mark.attrs.href, "_blank");
253 }
254 },
255 dispatchTransaction(tr) {
256 useEditorStates.setState((s) => {
257 let oldEditorState = this.state;
258 let newState = this.state.apply(tr);
259 let addToHistory = tr.getMeta("addToHistory");
260 let isBulkOp = tr.getMeta("bulkOp");
261 let docHasChanges = tr.steps.length !== 0 || tr.docChanged;
262 if (addToHistory !== false && docHasChanges) {
263 if (actionTimeout.current) {
264 window.clearTimeout(actionTimeout.current);
265 } else {
266 if (!isBulkOp) rep.undoManager.startGroup();
267 }
268
269 if (!isBulkOp)
270 actionTimeout.current = window.setTimeout(() => {
271 rep.undoManager.endGroup();
272 actionTimeout.current = null;
273 }, 200);
274 rep.undoManager.add({
275 redo: () => {
276 useEditorStates.setState((oldState) => {
277 let view = oldState.editorStates[props.entityID]?.view;
278 if (!view?.hasFocus() && !isBulkOp) view?.focus();
279 return {
280 editorStates: {
281 ...oldState.editorStates,
282 [props.entityID]: {
283 ...oldState.editorStates[props.entityID]!,
284 editor: newState,
285 },
286 },
287 };
288 });
289 },
290 undo: () => {
291 useEditorStates.setState((oldState) => {
292 let view = oldState.editorStates[props.entityID]?.view;
293 if (!view?.hasFocus() && !isBulkOp) view?.focus();
294 return {
295 editorStates: {
296 ...oldState.editorStates,
297 [props.entityID]: {
298 ...oldState.editorStates[props.entityID]!,
299 editor: oldEditorState,
300 },
301 },
302 };
303 });
304 },
305 });
306 }
307
308 return {
309 editorStates: {
310 ...s.editorStates,
311 [props.entityID]: {
312 editor: newState,
313 view: this as unknown as EditorView,
314 initial: false,
315 keymap: km,
316 },
317 },
318 };
319 });
320 },
321 },
322 );
323 return () => {
324 unsubscribe();
325 view.destroy();
326 useEditorStates.setState((s) => ({
327 ...s,
328 editorStates: {
329 ...s.editorStates,
330 [props.entityID]: undefined,
331 },
332 }));
333 };
334 }, [props.entityID, props.parent, value, handlePaste, rep]);
335
336 return (
337 <>
338 <div
339 className={`flex items-center justify-between w-full
340 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"}
341 ${props.type === "blockquote" ? " blockquote " : ""}
342 `}
343 >
344 <pre
345 data-entityid={props.entityID}
346 onBlur={async () => {
347 if (
348 ["***", "---", "___"].includes(
349 editorState?.doc.textContent.trim() || "",
350 )
351 ) {
352 await rep.rep?.mutate.assertFact({
353 entity: props.entityID,
354 attribute: "block/type",
355 data: { type: "block-type-union", value: "horizontal-rule" },
356 });
357 }
358 if (actionTimeout.current) {
359 rep.undoManager.endGroup();
360 window.clearTimeout(actionTimeout.current);
361 actionTimeout.current = null;
362 }
363 }}
364 onFocus={() => {
365 setTimeout(() => {
366 useUIState.getState().setSelectedBlock(props);
367 useUIState.setState(() => ({
368 focusedEntity: {
369 entityType: "block",
370 entityID: props.entityID,
371 parent: props.parent,
372 },
373 }));
374 }, 5);
375 }}
376 id={elementId.block(props.entityID).text}
377 // unless we break *only* on urls, this is better than tailwind 'break-all'
378 // b/c break-all can cause breaks in the middle of words, but break-word still
379 // forces break if a single text string (e.g. a url) spans more than a full line
380 style={{ wordBreak: "break-word" }}
381 className={`
382 ${alignmentClass}
383 grow resize-none align-top whitespace-pre-wrap bg-transparent
384 outline-hidden
385
386 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
387 ${props.className}`}
388 ref={mountRef}
389 />
390 {editorState?.doc.textContent.length === 0 &&
391 props.previousBlock === null &&
392 props.nextBlock === null ? (
393 // if this is the only block on the page and is empty or is a canvas, show placeholder
394 <div
395 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col
396 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""}
397 `}
398 >
399 {props.type === "text"
400 ? "write something..."
401 : headingLevel?.data.value === 3
402 ? "Subheader"
403 : headingLevel?.data.value === 2
404 ? "Header"
405 : "Title"}
406 <div className=" text-xs font-normal">
407 or type "/" to add a block
408 </div>
409 </div>
410 ) : editorState?.doc.textContent.length === 0 && focused ? (
411 // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button
412 <CommandOptions {...props} className={props.className} />
413 ) : null}
414
415 {editorState?.doc.textContent.startsWith("/") && selected && (
416 <BlockCommandBar
417 props={props}
418 searchValue={editorState.doc.textContent.slice(1)}
419 />
420 )}
421 </div>
422 <BlockifyLink entityID={props.entityID} editorState={editorState} />
423 </>
424 );
425}
426
427const BlockifyLink = (props: {
428 entityID: string;
429 editorState: EditorState | undefined;
430}) => {
431 let [loading, setLoading] = useState(false);
432 let { editorState } = props;
433 let rep = useReplicache();
434 let smoker = useSmoker();
435 let isLocked = useEntity(props.entityID, "block/is-locked");
436 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
437
438 let isBlueskyPost =
439 editorState?.doc.textContent.includes("bsky.app/") &&
440 editorState?.doc.textContent.includes("post");
441 // only if the line stats with http or https and doesn't have other content
442 // if its bluesky, change text to embed post
443
444 if (
445 !isLocked &&
446 focused &&
447 editorState &&
448 betterIsUrl(editorState.doc.textContent) &&
449 !editorState.doc.textContent.includes(" ")
450 ) {
451 return (
452 <button
453 onClick={async (e) => {
454 if (!rep.rep) return;
455 rep.undoManager.startGroup();
456 if (isBlueskyPost) {
457 let success = await addBlueskyPostBlock(
458 editorState.doc.textContent,
459 props.entityID,
460 rep.rep,
461 );
462 if (!success)
463 smoker({
464 error: true,
465 text: "post not found!",
466 position: {
467 x: e.clientX + 12,
468 y: e.clientY,
469 },
470 });
471 } else {
472 setLoading(true);
473 await addLinkBlock(
474 editorState.doc.textContent,
475 props.entityID,
476 rep.rep,
477 );
478 setLoading(false);
479 }
480 rep.undoManager.endGroup();
481 }}
482 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 "
483 >
484 {loading ? <DotLoader /> : "embed"}
485 </button>
486 );
487 } else return null;
488};
489
490const CommandOptions = (props: BlockProps & { className?: string }) => {
491 let rep = useReplicache();
492 let entity_set = useEntitySetContext();
493 let { data: pub } = useLeafletPublicationData();
494
495 return (
496 <div
497 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]"}`}
498 >
499 <TooltipButton
500 className={props.className}
501 onMouseDown={async () => {
502 let command = blockCommands.find((f) => f.name === "Image");
503 if (!rep.rep) return;
504 await command?.onSelect(
505 rep.rep,
506 { ...props, entity_set: entity_set.set },
507 rep.undoManager,
508 );
509 }}
510 side="bottom"
511 tooltipContent={
512 <div className="flex gap-1 font-bold">Add an Image</div>
513 }
514 >
515 <BlockImageSmall className="hover:text-accent-contrast text-border" />
516 </TooltipButton>
517
518 {!pub && (
519 <TooltipButton
520 className={props.className}
521 onMouseDown={async () => {
522 let command = blockCommands.find((f) => f.name === "New Page");
523 if (!rep.rep) return;
524 await command?.onSelect(
525 rep.rep,
526 { ...props, entity_set: entity_set.set },
527 rep.undoManager,
528 );
529 }}
530 side="bottom"
531 tooltipContent={
532 <div className="flex gap-1 font-bold">Add a Subpage</div>
533 }
534 >
535 <BlockDocPageSmall className="hover:text-accent-contrast text-border" />
536 </TooltipButton>
537 )}
538
539 <TooltipButton
540 className={props.className}
541 onMouseDown={(e) => {
542 e.preventDefault();
543 let editor = useEditorStates.getState().editorStates[props.entityID];
544
545 let editorState = editor?.editor;
546 if (editorState) {
547 editor?.view?.focus();
548 let tr = editorState.tr.insertText("/", 1);
549 tr.setSelection(TextSelection.create(tr.doc, 2));
550 useEditorStates.setState((s) => ({
551 editorStates: {
552 ...s.editorStates,
553 [props.entityID]: {
554 ...s.editorStates[props.entityID]!,
555 editor: editorState!.apply(tr),
556 },
557 },
558 }));
559 }
560 focusBlock(
561 {
562 type: props.type,
563 value: props.entityID,
564 parent: props.parent,
565 },
566 { type: "end" },
567 );
568 }}
569 side="bottom"
570 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>}
571 >
572 <div className="w-6 h-6 flex place-items-center justify-center">
573 <AddTiny className="text-accent-contrast" />
574 </div>
575 </TooltipButton>
576 </div>
577 );
578};
579
580function useYJSValue(entityID: string) {
581 const [ydoc] = useState(new Y.Doc());
582 const docStateFromReplicache = useEntity(entityID, "block/text");
583 let rep = useReplicache();
584 const [yText] = useState(ydoc.getXmlFragment("prosemirror"));
585
586 if (docStateFromReplicache) {
587 const update = base64.toByteArray(docStateFromReplicache.data.value);
588 Y.applyUpdate(ydoc, update);
589 }
590
591 useEffect(() => {
592 if (!rep.rep) return;
593 let timeout = null as null | number;
594 const updateReplicache = async () => {
595 const update = Y.encodeStateAsUpdate(ydoc);
596 await rep.rep?.mutate.assertFact({
597 //These undos are handled above in the Prosemirror context
598 ignoreUndo: true,
599 entity: entityID,
600 attribute: "block/text",
601 data: {
602 value: base64.fromByteArray(update),
603 type: "text",
604 },
605 });
606 };
607 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => {
608 if (!transaction.origin) return;
609 if (timeout) clearTimeout(timeout);
610 timeout = window.setTimeout(async () => {
611 updateReplicache();
612 }, 300);
613 };
614
615 yText.observeDeep(f);
616 return () => {
617 yText.unobserveDeep(f);
618 };
619 }, [yText, entityID, rep, ydoc]);
620 return yText;
621}