a tool for shared writing and social publishing

server render text blocks!!

+245 -82
+14 -3
app/[doc_id]/page.tsx
··· 1 - import { ReplicacheProvider } from "../../replicache"; 1 + import { createClient } from "@supabase/supabase-js"; 2 + import { Fact, ReplicacheProvider } from "../../replicache"; 3 + import { Database } from "../../supabase/database.types"; 2 4 import { AddBlock, Blocks } from "./Blocks"; 5 + import { Attributes } from "../../replicache/attributes"; 3 6 4 7 export const preferredRegion = ["sfo1"]; 5 8 export const dynamic = "force-dynamic"; 6 9 7 - export default function DocumentPage(props: { params: { doc_id: string } }) { 10 + let supabase = createClient<Database>( 11 + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 12 + process.env.SUPABASE_SERVICE_ROLE_KEY as string, 13 + ); 14 + export default async function DocumentPage(props: { 15 + params: { doc_id: string }; 16 + }) { 17 + let { data } = await supabase.rpc("get_facts", { root: props.params.doc_id }); 18 + let initialFacts = (data as unknown as Fact<keyof typeof Attributes>[]) || []; 8 19 return ( 9 - <ReplicacheProvider name={props.params.doc_id}> 20 + <ReplicacheProvider name={props.params.doc_id} initialFacts={initialFacts}> 10 21 <div className="text-blue-400">doc_id: {props.params.doc_id}</div> 11 22 <AddBlock entityID={props.params.doc_id} /> 12 23 <Blocks entityID={props.params.doc_id} />
+4 -1
app/layout.tsx
··· 1 + import { InitialPageLoad } from "../components/InitialPageLoadProvider"; 1 2 import "./globals.css"; 2 3 import localFont from "next/font/local"; 3 4 ··· 21 22 }) { 22 23 return ( 23 24 <html lang="en" className={`${quattro.variable}`}> 24 - <body>{children}</body> 25 + <body> 26 + <InitialPageLoad>{children}</InitialPageLoad> 27 + </body> 25 28 </html> 26 29 ); 27 30 }
+13
components/InitialPageLoadProvider.tsx
··· 1 + "use client"; 2 + import { useEffect } from "react"; 3 + import { create } from "zustand"; 4 + 5 + export const useInitialPageLoad = create(() => false); 6 + export function InitialPageLoad(props: { children: React.ReactNode }) { 7 + useEffect(() => { 8 + setTimeout(() => { 9 + useInitialPageLoad.setState(() => true); 10 + }, 80); 11 + }, []); 12 + return <>{props.children}</>; 13 + }
+59
components/RenderYJSFragment.tsx
··· 1 + import { XmlElement, XmlHook, XmlText } from "yjs"; 2 + import { nodes, marks } from "prosemirror-schema-basic"; 3 + export function RenderYJSFragment({ 4 + node, 5 + }: { 6 + node: XmlElement | XmlText | XmlHook; 7 + }) { 8 + if (node.constructor === XmlElement) { 9 + switch (node.nodeName as keyof typeof nodes) { 10 + case "paragraph": { 11 + let children = node.toArray(); 12 + return ( 13 + <p> 14 + {children.length === 0 ? ( 15 + <br /> 16 + ) : ( 17 + node 18 + .toArray() 19 + .map((f, index) => <RenderYJSFragment node={f} key={index} />) 20 + )} 21 + </p> 22 + ); 23 + } 24 + case "hard_break": 25 + return <br />; 26 + default: 27 + return null; 28 + } 29 + } 30 + if (node.constructor === XmlText) { 31 + return ( 32 + <> 33 + {(node.toDelta() as Delta[]).map((d, index) => { 34 + return ( 35 + <span key={index} className={attributesToClassName(d)}> 36 + {d.insert} 37 + </span> 38 + ); 39 + })} 40 + </> 41 + ); 42 + } 43 + return null; 44 + } 45 + 46 + type Delta = { 47 + insert: string; 48 + attributes?: { 49 + strong?: {}; 50 + em?: {}; 51 + }; 52 + }; 53 + 54 + function attributesToClassName(d: Delta) { 55 + let className = ""; 56 + if (d.attributes?.strong) className += "font-bold "; 57 + if (d.attributes?.em) className += "italic"; 58 + return className; 59 + }
+126 -72
components/TextBlock.tsx
··· 3 3 import { baseKeymap, toggleMark } from "prosemirror-commands"; 4 4 import { keymap } from "prosemirror-keymap"; 5 5 import * as Y from "yjs"; 6 - import { ProseMirror } from "@nytimes/react-prosemirror"; 6 + import { ProseMirror, useEditorState } from "@nytimes/react-prosemirror"; 7 7 import * as base64 from "base64-js"; 8 8 import { useReplicache, useEntity, ReplicacheMutators } from "../replicache"; 9 9 ··· 13 13 import { Replicache } from "replicache"; 14 14 import { generateKeyBetween } from "fractional-indexing"; 15 15 import { create } from "zustand"; 16 + import { RenderYJSFragment } from "./RenderYJSFragment"; 17 + import { useInitialPageLoad } from "./InitialPageLoadProvider"; 16 18 17 19 let useEditorStates = create( 18 20 () => 19 - ({}) as { [entity: string]: { editor: InstanceType<typeof EditorState> } }, 21 + ({}) as { 22 + [entity: string]: 23 + | { editor: InstanceType<typeof EditorState> } 24 + | undefined; 25 + }, 20 26 ); 21 27 22 28 export function TextBlock(props: { ··· 26 32 previousBlock: { value: string; position: string } | null; 27 33 nextPosition: string | null; 28 34 }) { 35 + let initialized = useInitialPageLoad(); 36 + return ( 37 + <> 38 + {!initialized && <RenderedTextBlock entityID={props.entityID} />} 39 + <div className={`${!initialized ? "hidden" : ""}`}> 40 + <BaseTextBlock {...props} /> 41 + </div> 42 + </> 43 + ); 44 + } 45 + 46 + function RenderedTextBlock(props: { entityID: string }) { 47 + let { initialFacts } = useReplicache(); 48 + let initialFact = initialFacts.find( 49 + (f) => f.entity === props.entityID && f.attribute === "block/text", 50 + ); 51 + if (!initialFact) return <pre className="min-h-6" />; 52 + let doc = new Y.Doc(); 53 + const update = base64.toByteArray(initialFact.data.value); 54 + Y.applyUpdate(doc, update); 55 + return ( 56 + <pre className="w-full whitespace-pre-wrap outline-none min-h-6"> 57 + {doc 58 + .getXmlElement("prosemirror") 59 + .toArray() 60 + .map((node, index) => ( 61 + <RenderYJSFragment key={index} node={node} /> 62 + ))} 63 + </pre> 64 + ); 65 + } 66 + export function BaseTextBlock(props: { 67 + entityID: string; 68 + parent: string; 69 + position: string; 70 + previousBlock: { value: string; position: string } | null; 71 + nextPosition: string | null; 72 + }) { 29 73 const [mount, setMount] = useState<HTMLElement | null>(null); 30 74 let value = useYJSValue(props.entityID); 31 75 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); ··· 37 81 useEffect(() => { 38 82 repRef.current = rep.rep; 39 83 }, [rep?.rep]); 40 - let [editorState, setEditorState] = useState( 41 - EditorState.create({ 42 - schema, 43 - plugins: [ 44 - ySyncPlugin(value), 45 - keymap({ 46 - "Meta-b": toggleMark(schema.marks.strong), 47 - "Meta-i": toggleMark(schema.marks.em), 48 - Backspace: (state) => { 49 - if (state.doc.textContent.length === 0) { 50 - repRef.current?.mutate.removeBlock({ 51 - blockEntity: props.entityID, 52 - }); 53 - if (propsRef.current.previousBlock) { 54 - let prevBlock = propsRef.current.previousBlock.value; 55 - document 56 - .getElementById(elementId.block(prevBlock).text) 57 - ?.focus(); 58 - let previousBlockEditor = 59 - useEditorStates.getState()[prevBlock]?.editor; 60 - if (previousBlockEditor) { 61 - let tr = previousBlockEditor.tr; 62 - let endPos = tr.doc.content.size; 63 84 64 - let newState = previousBlockEditor.apply( 65 - tr.setSelection( 66 - TextSelection.create(tr.doc, endPos - 1, endPos - 1), 67 - ), 68 - ); 69 - useEditorStates.setState((s) => ({ 70 - ...s, 71 - [prevBlock]: { editor: newState }, 72 - })); 73 - } 74 - } 75 - } 76 - return false; 77 - }, 78 - "Shift-Enter": () => { 79 - let newEntityID = crypto.randomUUID(); 80 - repRef.current?.mutate.addBlock({ 81 - newEntityID, 82 - parent: props.parent, 83 - position: generateKeyBetween( 84 - propsRef.current.position, 85 - propsRef.current.nextPosition, 86 - ), 87 - }); 88 - setTimeout(() => { 89 - document 90 - .getElementById(elementId.block(newEntityID).text) 91 - ?.focus(); 92 - }, 100); 93 - return true; 94 - }, 95 - }), 96 - keymap(baseKeymap), 97 - ], 98 - }), 99 - ); 85 + let editorState = useEditorStates((s) => s[props.entityID])?.editor; 100 86 useEffect(() => { 101 - useEditorStates.setState((s) => { 102 - return { ...s, [props.entityID]: { editor: editorState } }; 103 - }); 104 - }, [editorState, props.entityID]); 87 + if (!editorState) 88 + useEditorStates.setState((s) => ({ 89 + ...s, 90 + [props.entityID]: { 91 + editor: EditorState.create({ 92 + schema, 93 + plugins: [ 94 + ySyncPlugin(value), 95 + keymap({ 96 + "Meta-b": toggleMark(schema.marks.strong), 97 + "Meta-i": toggleMark(schema.marks.em), 98 + Backspace: (state) => { 99 + if (state.doc.textContent.length === 0) { 100 + repRef.current?.mutate.removeBlock({ 101 + blockEntity: props.entityID, 102 + }); 103 + if (propsRef.current.previousBlock) { 104 + let prevBlock = propsRef.current.previousBlock.value; 105 + document 106 + .getElementById(elementId.block(prevBlock).text) 107 + ?.focus(); 108 + let previousBlockEditor = 109 + useEditorStates.getState()[prevBlock]?.editor; 110 + if (previousBlockEditor) { 111 + let tr = previousBlockEditor.tr; 112 + let endPos = tr.doc.content.size; 105 113 106 - let editorStateFromZustand = useEditorStates((s) => s[props.entityID]); 107 - useEffect(() => { 108 - if (editorStateFromZustand) setEditorState(editorStateFromZustand.editor); 109 - }, [editorStateFromZustand]); 114 + let newState = previousBlockEditor.apply( 115 + tr.setSelection( 116 + TextSelection.create( 117 + tr.doc, 118 + endPos - 1, 119 + endPos - 1, 120 + ), 121 + ), 122 + ); 123 + useEditorStates.setState((s) => ({ 124 + ...s, 125 + [prevBlock]: { editor: newState }, 126 + })); 127 + } 128 + } 129 + } 130 + return false; 131 + }, 132 + "Shift-Enter": () => { 133 + let newEntityID = crypto.randomUUID(); 134 + repRef.current?.mutate.addBlock({ 135 + newEntityID, 136 + parent: props.parent, 137 + position: generateKeyBetween( 138 + propsRef.current.position, 139 + propsRef.current.nextPosition, 140 + ), 141 + }); 142 + setTimeout(() => { 143 + document 144 + .getElementById(elementId.block(newEntityID).text) 145 + ?.focus(); 146 + }, 10); 147 + return true; 148 + }, 149 + }), 150 + keymap(baseKeymap), 151 + ], 152 + }), 153 + }, 154 + })); 155 + }, [editorState, props.entityID, props.parent, value]); 156 + if (!editorState) return null; 110 157 111 158 return ( 112 159 <ProseMirror 113 160 mount={mount} 114 161 state={editorState} 115 162 dispatchTransaction={(tr) => { 116 - setEditorState((s) => s.apply(tr)); 163 + useEditorStates.setState((s) => { 164 + let existingState = s[props.entityID]?.editor; 165 + if (!existingState) return s; 166 + return { 167 + ...s, 168 + [props.entityID]: { editor: existingState.apply(tr) }, 169 + }; 170 + }); 117 171 }} 118 172 > 119 173 <pre ··· 135 189 136 190 if (docStateFromReplicache) { 137 191 const update = base64.toByteArray(docStateFromReplicache.data.value); 138 - Y.applyUpdateV2(ydoc, update); 192 + Y.applyUpdate(ydoc, update); 139 193 } 140 194 141 195 useEffect(() => { 142 196 if (!rep.rep) return; 143 197 const f = async () => { 144 - const update = Y.encodeStateAsUpdateV2(ydoc); 198 + const update = Y.encodeStateAsUpdate(ydoc); 145 199 await rep.rep?.mutate.assertFact({ 146 200 entity: entityID, 147 201 attribute: "block/text",
+4 -1
instrumentation.ts
··· 1 1 export async function register() { 2 - if (process.env.NEXT_RUNTIME === "nodejs") { 2 + if ( 3 + process.env.NEXT_RUNTIME === "nodejs" && 4 + process.env.NODE_ENV === "production" 5 + ) { 3 6 const { BaselimeSDK, VercelPlugin, BetterHttpInstrumentation } = 4 7 //@ts-ignore 5 8 await import("@baselime/node-opentelemetry");
+11 -1
package-lock.json
··· 32 32 "react-dom": "^18.3.1", 33 33 "react-use-measure": "^2.1.1", 34 34 "replicache": "^14.2.2", 35 + "replicache-react": "^5.0.1", 35 36 "y-prosemirror": "^1.2.5", 36 37 "yjs": "^13.6.15", 37 38 "zustand": "^4.5.2" ··· 47 48 "prettier": "3.2.5", 48 49 "supabase": "^1.167.4", 49 50 "tailwindcss": "^3.4.3", 50 - "typescript": "5.4.5", 51 + "typescript": "^5.4.5", 51 52 "wrangler": "^3.56.0" 52 53 } 53 54 }, ··· 11421 11422 }, 11422 11423 "engines": { 11423 11424 "node": ">=14.8.0" 11425 + } 11426 + }, 11427 + "node_modules/replicache-react": { 11428 + "version": "5.0.1", 11429 + "resolved": "https://registry.npmjs.org/replicache-react/-/replicache-react-5.0.1.tgz", 11430 + "integrity": "sha512-xwiVdoANIX9oagqK8sT/txx9ltfEmkKgbJivdVvyz13bj5PAT+b8o9xuMtY4X1bJgu+9mn04muWPKtPv9MV/ZA==", 11431 + "peerDependencies": { 11432 + "react": ">=16.0 <19.0", 11433 + "react-dom": ">=16.0 <19.0" 11424 11434 } 11425 11435 }, 11426 11436 "node_modules/require-directory": {
+2 -1
package.json
··· 34 34 "react-dom": "^18.3.1", 35 35 "react-use-measure": "^2.1.1", 36 36 "replicache": "^14.2.2", 37 + "replicache-react": "^5.0.1", 37 38 "y-prosemirror": "^1.2.5", 38 39 "yjs": "^13.6.15", 39 40 "zustand": "^4.5.2" ··· 49 50 "prettier": "3.2.5", 50 51 "supabase": "^1.167.4", 51 52 "tailwindcss": "^3.4.3", 52 - "typescript": "5.4.5", 53 + "typescript": "^5.4.5", 53 54 "wrangler": "^3.56.0" 54 55 } 55 56 }
+12 -3
replicache/index.tsx
··· 28 28 29 29 let ReplicacheContext = createContext({ 30 30 rep: null as null | Replicache<ReplicacheMutators>, 31 + initialFacts: [] as Fact<keyof typeof Attributes>[], 31 32 }); 32 33 export function useReplicache() { 33 34 return useContext(ReplicacheContext); ··· 39 40 ) => Promise<void>; 40 41 }; 41 42 export function ReplicacheProvider(props: { 43 + initialFacts: Fact<keyof typeof Attributes>[]; 42 44 name: string; 43 45 children: React.ReactNode; 44 46 }) { ··· 97 99 }; 98 100 }, [props.name]); 99 101 return ( 100 - <ReplicacheContext.Provider value={{ rep }}> 102 + <ReplicacheContext.Provider 103 + value={{ rep, initialFacts: props.initialFacts }} 104 + > 101 105 {props.children} 102 106 </ReplicacheContext.Provider> 103 107 ); ··· 111 115 entity: string, 112 116 attribute: A, 113 117 ): CardinalityResult<A> { 114 - let [data, setData] = useState<DeepReadonlyObject<Fact<A>[]>>([]); 115 - let { rep } = useReplicache(); 118 + let { rep, initialFacts } = useReplicache(); 119 + let fallbackData = initialFacts.filter( 120 + (f) => f.entity === entity && f.attribute === attribute, 121 + ); 122 + let [data, setData] = useState<DeepReadonlyObject<Fact<A>[]>>( 123 + fallbackData as DeepReadonlyObject<Fact<A>>[], 124 + ); 116 125 useEffect(() => { 117 126 if (!rep) return; 118 127 return rep.subscribe(