a tool for shared writing and social publishing
0
fork

Configure Feed

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

handle focusing between blocks with keyboard

+174 -68
+3 -2
app/[doc_id]/Blocks.tsx
··· 1 1 "use client"; 2 2 import { useEntity, useReplicache } from "../../replicache"; 3 - import NextImage from "next/image"; 4 3 import { TextBlock } from "../../components/TextBlock"; 5 4 import { generateKeyBetween } from "fractional-indexing"; 6 - import { supabaseBrowserClient } from "../../supabase/browserClient"; 7 5 import { useMemo } from "react"; 8 6 import { addImage } from "../../utils/addImage"; 9 7 export function AddBlock(props: { entityID: string }) { ··· 64 62 parent={props.entityID} 65 63 position={f.data.position} 66 64 previousBlock={arr[index - 1]?.data || null} 65 + nextBlock={arr[index + 1]?.data || null} 67 66 nextPosition={arr[index + 1]?.data.position || null} 68 67 /> 69 68 ); ··· 77 76 parent: string; 78 77 position: string; 79 78 previousBlock: { position: string; value: string } | null; 79 + nextBlock: { position: string; value: string } | null; 80 80 nextPosition: string | null; 81 81 }) { 82 82 let image = useEntity(props.entityID, "block/image"); ··· 97 97 </div> 98 98 <div className="border p-2 w-full"> 99 99 <TextBlock 100 + nextBlock={props.nextBlock} 100 101 parent={props.parent} 101 102 previousBlock={{ value: props.entityID, position: props.position }} 102 103 entityID={virtualBlock}
+170 -66
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 { ProseMirror, useEditorState } from "@nytimes/react-prosemirror"; 7 + import { 8 + ProseMirror, 9 + useEditorEffect, 10 + useEditorState, 11 + } from "@nytimes/react-prosemirror"; 8 12 import * as base64 from "base64-js"; 9 13 import { 10 14 useReplicache, ··· 21 25 nodes: { doc: nodes.doc, paragraph: nodes.paragraph, text: nodes.text }, 22 26 }); 23 27 24 - import { EditorState, TextSelection } from "prosemirror-state"; 28 + import { EditorState, TextSelection, Transaction } from "prosemirror-state"; 29 + import { EditorView } from "prosemirror-view"; 25 30 import { marks, nodes } from "prosemirror-schema-basic"; 26 31 import { ySyncPlugin } from "y-prosemirror"; 27 32 import { Replicache } from "replicache"; ··· 35 40 () => 36 41 ({}) as { 37 42 [entity: string]: 38 - | { editor: InstanceType<typeof EditorState> } 43 + | { 44 + editor: InstanceType<typeof EditorState>; 45 + view?: InstanceType<typeof EditorView>; 46 + } 39 47 | undefined; 40 48 }, 41 49 ); 42 50 51 + const setEditorState = ( 52 + entityID: string, 53 + s: { 54 + editor: InstanceType<typeof EditorState>; 55 + }, 56 + ) => { 57 + useEditorStates.setState((oldState) => { 58 + let existingState = oldState[entityID]; 59 + return { ...oldState, [entityID]: { ...existingState, ...s } }; 60 + }); 61 + }; 62 + 43 63 export function TextBlock(props: { 44 64 entityID: string; 45 65 parent: string; 46 66 position: string; 47 67 previousBlock: { value: string; position: string } | null; 68 + nextBlock: { value: string; position: string } | null; 48 69 nextPosition: string | null; 49 70 }) { 50 71 let initialized = useInitialPageLoad(); ··· 82 103 entityID: string; 83 104 parent: string; 84 105 position: string; 106 + nextBlock: { value: string; position: string } | null; 85 107 previousBlock: { value: string; position: string } | null; 86 108 nextPosition: string | null; 87 109 }) { ··· 100 122 let editorState = useEditorStates((s) => s[props.entityID])?.editor; 101 123 useEffect(() => { 102 124 if (!editorState) 103 - useEditorStates.setState((s) => ({ 104 - ...s, 105 - [props.entityID]: { 106 - editor: EditorState.create({ 107 - schema, 108 - plugins: [ 109 - ySyncPlugin(value), 110 - keymap({ 111 - "Meta-b": toggleMark(schema.marks.strong), 112 - "Meta-i": toggleMark(schema.marks.em), 113 - Backspace: (state) => { 114 - if (state.doc.textContent.length === 0) { 115 - repRef.current?.mutate.removeBlock({ 116 - blockEntity: props.entityID, 117 - }); 118 - if (propsRef.current.previousBlock) { 119 - let prevBlock = propsRef.current.previousBlock.value; 120 - document 121 - .getElementById(elementId.block(prevBlock).text) 122 - ?.focus(); 123 - let previousBlockEditor = 124 - useEditorStates.getState()[prevBlock]?.editor; 125 - if (previousBlockEditor) { 126 - let tr = previousBlockEditor.tr; 127 - let endPos = tr.doc.content.size; 125 + setEditorState(props.entityID, { 126 + editor: EditorState.create({ 127 + schema, 128 + plugins: [ 129 + ySyncPlugin(value), 130 + keymap({ 131 + "Meta-b": toggleMark(schema.marks.strong), 132 + "Meta-i": toggleMark(schema.marks.em), 133 + ArrowUp: (state, tr, view) => { 134 + if (!view) return false; 135 + const viewClientRect = view.dom.getBoundingClientRect(); 136 + const coords = view.coordsAtPos(view.state.selection.anchor); 137 + if (coords.top - viewClientRect.top < 5) { 138 + let block = propsRef.current.previousBlock; 139 + 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; 128 154 129 - let newState = previousBlockEditor.apply( 130 - tr.setSelection( 131 - TextSelection.create( 132 - tr.doc, 133 - endPos - 1, 134 - endPos - 1, 135 - ), 136 - ), 137 - ); 138 - useEditorStates.setState((s) => ({ 139 - ...s, 140 - [prevBlock]: { editor: newState }, 141 - })); 142 - } 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 }); 143 162 } 144 163 } 145 - return false; 146 - }, 147 - "Shift-Enter": () => { 148 - let newEntityID = crypto.randomUUID(); 149 - repRef.current?.mutate.addBlock({ 150 - newEntityID, 151 - parent: props.parent, 152 - position: generateKeyBetween( 153 - propsRef.current.position, 154 - propsRef.current.nextPosition, 155 - ), 156 - }); 157 - setTimeout(() => { 164 + return true; 165 + } 166 + return false; 167 + }, 168 + ArrowDown: (state, tr, view) => { 169 + if (!view) return false; 170 + const viewClientRect = view.dom.getBoundingClientRect(); 171 + const coords = view.coordsAtPos(view.state.selection.anchor); 172 + let isBottom = viewClientRect.bottom - coords.bottom < 5; 173 + if (isBottom) { 174 + let block = propsRef.current.nextBlock; 175 + if (block) { 176 + let nextBlockID = block.value; 158 177 document 159 - .getElementById(elementId.block(newEntityID).text) 178 + .getElementById(elementId.block(nextBlockID).text) 160 179 ?.focus(); 161 - }, 10); 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 + } 199 + } 162 200 return true; 163 - }, 164 - }), 165 - keymap(baseKeymap), 166 - ], 167 - }), 168 - }, 169 - })); 201 + } 202 + return false; 203 + }, 204 + Backspace: (state) => { 205 + if (state.doc.textContent.length === 0) { 206 + repRef.current?.mutate.removeBlock({ 207 + blockEntity: props.entityID, 208 + }); 209 + 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 + } 227 + } 228 + } 229 + return false; 230 + }, 231 + "Shift-Enter": () => { 232 + let newEntityID = crypto.randomUUID(); 233 + repRef.current?.mutate.addBlock({ 234 + newEntityID, 235 + parent: props.parent, 236 + position: generateKeyBetween( 237 + propsRef.current.position, 238 + propsRef.current.nextPosition, 239 + ), 240 + }); 241 + setTimeout(() => { 242 + document 243 + .getElementById(elementId.block(newEntityID).text) 244 + ?.focus(); 245 + }, 10); 246 + return true; 247 + }, 248 + }), 249 + keymap(baseKeymap), 250 + ], 251 + }), 252 + }); 170 253 }, [editorState, props.entityID, props.parent, value]); 171 254 if (!editorState) return null; 172 255 ··· 176 259 state={editorState} 177 260 dispatchTransaction={(tr) => { 178 261 useEditorStates.setState((s) => { 179 - let existingState = s[props.entityID]?.editor; 262 + let existingState = s[props.entityID]; 180 263 if (!existingState) return s; 181 264 return { 182 265 ...s, 183 - [props.entityID]: { editor: existingState.apply(tr) }, 266 + [props.entityID]: { 267 + ...existingState, 268 + editor: existingState.editor.apply(tr), 269 + }, 184 270 }; 185 271 }); 186 272 }} ··· 209 295 className="w-full whitespace-pre-wrap outline-none" 210 296 ref={setMount} 211 297 /> 298 + <SyncView entityID={props.entityID} /> 212 299 </ProseMirror> 213 300 ); 214 301 } 302 + 303 + let SyncView = (props: { entityID: string }) => { 304 + useEditorEffect( 305 + (view) => { 306 + useEditorStates.setState((s) => { 307 + let existingEditor = s[props.entityID]; 308 + if (!existingEditor) return s; 309 + return { 310 + ...s, 311 + [props.entityID]: { ...existingEditor, view }, 312 + }; 313 + }); 314 + }, 315 + [props.entityID], 316 + ); 317 + return null; 318 + }; 215 319 216 320 //I need to get *and* set the value to zustand? 217 321 // This will mean that the value is undefined for a second... Maybe I could use a ref to figure that out?
+1
package-lock.json
··· 27 27 "postgres": "^3.4.4", 28 28 "prosemirror-commands": "^1.5.2", 29 29 "prosemirror-keymap": "^1.2.2", 30 + "prosemirror-model": "^1.21.0", 30 31 "prosemirror-schema-basic": "^1.2.2", 31 32 "prosemirror-state": "^1.4.3", 32 33 "react": "^18.3.1",