a tool for shared writing and social publishing
at update/reader 207 lines 6.3 kB view raw
1import { schema } from "components/Blocks/TextBlock/schema"; 2import { EditorState, TextSelection } from "prosemirror-state"; 3import { useUIState } from "src/useUIState"; 4import { ToolbarButton } from "."; 5import { useEffect, useState } from "react"; 6import { Separator } from "components/Layout"; 7import { MarkType } from "prosemirror-model"; 8import { setEditorState, useEditorStates } from "src/state/useEditorState"; 9import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 10import { Input } from "components/Input"; 11import { useReplicache } from "src/replicache"; 12import { CheckTiny } from "components/Icons/CheckTiny"; 13import { LinkSmall } from "components/Icons/LinkSmall"; 14 15export function LinkButton(props: { setToolbarState: (s: "link") => void }) { 16 let focusedBlock = useUIState((s) => s.focusedEntity); 17 let focusedEditor = useEditorStates((s) => 18 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 19 ); 20 let isLink; 21 if (focusedEditor) { 22 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection; 23 if ($cursor) isLink = !!schema.marks.link.isInSet($cursor.marks()); 24 if (to !== from) 25 isLink = !!rangeHasMark( 26 focusedEditor.editor, 27 schema.marks.link, 28 from, 29 to, 30 ); 31 } 32 33 return ( 34 <ToolbarButton 35 active={isLink} 36 onClick={(e) => { 37 e.preventDefault(); 38 props.setToolbarState("link"); 39 }} 40 disabled={ 41 !focusedEditor || (focusedEditor?.editor.selection.empty && !isLink) 42 } 43 tooltipContent={ 44 <div className="text-accent-contrast underline">Inline Link</div> 45 } 46 > 47 <LinkSmall /> 48 </ToolbarButton> 49 ); 50} 51 52export function InlineLinkToolbar(props: { onClose: () => void }) { 53 let focusedBlock = useUIState((s) => s.focusedEntity); 54 let focusedEditor = useEditorStates((s) => 55 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 56 ); 57 let { undoManager } = useReplicache(); 58 useEffect(() => { 59 if (focusedEditor) { 60 let isLink; 61 let { to, from, $cursor } = focusedEditor.editor 62 .selection as TextSelection; 63 if ($cursor) isLink = !!schema.marks.link.isInSet($cursor.marks()); 64 if (to !== from) 65 isLink = !!rangeHasMark( 66 focusedEditor.editor, 67 schema.marks.link, 68 from, 69 to, 70 ); 71 if (isLink) return; 72 } 73 if (focusedEditor?.editor.selection.empty) props.onClose(); 74 }, [focusedEditor, props]); 75 let content = ""; 76 let start: number | null = null; 77 let end: number | null = null; 78 if (focusedEditor) { 79 let { to, from, $cursor } = focusedEditor.editor.selection as TextSelection; 80 if (to !== from) { 81 start = from; 82 end = to; 83 } else { 84 let markRange = findMarkRange(focusedEditor.editor, schema.marks.link); 85 start = markRange.start; 86 end = markRange.end; 87 } 88 if ($cursor) { 89 let link = $cursor.marks().find((f) => f.type === schema.marks.link); 90 if (link) { 91 content = link.attrs.href; 92 } 93 } 94 } 95 let [linkValue, setLinkValue] = useState(content); 96 let setLink = () => { 97 let href = 98 !linkValue.startsWith("http") && 99 !linkValue.startsWith("mailto") && 100 !linkValue.startsWith("tel:") 101 ? `https://${linkValue}` 102 : linkValue; 103 104 let editor = focusedEditor?.editor; 105 if (!editor || start === null || !end || !focusedBlock) return; 106 let tr = editor.tr; 107 tr.addMark(start, end, schema.marks.link.create({ href })); 108 tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); 109 110 let oldState = editor; 111 let newState = editor.apply(tr); 112 undoManager.add({ 113 undo: () => { 114 if (!focusedEditor?.view?.hasFocus()) focusedEditor?.view?.focus(); 115 setEditorState(focusedBlock.entityID, { 116 editor: oldState, 117 }); 118 }, 119 redo: () => { 120 if (!focusedEditor?.view?.hasFocus()) focusedEditor?.view?.focus(); 121 setEditorState(focusedBlock.entityID, { 122 editor: newState, 123 }); 124 }, 125 }); 126 setEditorState(focusedBlock?.entityID, { 127 editor: newState, 128 }); 129 props.onClose(); 130 }; 131 132 return ( 133 <div className="w-full flex items-center gap-[6px] grow"> 134 <LinkSmall /> 135 <Separator classname="h-6!" /> 136 <Input 137 autoFocus 138 className="w-full grow bg-transparent border-none outline-hidden " 139 placeholder="www.example.com" 140 value={linkValue} 141 onChange={(e) => setLinkValue(e.target.value)} 142 onKeyDown={(e) => { 143 if (e.key === "Enter") { 144 e.preventDefault(); 145 setLink(); 146 } 147 if (e.key === "Escape") { 148 props.onClose(); 149 } 150 }} 151 /> 152 {/* 153 TODO: 154 to avoid all sort of messiness, editing any portion of link will edit the 155 entire range that includes the link rather than just the link text that is selected. 156 157 ALSO TODO: 158 if there is already a link mark, the input should be prefilled with the link value 159 and the check mark should be a garbage can to remove the link. 160 161 if the user changes the link, then the button reverts to a check mark. 162 */} 163 <div className="flex items-center gap-3 w-4"> 164 <button 165 disabled={!linkValue || linkValue === ""} 166 className="hover:text-accent-contrast -mr-6 disabled:text-border" 167 onMouseDown={(e) => { 168 e.preventDefault(); 169 setLink(); 170 }} 171 > 172 <CheckTiny /> 173 </button> 174 </div> 175 </div> 176 ); 177} 178 179function findMarkRange(state: EditorState, markType: MarkType) { 180 const { from, $from } = state.selection; 181 182 // Find the start of the mark 183 let start = from; 184 let startPos = $from; 185 while ( 186 startPos.parent.inlineContent && 187 startPos.nodeBefore && 188 startPos.nodeBefore.marks.some((mark) => mark.type === markType) 189 ) { 190 start -= startPos.nodeBefore.nodeSize; 191 startPos = state.doc.resolve(start); 192 } 193 194 // Find the end of the mark 195 let end = from; 196 let endPos = $from; 197 while ( 198 endPos.parent.inlineContent && 199 endPos.nodeAfter && 200 endPos.nodeAfter.marks.some((mark) => mark.type === markType) 201 ) { 202 end += endPos.nodeAfter.nodeSize; 203 endPos = state.doc.resolve(end); 204 } 205 206 return { start, end }; 207}