a tool for shared writing and social publishing
at main 235 lines 7.0 kB view raw
1import { useLayoutEffect, useRef } from "react"; 2import { EditorState, TextSelection } from "prosemirror-state"; 3import { EditorView } from "prosemirror-view"; 4import { baseKeymap, toggleMark } from "prosemirror-commands"; 5import { keymap } from "prosemirror-keymap"; 6import { ySyncPlugin } from "y-prosemirror"; 7import { schema } from "components/Blocks/TextBlock/schema"; 8import { useReplicache } from "src/replicache"; 9import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 10import { betterIsUrl } from "src/utils/isURL"; 11import { 12 useYJSValue, 13 trackUndoRedo, 14} from "components/Blocks/TextBlock/mountProsemirror"; 15import { DeleteTiny } from "components/Icons/DeleteTiny"; 16import { FootnoteItemLayout } from "./FootnoteItemLayout"; 17import { useEditorStates } from "src/state/useEditorState"; 18import { useUIState } from "src/useUIState"; 19import { useFootnoteContext } from "./FootnoteContext"; 20 21export function FootnoteEditor(props: { 22 footnoteEntityID: string; 23 index: number; 24 editable: boolean; 25 onDelete?: () => void; 26 autoFocus?: boolean; 27}) { 28 let mountRef = useRef<HTMLDivElement | null>(null); 29 let rep = useReplicache(); 30 let value = useYJSValue(props.footnoteEntityID); 31 let actionTimeout = useRef<number | null>(null); 32 let { pageID } = useFootnoteContext(); 33 34 useLayoutEffect(() => { 35 if (!mountRef.current || !value) return; 36 37 let plugins = [ 38 ySyncPlugin(value), 39 keymap({ 40 "Meta-b": toggleMark(schema.marks.strong), 41 "Ctrl-b": toggleMark(schema.marks.strong), 42 "Meta-u": toggleMark(schema.marks.underline), 43 "Ctrl-u": toggleMark(schema.marks.underline), 44 "Meta-i": toggleMark(schema.marks.em), 45 "Ctrl-i": toggleMark(schema.marks.em), 46 "Shift-Enter": (state, dispatch) => { 47 let hardBreak = schema.nodes.hard_break.create(); 48 if (dispatch) { 49 dispatch(state.tr.replaceSelectionWith(hardBreak).scrollIntoView()); 50 } 51 return true; 52 }, 53 Enter: (_state, _dispatch, view) => { 54 view?.dom.blur(); 55 return true; 56 }, 57 }), 58 keymap(baseKeymap), 59 autolink({ 60 type: schema.marks.link, 61 shouldAutoLink: () => true, 62 defaultProtocol: "https", 63 }), 64 ]; 65 66 let state = EditorState.create({ schema, plugins }); 67 let view = new EditorView( 68 { mount: mountRef.current }, 69 { 70 state, 71 editable: () => props.editable, 72 handlePaste: (view, e) => { 73 let text = e.clipboardData?.getData("text"); 74 if (text && betterIsUrl(text)) { 75 let selection = view.state.selection as TextSelection; 76 let tr = view.state.tr; 77 let { from, to } = selection; 78 if (selection.empty) { 79 tr.insertText(text, selection.from); 80 tr.addMark( 81 from, 82 from + text.length, 83 schema.marks.link.create({ href: text }), 84 ); 85 } else { 86 tr.addMark(from, to, schema.marks.link.create({ href: text })); 87 } 88 view.dispatch(tr); 89 return true; 90 } 91 }, 92 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 93 if (!direct) return; 94 if (node.nodeSize - 2 <= _pos) return; 95 const nodeAt1 = node.nodeAt(_pos - 1); 96 const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 97 let linkMark = 98 nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 99 nodeAt2?.marks.find((f) => f.type === schema.marks.link); 100 if (linkMark) { 101 window.open(linkMark.attrs.href, "_blank"); 102 return; 103 } 104 }, 105 dispatchTransaction(this: EditorView, tr) { 106 let oldState = this.state; 107 let newState = this.state.apply(tr); 108 this.updateState(newState); 109 110 useEditorStates.setState((s) => ({ 111 editorStates: { 112 ...s.editorStates, 113 [props.footnoteEntityID]: { 114 editor: newState, 115 view: this, 116 }, 117 }, 118 })); 119 120 trackUndoRedo( 121 tr, 122 rep.undoManager, 123 actionTimeout, 124 () => { 125 this.focus(); 126 this.updateState(oldState); 127 }, 128 () => { 129 this.focus(); 130 this.updateState(newState); 131 }, 132 ); 133 }, 134 }, 135 ); 136 137 // Register editor state 138 useEditorStates.setState((s) => ({ 139 editorStates: { 140 ...s.editorStates, 141 [props.footnoteEntityID]: { 142 editor: view.state, 143 view, 144 }, 145 }, 146 })); 147 148 // Subscribe to external state changes (e.g. link toolbar) 149 let unsubscribe = useEditorStates.subscribe((s) => { 150 let editorState = s.editorStates[props.footnoteEntityID]; 151 if (editorState?.editor) 152 editorState.view?.updateState(editorState.editor); 153 }); 154 155 // Set focusedEntity on focus 156 let handleFocus = () => { 157 useUIState.setState({ 158 focusedEntity: { 159 entityType: "footnote", 160 entityID: props.footnoteEntityID, 161 parent: pageID, 162 }, 163 }); 164 }; 165 view.dom.addEventListener("focus", handleFocus); 166 167 if (props.autoFocus) { 168 setTimeout(() => view.focus(), 50); 169 } 170 171 return () => { 172 unsubscribe(); 173 view.dom.removeEventListener("focus", handleFocus); 174 view.destroy(); 175 useEditorStates.setState((s) => { 176 let { [props.footnoteEntityID]: _, ...rest } = s.editorStates; 177 return { editorStates: rest }; 178 }); 179 }; 180 }, [ 181 props.footnoteEntityID, 182 value, 183 props.editable, 184 props.autoFocus, 185 rep.undoManager, 186 pageID, 187 ]); 188 189 return ( 190 <div data-footnote-editor={props.footnoteEntityID}> 191 <FootnoteItemLayout 192 index={props.index} 193 indexAction={() => { 194 let pm = mountRef.current?.querySelector( 195 ".ProseMirror", 196 ) as HTMLElement | null; 197 if (pm) { 198 pm.focus(); 199 } 200 }} 201 trailing={ 202 props.editable && props.onDelete ? ( 203 <FootnoteDeleteButton 204 footnoteEntityID={props.footnoteEntityID} 205 onDelete={props.onDelete} 206 /> 207 ) : undefined 208 } 209 > 210 <div ref={mountRef} className="outline-hidden" /> 211 </FootnoteItemLayout> 212 </div> 213 ); 214} 215 216function FootnoteDeleteButton(props: { 217 footnoteEntityID: string; 218 onDelete: () => void; 219}) { 220 let isActive = useUIState( 221 (s) => 222 s.focusedEntity?.entityType === "footnote" && 223 s.focusedEntity.entityID === props.footnoteEntityID, 224 ); 225 226 return ( 227 <button 228 className={`shrink-0 mt-0.5 text-tertiary hover:text-accent-contrast transition-opacity ${isActive ? "opacity-100" : "opacity-0"}`} 229 onClick={props.onDelete} 230 title="Delete footnote" 231 > 232 <DeleteTiny /> 233 </button> 234 ); 235}