a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add block types and focusing through images!

+239 -154
+171 -44
app/[doc_id]/Blocks.tsx
··· 1 1 "use client"; 2 - import { useEntity, useReplicache } from "../../replicache"; 3 - import { TextBlock } from "../../components/TextBlock"; 2 + import { Fact, useEntity, useReplicache } from "../../replicache"; 3 + import { 4 + TextBlock, 5 + setEditorState, 6 + useEditorStates, 7 + } from "../../components/TextBlock"; 4 8 import { generateKeyBetween } from "fractional-indexing"; 5 - import { useMemo } from "react"; 9 + import { useEffect, useMemo } from "react"; 6 10 import { addImage } from "../../utils/addImage"; 11 + import { create } from "zustand"; 12 + import { combine } from "zustand/middleware"; 13 + import { useSubscribe } from "replicache-react"; 14 + import { elementId } from "../../utils/elementId"; 15 + import { TextSelection } from "prosemirror-state"; 16 + export const useUIState = create( 17 + combine({ selectedBlock: null as null | string }, (set) => ({ 18 + setSelectedBlock: (block: string) => 19 + set((state) => { 20 + return { ...state, selectedBlock: block }; 21 + }), 22 + })), 23 + ); 24 + 7 25 export function AddBlock(props: { entityID: string }) { 8 26 let rep = useReplicache(); 9 27 let blocks = useEntity(props.entityID, "card/block")?.sort((a, b) => { ··· 14 32 onMouseDown={() => { 15 33 rep?.rep?.mutate.addBlock({ 16 34 parent: props.entityID, 35 + type: "text", 17 36 position: generateKeyBetween(null, blocks[0]?.data.position || null), 18 37 newEntityID: crypto.randomUUID(), 19 38 }); ··· 45 64 ); 46 65 } 47 66 67 + export type Block = { 68 + position: string; 69 + value: string; 70 + type: Fact<"block/type">["data"]["value"]; 71 + }; 48 72 export function Blocks(props: { entityID: string }) { 49 - let blocks = useEntity(props.entityID, "card/block"); 73 + let rep = useReplicache(); 74 + let initialValue = rep.initialFacts 75 + .filter((f) => f.attribute === "card/block") 76 + .map((_f) => { 77 + let block = _f as Fact<"card/block">; 78 + let type = rep.initialFacts.find( 79 + (f) => f.entity === block.data.value && f.attribute === "block/type", 80 + ) as Fact<"block/type"> | undefined; 81 + if (!type) return null; 82 + return { ...block.data, type: type.data.value }; 83 + }); 84 + let blocks = 85 + useSubscribe(rep?.rep, async (tx) => { 86 + let blocks = await tx 87 + .scan< 88 + Fact<"card/block"> 89 + >({ indexName: "eav", prefix: `${props.entityID}-card/block` }) 90 + .toArray(); 91 + 92 + return Promise.all( 93 + blocks.map(async (b) => { 94 + let type = ( 95 + await tx 96 + .scan< 97 + Fact<"block/type"> 98 + >({ prefix: `${b.data.value}-block/type`, indexName: "eav" }) 99 + .toArray() 100 + )[0]; 101 + if (!type) return null; 102 + return { ...b.data, type: type.data.value } as Block; 103 + }), 104 + ); 105 + }) || initialValue; 50 106 51 107 return ( 52 108 <div className="mx-auto max-w-3xl flex flex-col gap-1 p-2"> 53 109 {blocks 110 + ?.flatMap((f) => (!f ? [] : [f])) 54 111 ?.sort((a, b) => { 55 - return a.data.position > b.data.position ? 1 : -1; 112 + return a.position > b.position ? 1 : -1; 56 113 }) 57 114 .map((f, index, arr) => { 58 115 return ( 59 116 <Block 60 - key={f.data.value} 61 - entityID={f.data.value} 117 + {...f} 118 + key={f.value} 119 + entityID={f.value} 62 120 parent={props.entityID} 63 - position={f.data.position} 64 - previousBlock={arr[index - 1]?.data || null} 65 - nextBlock={arr[index + 1]?.data || null} 66 - nextPosition={arr[index + 1]?.data.position || null} 121 + previousBlock={arr[index - 1] || null} 122 + nextBlock={arr[index + 1] || null} 123 + nextPosition={arr[index + 1]?.position || null} 67 124 /> 68 125 ); 69 126 })} ··· 71 128 ); 72 129 } 73 130 74 - function Block(props: { 131 + export type BlockProps = { 75 132 entityID: string; 76 133 parent: string; 77 134 position: string; 78 - previousBlock: { position: string; value: string } | null; 79 - nextBlock: { position: string; value: string } | null; 135 + nextBlock: Block | null; 136 + previousBlock: Block | null; 80 137 nextPosition: string | null; 81 - }) { 138 + }; 139 + 140 + function Block(props: Block & BlockProps) { 141 + let selected = useUIState((s) => s.selectedBlock === props.entityID); 142 + return ( 143 + <div className={`border w-full`}> 144 + <div className={`p-2 border ${!selected ? "border-transparent" : ""}`}> 145 + {props.type === "text" ? ( 146 + <TextBlock {...props} /> 147 + ) : ( 148 + <ImageBlock {...props} /> 149 + )} 150 + </div> 151 + </div> 152 + ); 153 + } 154 + 155 + function ImageBlock(props: BlockProps) { 156 + let rep = useReplicache(); 82 157 let image = useEntity(props.entityID, "block/image"); 83 - let virtualBlock = useMemo(() => { 84 - return crypto.randomUUID(); 85 - }, []); 158 + let selected = useUIState((s) => s.selectedBlock === props.entityID); 159 + useEffect(() => { 160 + if (!selected || !rep.rep) return; 161 + let r = rep.rep; 162 + let listener = (e: KeyboardEvent) => { 163 + if (e.defaultPrevented) return; 164 + if (e.key === "ArrowDown") { 165 + e.preventDefault(); 166 + let block = props.nextBlock; 167 + if (block) 168 + focusBlock(block, useEditorStates.getState().lastXPosition, "top"); 169 + if (!block) return; 170 + } 171 + if (e.key === "ArrowUp") { 172 + e.preventDefault(); 173 + let block = props.previousBlock; 174 + if (block) 175 + focusBlock(block, useEditorStates.getState().lastXPosition, "bottom"); 176 + if (!block) return; 177 + } 178 + if (e.key === "Enter") { 179 + let newEntityID = crypto.randomUUID(); 180 + r.mutate.addBlock({ 181 + newEntityID, 182 + parent: props.parent, 183 + type: "text", 184 + position: generateKeyBetween(props.position, props.nextPosition), 185 + }); 186 + setTimeout(() => { 187 + document.getElementById(elementId.block(newEntityID).text)?.focus(); 188 + }, 10); 189 + } 190 + }; 191 + window.addEventListener("keydown", listener); 192 + return () => window.removeEventListener("keydown", listener); 193 + }, [ 194 + selected, 195 + props.nextBlock, 196 + props.previousBlock, 197 + props.position, 198 + props.nextPosition, 199 + rep, 200 + props.parent, 201 + ]); 86 202 87 - if (image) 88 - return ( 89 - <> 90 - <div className="border p-2 w-full"> 91 - <img 92 - alt={""} 93 - src={image.data.src} 94 - height={image.data.height} 95 - width={image.data.width} 96 - /> 97 - </div> 98 - <div className="border p-2 w-full"> 99 - <TextBlock 100 - nextBlock={props.nextBlock} 101 - parent={props.parent} 102 - previousBlock={{ value: props.entityID, position: props.position }} 103 - entityID={virtualBlock} 104 - nextPosition={props.nextPosition} 105 - position={generateKeyBetween(props.position, props.nextPosition)} 106 - /> 107 - </div> 108 - </> 109 - ); 110 203 return ( 111 - <div className="border p-2 w-full"> 112 - <TextBlock {...props} /> 113 - </div> 204 + <img 205 + alt={""} 206 + src={image?.data.src} 207 + height={image?.data.height} 208 + width={image?.data.width} 209 + /> 210 + ); 211 + } 212 + 213 + export function focusBlock( 214 + block: Block, 215 + left: number | "end", 216 + top: "top" | "bottom", 217 + ) { 218 + if (block.type === "image") { 219 + useUIState.getState().setSelectedBlock(block.value); 220 + return true; 221 + } 222 + let nextBlockID = block.value; 223 + let nextBlock = useEditorStates.getState().editorStates[nextBlockID]; 224 + if (!nextBlock || !nextBlock.view) return; 225 + nextBlock.view.focus(); 226 + let nextBlockViewClientRect = nextBlock.view.dom.getBoundingClientRect(); 227 + let tr = nextBlock.editor.tr; 228 + if (left === "end") left = tr.doc.content.size - 1; 229 + let pos = nextBlock.view.posAtCoords({ 230 + top: 231 + top === "top" 232 + ? nextBlockViewClientRect.top + 5 233 + : nextBlockViewClientRect.bottom - 5, 234 + left, 235 + }); 236 + 237 + let newState = nextBlock.editor.apply( 238 + tr.setSelection(TextSelection.create(tr.doc, pos?.pos || 0)), 114 239 ); 240 + 241 + setEditorState(nextBlockID, { editor: newState }); 115 242 }
+56 -110
components/TextBlock.tsx
··· 4 4 import { keymap } from "prosemirror-keymap"; 5 5 import { Schema } from "prosemirror-model"; 6 6 import * as Y from "yjs"; 7 - import { 8 - ProseMirror, 9 - useEditorEffect, 10 - useEditorState, 11 - } from "@nytimes/react-prosemirror"; 7 + import { ProseMirror, useEditorEffect } from "@nytimes/react-prosemirror"; 12 8 import * as base64 from "base64-js"; 13 9 import { 14 10 useReplicache, ··· 25 21 nodes: { doc: nodes.doc, paragraph: nodes.paragraph, text: nodes.text }, 26 22 }); 27 23 28 - import { EditorState, TextSelection, Transaction } from "prosemirror-state"; 24 + import { EditorState, TextSelection } from "prosemirror-state"; 29 25 import { EditorView } from "prosemirror-view"; 30 26 import { marks, nodes } from "prosemirror-schema-basic"; 31 27 import { ySyncPlugin } from "y-prosemirror"; ··· 35 31 import { RenderYJSFragment } from "./RenderYJSFragment"; 36 32 import { useInitialPageLoad } from "./InitialPageLoadProvider"; 37 33 import { addImage } from "../utils/addImage"; 34 + import { BlockProps, focusBlock, useUIState } from "../app/[doc_id]/Blocks"; 38 35 39 - let useEditorStates = create( 40 - () => 41 - ({}) as { 42 - [entity: string]: 43 - | { 44 - editor: InstanceType<typeof EditorState>; 45 - view?: InstanceType<typeof EditorView>; 46 - } 47 - | undefined; 48 - }, 49 - ); 36 + export let useEditorStates = create(() => ({ 37 + lastXPosition: 0, 38 + editorStates: {} as { 39 + [entity: string]: 40 + | { 41 + editor: InstanceType<typeof EditorState>; 42 + view?: InstanceType<typeof EditorView>; 43 + } 44 + | undefined; 45 + }, 46 + })); 50 47 51 - const setEditorState = ( 48 + export const setEditorState = ( 52 49 entityID: string, 53 50 s: { 54 51 editor: InstanceType<typeof EditorState>; 55 52 }, 56 53 ) => { 57 54 useEditorStates.setState((oldState) => { 58 - let existingState = oldState[entityID]; 59 - return { ...oldState, [entityID]: { ...existingState, ...s } }; 55 + let existingState = oldState.editorStates[entityID]; 56 + return { 57 + editorStates: { 58 + ...oldState.editorStates, 59 + [entityID]: { ...existingState, ...s }, 60 + }, 61 + }; 60 62 }); 61 63 }; 62 64 63 - export function TextBlock(props: { 64 - entityID: string; 65 - parent: string; 66 - position: string; 67 - previousBlock: { value: string; position: string } | null; 68 - nextBlock: { value: string; position: string } | null; 69 - nextPosition: string | null; 70 - }) { 65 + export function TextBlock(props: BlockProps) { 71 66 let initialized = useInitialPageLoad(); 72 67 return ( 73 68 <> ··· 99 94 </pre> 100 95 ); 101 96 } 102 - export function BaseTextBlock(props: { 103 - entityID: string; 104 - parent: string; 105 - position: string; 106 - nextBlock: { value: string; position: string } | null; 107 - previousBlock: { value: string; position: string } | null; 108 - nextPosition: string | null; 109 - }) { 97 + export function BaseTextBlock(props: BlockProps) { 110 98 const [mount, setMount] = useState<HTMLElement | null>(null); 111 99 let value = useYJSValue(props.entityID); 112 100 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); ··· 119 107 repRef.current = rep.rep; 120 108 }, [rep?.rep]); 121 109 122 - let editorState = useEditorStates((s) => s[props.entityID])?.editor; 110 + let editorState = useEditorStates( 111 + (s) => s.editorStates[props.entityID], 112 + )?.editor; 123 113 useEffect(() => { 124 114 if (!editorState) 125 115 setEditorState(props.entityID, { ··· 130 120 keymap({ 131 121 "Meta-b": toggleMark(schema.marks.strong), 132 122 "Meta-i": toggleMark(schema.marks.em), 133 - ArrowUp: (state, tr, view) => { 123 + ArrowUp: (_state, _tr, view) => { 134 124 if (!view) return false; 135 125 const viewClientRect = view.dom.getBoundingClientRect(); 136 126 const coords = view.coordsAtPos(view.state.selection.anchor); 137 127 if (coords.top - viewClientRect.top < 5) { 138 128 let block = propsRef.current.previousBlock; 139 129 if (block) { 140 - let nextBlockID = block.value; 141 - document 142 - .getElementById(elementId.block(nextBlockID).text) 143 - ?.focus(); 144 - let nextBlock = useEditorStates.getState()[nextBlockID]; 145 - if (nextBlock && nextBlock.view) { 146 - // I need to somehow get the view here 147 - let nextBlockViewClientRect = 148 - nextBlock.view.dom.getBoundingClientRect(); 149 - let pos = nextBlock.view.posAtCoords({ 150 - top: nextBlockViewClientRect.bottom - 8, 151 - left: coords.left, 152 - }); 153 - let tr = nextBlock.editor.tr; 154 - 155 - let newState = nextBlock.editor.apply( 156 - tr.setSelection( 157 - TextSelection.create(tr.doc, pos?.pos || 0), 158 - ), 159 - ); 160 - 161 - setEditorState(nextBlockID, { editor: newState }); 162 - } 130 + view.dom.blur(); 131 + focusBlock(block, coords.left, "bottom"); 163 132 } 164 133 return true; 165 134 } 166 135 return false; 167 136 }, 168 137 ArrowDown: (state, tr, view) => { 169 - if (!view) return false; 138 + if (!view) return true; 170 139 const viewClientRect = view.dom.getBoundingClientRect(); 171 140 const coords = view.coordsAtPos(view.state.selection.anchor); 172 141 let isBottom = viewClientRect.bottom - coords.bottom < 5; 173 142 if (isBottom) { 174 143 let block = propsRef.current.nextBlock; 175 144 if (block) { 176 - let nextBlockID = block.value; 177 - document 178 - .getElementById(elementId.block(nextBlockID).text) 179 - ?.focus(); 180 - let nextBlock = useEditorStates.getState()[nextBlockID]; 181 - if (nextBlock && nextBlock.view) { 182 - // I need to somehow get the view here 183 - let nextBlockViewClientRect = 184 - nextBlock.view.dom.getBoundingClientRect(); 185 - let pos = nextBlock.view.posAtCoords({ 186 - top: nextBlockViewClientRect.top, 187 - left: coords.left, 188 - }); 189 - let tr = nextBlock.editor.tr; 190 - 191 - let newState = nextBlock.editor.apply( 192 - tr.setSelection( 193 - TextSelection.create(tr.doc, pos?.pos || 0), 194 - ), 195 - ); 196 - 197 - setEditorState(nextBlockID, { editor: newState }); 198 - } 145 + view.dom.blur(); 146 + focusBlock(block, coords.left, "top"); 199 147 } 200 148 return true; 201 149 } ··· 207 155 blockEntity: props.entityID, 208 156 }); 209 157 if (propsRef.current.previousBlock) { 210 - let prevBlock = propsRef.current.previousBlock.value; 211 - document 212 - .getElementById(elementId.block(prevBlock).text) 213 - ?.focus(); 214 - let previousBlockEditor = 215 - useEditorStates.getState()[prevBlock]?.editor; 216 - if (previousBlockEditor) { 217 - let tr = previousBlockEditor.tr; 218 - let endPos = tr.doc.content.size; 219 - 220 - let newState = previousBlockEditor.apply( 221 - tr.setSelection( 222 - TextSelection.create(tr.doc, endPos - 1, endPos - 1), 223 - ), 224 - ); 225 - setEditorState(prevBlock, { editor: newState }); 226 - } 158 + focusBlock(propsRef.current.previousBlock, "end", "bottom"); 227 159 } 228 160 } 229 161 return false; ··· 233 165 repRef.current?.mutate.addBlock({ 234 166 newEntityID, 235 167 parent: props.parent, 168 + type: "text", 236 169 position: generateKeyBetween( 237 170 propsRef.current.position, 238 171 propsRef.current.nextPosition, ··· 259 192 state={editorState} 260 193 dispatchTransaction={(tr) => { 261 194 useEditorStates.setState((s) => { 262 - let existingState = s[props.entityID]; 195 + let existingState = s.editorStates[props.entityID]; 263 196 if (!existingState) return s; 264 197 return { 265 - ...s, 266 - [props.entityID]: { 267 - ...existingState, 268 - editor: existingState.editor.apply(tr), 198 + editorStates: { 199 + ...s.editorStates, 200 + [props.entityID]: { 201 + ...existingState, 202 + editor: existingState.editor.apply(tr), 203 + }, 269 204 }, 270 205 }; 271 206 }); 272 207 }} 273 208 > 274 209 <pre 210 + onFocus={() => { 211 + useUIState.getState().setSelectedBlock(props.entityID); 212 + }} 213 + onKeyDown={(e) => {}} 275 214 onPaste={(e) => { 276 215 if (!rep.rep) return; 277 216 for (let item of e.clipboardData.items) { ··· 301 240 } 302 241 303 242 let SyncView = (props: { entityID: string }) => { 243 + useEditorEffect((view) => { 244 + if (!view.hasFocus()) return; 245 + const coords = view.coordsAtPos(view.state.selection.anchor); 246 + useEditorStates.setState({ lastXPosition: coords.left }); 247 + }); 304 248 useEditorEffect( 305 249 (view) => { 306 250 useEditorStates.setState((s) => { 307 - let existingEditor = s[props.entityID]; 251 + let existingEditor = s.editorStates[props.entityID]; 308 252 if (!existingEditor) return s; 309 253 return { 310 - ...s, 311 - [props.entityID]: { ...existingEditor, view }, 254 + editorStates: { 255 + ...s.editorStates, 256 + [props.entityID]: { ...existingEditor, view }, 257 + }, 312 258 }; 313 259 }); 314 260 },
+4
replicache/attributes.ts
··· 3 3 type: "ordered-reference", 4 4 cardinality: "many", 5 5 }, 6 + "block/type": { 7 + type: "block-type-union", 8 + cardinality: "one", 9 + }, 6 10 "block/position": { 7 11 type: "text", 8 12 cardinality: "one",
+1
replicache/index.tsx
··· 25 25 }; 26 26 image: { type: "image"; src: string; height: number; width: number }; 27 27 reference: { type: "reference"; value: string }; 28 + "block-type-union": { type: "block-type-union"; value: "text" | "image" }; 28 29 }[(typeof Attributes)[A]["type"]]; 29 30 30 31 let ReplicacheContext = createContext({
+6
replicache/mutations.ts
··· 20 20 21 21 const addBlock: Mutation<{ 22 22 parent: string; 23 + type: Fact<"block/type">["data"]["value"]; 23 24 newEntityID: string; 24 25 position: string; 25 26 }> = async (args, ctx) => { ··· 32 33 position: args.position, 33 34 }, 34 35 attribute: "card/block", 36 + }); 37 + await ctx.assertFact({ 38 + entity: args.newEntityID, 39 + data: { type: "block-type-union", value: args.type }, 40 + attribute: "block/type", 35 41 }); 36 42 }; 37 43
+1
utils/addImage.ts
··· 24 24 ); 25 25 let newBlockEntity = crypto.randomUUID(); 26 26 await rep.mutate.addBlock({ 27 + type: "image", 27 28 parent: args.parent, 28 29 position: args.position, 29 30 newEntityID: newBlockEntity,