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