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