a tool for shared writing and social publishing

fix selection on iOS! I think!

This was a ton of work from Celine and I, eventually the solution we
seem to have settled on involves capturing clicks with a block floating
on top of the textblock and then triggering a focus into the textblock
seperately. We also need to check if the textblock becomes hidden by the
keyboard coming up and then scroll if neccessary.

+88 -79
-3
app/[doc_id]/layout.tsx
··· 6 6 export default function Layout(props: { children: React.ReactNode }) { 7 7 let viewheight = useViewportSize().height; 8 8 let difference = useViewportDifference(); 9 - 10 - usePreventScroll(); 11 - 12 9 return <div style={{ height: viewheight || "100%" }}>{props.children}</div>; 13 10 } 14 11
+2 -5
components/Blocks.tsx
··· 1 1 "use client"; 2 2 import { Fact, useEntity, useReplicache } from "src/replicache"; 3 - import { 4 - TextBlock, 5 - setEditorState, 6 - useEditorStates, 7 - } from "components/TextBlock"; 3 + import { TextBlock } from "components/TextBlock"; 8 4 import { generateKeyBetween } from "fractional-indexing"; 9 5 import { useEffect } from "react"; 10 6 import { elementId } from "src/utils/elementId"; ··· 16 12 import { ExternalLinkBlock } from "./ExternalLinkBlock"; 17 13 import { BlockOptions } from "./BlockOptions"; 18 14 import { useBlocks } from "src/hooks/queries/useBlocks"; 15 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 19 16 20 17 export type Block = { 21 18 parent: string;
+2 -1
components/Cards.tsx
··· 77 77 className={` 78 78 card w-[calc(100vw-12px)] md:w-[calc(50vw-32px)] max-w-prose 79 79 grow flex flex-col 80 + overscroll-none 80 81 overflow-y-scroll no-scrollbar 81 82 rounded-lg border 82 83 ${isFocused ? "shadow-md border-border" : "border-border-light"} ··· 209 210 }); 210 211 211 212 if (firstBlock) { 212 - focusBlock(firstBlock, "start", "top"); 213 + focusBlock(firstBlock, { type: "start" }); 213 214 } 214 215 } 215 216
+11 -6
components/SelectionManager.tsx
··· 5 5 import { useUIState } from "src/useUIState"; 6 6 import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 7 7 import { scanIndex } from "src/replicache/utils"; 8 - import { useEditorStates } from "./TextBlock"; 9 8 import { focusBlock } from "./Blocks"; 9 + import { useEditorStates } from "src/state/useEditorState"; 10 10 export const useSelectingMouse = create(() => ({ 11 11 start: null as null | { top: number; left: number }, 12 12 })); ··· 63 63 useUIState.getState().setSelectedBlock(firstBlock); 64 64 focusBlock( 65 65 { ...firstBlock, type: type[0].data.value }, 66 - "start", 67 - "top", 66 + { type: "start" }, 68 67 ); 69 68 } else { 70 69 if ( ··· 130 129 ); 131 130 if (!type?.[0]) return; 132 131 useUIState.getState().setSelectedBlock(firstBlock); 133 - focusBlock({ ...firstBlock, type: type[0].data.value }, "start", "top"); 132 + focusBlock( 133 + { ...firstBlock, type: type[0].data.value }, 134 + { type: "start" }, 135 + ); 134 136 } 135 137 if (e.key === "ArrowRight") { 136 138 let selectedBlocks = useUIState ··· 144 146 ); 145 147 if (!type?.[0]) return; 146 148 useUIState.getState().setSelectedBlock(lastBlock); 147 - focusBlock({ ...lastBlock, type: type[0].data.value }, "end", "top"); 149 + focusBlock({ ...lastBlock, type: type[0].data.value }, { type: "end" }); 148 150 } 149 151 if (e.key === "ArrowDown") { 150 152 let selectedBlocks = useUIState ··· 160 162 ); 161 163 if (!type?.[0]) return; 162 164 useUIState.getState().setSelectedBlock(lastBlock); 163 - focusBlock({ ...lastBlock, type: type[0].data.value }, "end", "top"); 165 + focusBlock( 166 + { ...lastBlock, type: type[0].data.value }, 167 + { type: "end" }, 168 + ); 164 169 } 165 170 if (e.shiftKey) { 166 171 if (
+40 -46
components/TextBlock/index.tsx
··· 15 15 ReplicacheMutators, 16 16 Fact, 17 17 } from "src/replicache"; 18 + import { isVisible } from "src/utils/isVisible"; 18 19 19 20 import { EditorState } from "prosemirror-state"; 20 - import { EditorView } from "prosemirror-view"; 21 21 import { ySyncPlugin } from "y-prosemirror"; 22 22 import { Replicache } from "replicache"; 23 23 import { generateKeyBetween } from "fractional-indexing"; 24 - import { create } from "zustand"; 25 24 import { RenderYJSFragment } from "./RenderYJSFragment"; 26 25 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 27 26 import { addImage } from "src/utils/addImage"; ··· 33 32 import { useAppEventListener } from "src/eventBus"; 34 33 import { addLinkBlock } from "src/utils/addLinkBlock"; 35 34 import { BlockOptions } from "components/BlockOptions"; 36 - 37 - export let useEditorStates = create(() => ({ 38 - lastXPosition: 0, 39 - editorStates: {} as { 40 - [entity: string]: 41 - | { 42 - editor: InstanceType<typeof EditorState>; 43 - view?: InstanceType<typeof EditorView>; 44 - } 45 - | undefined; 46 - }, 47 - })); 48 - 49 - export const setEditorState = ( 50 - entityID: string, 51 - s: { 52 - editor: InstanceType<typeof EditorState>; 53 - }, 54 - ) => { 55 - useEditorStates.setState((oldState) => { 56 - let existingState = oldState.editorStates[entityID]; 57 - return { 58 - editorStates: { 59 - ...oldState.editorStates, 60 - [entityID]: { ...existingState, ...s }, 61 - }, 62 - }; 63 - }); 64 - }; 35 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 36 + import { isIOS } from "@react-aria/utils"; 65 37 66 38 export function TextBlock(props: BlockProps & { className: string }) { 67 39 let initialized = useInitialPageLoad(); ··· 77 49 /> 78 50 )} 79 51 <div className={`relative group/text ${!initialized ? "hidden" : ""}`}> 52 + <IOSBS {...props} /> 80 53 <BaseTextBlock {...props} /> 81 54 </div> 82 55 </> 83 56 ); 84 57 } 85 58 59 + export function IOSBS(props: BlockProps) { 60 + let selected = useUIState((s) => 61 + s.selectedBlock.find((b) => b.value === props.entityID), 62 + ); 63 + if (selected) return null; 64 + return ( 65 + <div 66 + style={{ display: isIOS() ? "none" : undefined }} 67 + className="h-full w-full absolute cursor-text" 68 + onMouseDown={(e) => { 69 + e.preventDefault(); 70 + let target = e.target; 71 + focusBlock(props, { 72 + type: "coord", 73 + top: e.clientY, 74 + left: e.clientX, 75 + }); 76 + setTimeout(async () => { 77 + let vis = await isVisible(target as Element); 78 + if (!vis) { 79 + let parentEl = document.getElementById( 80 + elementId.card(props.parent).container, 81 + ); 82 + if (!parentEl) return; 83 + parentEl?.scrollBy({ 84 + top: 250, 85 + behavior: "smooth", 86 + }); 87 + } 88 + }, 600); 89 + }} 90 + /> 91 + ); 92 + } 93 + 86 94 export function RenderedTextBlock(props: { 87 95 entityID: string; 88 96 className?: string; ··· 262 270 parent: propsRef.current.parent, 263 271 position: p, 264 272 }, 265 - "end", 266 - "bottom", 273 + { type: "end" }, 267 274 ); 268 275 } 269 276 }, 10); ··· 444 451 let cursorPosY = coords.top; 445 452 let bottomScrollPadding = 100; 446 453 if (cursorPosY && parentHeight) { 447 - if (cursorPosY > parentHeight - bottomScrollPadding) { 448 - parentID?.scrollBy({ 449 - top: bottomScrollPadding - (parentHeight - cursorPosY), 450 - behavior: "smooth", 451 - }); 452 - } 453 - if (cursorPosY < 50) { 454 - if (parentID?.scrollTop === 0) return; 455 - parentID?.scrollBy({ 456 - top: cursorPosY - 50, 457 - behavior: "smooth", 458 - }); 459 - } 460 454 } 461 455 }, 10); 462 456 });
+28 -15
components/TextBlock/keymap.ts
··· 7 7 import { Replicache } from "replicache"; 8 8 import { ReplicacheMutators } from "src/replicache"; 9 9 import { elementId } from "src/utils/elementId"; 10 - import { setEditorState, useEditorStates } from "."; 11 10 import { schema } from "./schema"; 12 11 import { useUIState } from "src/useUIState"; 12 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 13 13 14 14 export const TextBlockKeymap = ( 15 15 propsRef: MutableRefObject<BlockProps>, ··· 19 19 "Meta-b": toggleMark(schema.marks.strong), 20 20 "Meta-u": toggleMark(schema.marks.underline), 21 21 "Meta-i": toggleMark(schema.marks.em), 22 + Escape: (_state, _dispatch, view) => { 23 + view?.dom.blur(); 24 + 25 + return true; 26 + }, 22 27 "#": (state, dispatch, view) => { 23 28 if (state.selection.content().size > 0) return false; 24 29 if (state.selection.anchor > 1) return false; 25 30 repRef.current?.mutate.increaseHeadingLevel({ 26 31 entityID: propsRef.current.entityID, 27 32 }); 33 + 28 34 setTimeout( 29 35 () => 30 36 focusBlock( ··· 34 40 position: propsRef.current.position, 35 41 parent: propsRef.current.parent, 36 42 }, 37 - "start", 38 - "bottom", 43 + { type: "start" }, 39 44 ), 40 45 10, 41 46 ); ··· 96 101 let block = propsRef.current.previousBlock; 97 102 if (block) { 98 103 view.dom.blur(); 99 - focusBlock(block, coords.left, "bottom"); 104 + focusBlock(block, { left: coords.left, type: "bottom" }); 100 105 return true; 101 106 } 102 107 return false; ··· 114 119 let block = propsRef.current.nextBlock; 115 120 if (block) { 116 121 view.dom.blur(); 117 - focusBlock(block, coords.left, "top"); 122 + focusBlock(block, { left: coords.left, type: "top" }); 118 123 return true; 119 124 } 120 125 return false; ··· 127 132 let block = propsRef.current.previousBlock; 128 133 if (block) { 129 134 view?.dom.blur(); 130 - focusBlock(block, "end", "top"); 135 + focusBlock(block, { type: "end" }); 131 136 } 132 137 return true; 133 138 }, ··· 137 142 let block = propsRef.current.nextBlock; 138 143 if (block) { 139 144 view?.dom.blur(); 140 - focusBlock(block, "start", "top"); 145 + focusBlock(block, { type: "start" }); 141 146 } 142 147 return true; 143 148 }, ··· 172 177 position: propsRef.current.position, 173 178 parent: propsRef.current.parent, 174 179 }, 175 - "start", 176 - "bottom", 180 + { type: "start" }, 177 181 ), 178 182 10, 179 183 ); ··· 186 190 blockEntity: propsRef.current.entityID, 187 191 }); 188 192 if (propsRef.current.previousBlock) { 189 - focusBlock(propsRef.current.previousBlock, "end", "bottom"); 193 + focusBlock(propsRef.current.previousBlock, { type: "end" }); 190 194 } 191 195 return true; 192 196 } ··· 234 238 tr.delete(state.selection.anchor, state.doc.content.size); 235 239 dispatch?.(tr); 236 240 let newEntityID = crypto.randomUUID(); 241 + let position = generateKeyBetween( 242 + propsRef.current.position, 243 + propsRef.current.nextPosition, 244 + ); 237 245 repRef.current?.mutate.addBlock({ 238 246 newEntityID, 239 247 parent: propsRef.current.parent, 240 248 type: "text", 241 - position: generateKeyBetween( 242 - propsRef.current.position, 243 - propsRef.current.nextPosition, 244 - ), 249 + position, 245 250 }); 246 251 setTimeout(() => { 247 252 let block = useEditorStates.getState().editorStates[newEntityID]; ··· 255 260 editor: newState, 256 261 }); 257 262 } 263 + focusBlock( 264 + { 265 + value: newEntityID, 266 + parent: propsRef.current.parent, 267 + type: "text", 268 + position, 269 + }, 270 + { type: "start" }, 271 + ); 258 272 } 259 - document.getElementById(elementId.block(newEntityID).text)?.focus(); 260 273 }, 10); 261 274 return true; 262 275 };
+1 -1
components/Toolbar/LinkButton.tsx
··· 1 - import { setEditorState, useEditorStates } from "components/TextBlock"; 2 1 import { schema } from "components/TextBlock/schema"; 3 2 import { EditorState, TextSelection } from "prosemirror-state"; 4 3 import { useUIState } from "src/useUIState"; ··· 7 6 import { useState } from "react"; 8 7 import { Separator } from "components/Layout"; 9 8 import { MarkType } from "prosemirror-model"; 9 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 10 10 11 11 export function LinkButton(props: { setToolBarState: (s: "link") => void }) { 12 12 let focusedBlock = useUIState((s) => s.focusedBlock);
+1 -1
components/Toolbar/TextBlockTypeButtons.tsx
··· 5 5 ParagraphSmall, 6 6 } from "components/Icons"; 7 7 import { Separator } from "components/Layout"; 8 - import { setEditorState, useEditorStates } from "components/TextBlock"; 9 8 import { CloseToolbarButton, ToolbarButton } from "components/Toolbar"; 10 9 import { TextSelection } from "prosemirror-state"; 11 10 import { useCallback } from "react"; 12 11 import { useEntity, useReplicache } from "src/replicache"; 12 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 13 13 import { useUIState } from "src/useUIState"; 14 14 export const TextBlockTypeButtons = (props: { onClose: () => void }) => { 15 15 let focusedBlock = useUIState((s) => s.focusedBlock);
+1 -1
components/Toolbar/TextDecorationButton.tsx
··· 1 - import { useEditorStates } from "components/TextBlock"; 2 1 import { MarkType } from "prosemirror-model"; 3 2 import { useUIState } from "src/useUIState"; 4 3 import { ToolbarButton } from "."; 5 4 import { toggleMark } from "prosemirror-commands"; 6 5 import { TextSelection } from "prosemirror-state"; 7 6 import { publishAppEvent } from "src/eventBus"; 7 + import { useEditorStates } from "src/state/useEditorState"; 8 8 9 9 export function TextDecorationButton(props: { 10 10 mark: MarkType;
+1
package-lock.json
··· 13 13 "@nytimes/react-prosemirror": "^0.6.1", 14 14 "@radix-ui/react-popover": "^1.0.7", 15 15 "@radix-ui/react-slider": "^1.1.2", 16 + "@react-aria/utils": "^3.24.1", 16 17 "@react-spring/web": "^9.7.3", 17 18 "@supabase/ssr": "^0.3.0", 18 19 "@supabase/supabase-js": "^2.43.2",
+1
package.json
··· 15 15 "@nytimes/react-prosemirror": "^0.6.1", 16 16 "@radix-ui/react-popover": "^1.0.7", 17 17 "@radix-ui/react-slider": "^1.1.2", 18 + "@react-aria/utils": "^3.24.1", 18 19 "@react-spring/web": "^9.7.3", 19 20 "@supabase/ssr": "^0.3.0", 20 21 "@supabase/supabase-js": "^2.43.2",