👁️
5
fork

Configure Feed

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

dark mode, link edit modal

+293 -46
+149
src/components/richtext/LinkModal.tsx
··· 1 + import { X } from "lucide-react"; 2 + import { useCallback, useEffect, useId, useRef, useState } from "react"; 3 + 4 + export interface LinkModalProps { 5 + isOpen: boolean; 6 + onClose: () => void; 7 + onSubmit: (url: string, text?: string) => void; 8 + initialUrl?: string; 9 + initialText?: string; 10 + showTextInput?: boolean; 11 + } 12 + 13 + export function LinkModal({ 14 + isOpen, 15 + onClose, 16 + onSubmit, 17 + initialUrl = "", 18 + initialText = "", 19 + showTextInput = false, 20 + }: LinkModalProps) { 21 + const [url, setUrl] = useState(initialUrl); 22 + const [text, setText] = useState(initialText); 23 + const urlInputRef = useRef<HTMLInputElement>(null); 24 + const id = useId(); 25 + const urlId = `${id}-url`; 26 + const textId = `${id}-text`; 27 + 28 + useEffect(() => { 29 + if (isOpen) { 30 + setUrl(initialUrl); 31 + setText(initialText); 32 + setTimeout(() => urlInputRef.current?.focus(), 0); 33 + } 34 + }, [isOpen, initialUrl, initialText]); 35 + 36 + const handleSubmit = useCallback( 37 + (e: React.FormEvent) => { 38 + e.preventDefault(); 39 + if (!url.trim()) return; 40 + onSubmit( 41 + url.trim(), 42 + showTextInput ? text.trim() || url.trim() : undefined, 43 + ); 44 + onClose(); 45 + }, 46 + [url, text, showTextInput, onSubmit, onClose], 47 + ); 48 + 49 + const handleKeyDown = useCallback( 50 + (e: React.KeyboardEvent) => { 51 + if (e.key === "Escape") { 52 + onClose(); 53 + } 54 + }, 55 + [onClose], 56 + ); 57 + 58 + if (!isOpen) return null; 59 + 60 + return ( 61 + <div 62 + role="dialog" 63 + aria-modal="true" 64 + aria-labelledby={`${id}-title`} 65 + className="fixed inset-0 z-50 flex items-center justify-center" 66 + onKeyDown={handleKeyDown} 67 + > 68 + <button 69 + type="button" 70 + className="absolute inset-0 bg-black/50 cursor-default" 71 + onClick={onClose} 72 + aria-label="Close modal" 73 + /> 74 + <div className="relative bg-white dark:bg-slate-800 rounded-lg shadow-xl p-4 w-full max-w-md mx-4"> 75 + <div className="flex items-center justify-between mb-4"> 76 + <h3 77 + id={`${id}-title`} 78 + className="text-lg font-semibold text-gray-900 dark:text-white" 79 + > 80 + {initialUrl ? "Edit Link" : "Insert Link"} 81 + </h3> 82 + <button 83 + type="button" 84 + onClick={onClose} 85 + className="p-1 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-500 dark:text-gray-400" 86 + > 87 + <X className="w-5 h-5" /> 88 + </button> 89 + </div> 90 + 91 + <form onSubmit={handleSubmit} className="space-y-4"> 92 + <div> 93 + <label 94 + htmlFor={urlId} 95 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" 96 + > 97 + URL 98 + </label> 99 + <input 100 + ref={urlInputRef} 101 + id={urlId} 102 + type="text" 103 + value={url} 104 + onChange={(e) => setUrl(e.target.value)} 105 + placeholder="example.com" 106 + className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" 107 + /> 108 + </div> 109 + 110 + {showTextInput && ( 111 + <div> 112 + <label 113 + htmlFor={textId} 114 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" 115 + > 116 + Link Text 117 + </label> 118 + <input 119 + id={textId} 120 + type="text" 121 + value={text} 122 + onChange={(e) => setText(e.target.value)} 123 + placeholder="Display text (optional)" 124 + className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" 125 + /> 126 + </div> 127 + )} 128 + 129 + <div className="flex justify-end gap-2"> 130 + <button 131 + type="button" 132 + onClick={onClose} 133 + className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-md" 134 + > 135 + Cancel 136 + </button> 137 + <button 138 + type="submit" 139 + disabled={!url.trim()} 140 + className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md" 141 + > 142 + {initialUrl ? "Save" : "Insert"} 143 + </button> 144 + </div> 145 + </form> 146 + </div> 147 + </div> 148 + ); 149 + }
+1 -1
src/components/richtext/ProseMirrorEditor.tsx
··· 76 76 }, 77 77 attributes: { 78 78 class: 79 - "prose dark:prose-invert prose-sm max-w-none focus:outline-none min-h-[8rem] p-3", 79 + "focus:outline-none min-h-[8rem] p-3 text-gray-900 dark:text-gray-100", 80 80 "data-placeholder": initialPlaceholderRef.current, 81 81 }, 82 82 });
+143 -45
src/components/richtext/Toolbar.tsx
··· 2 2 import { toggleMark } from "prosemirror-commands"; 3 3 import type { MarkType } from "prosemirror-model"; 4 4 import type { EditorView } from "prosemirror-view"; 5 + import { useCallback, useState } from "react"; 6 + import { LinkModal } from "./LinkModal"; 5 7 6 8 interface ToolbarProps { 7 9 view: EditorView | null; 8 10 } 9 11 10 12 export function Toolbar({ view }: ToolbarProps) { 13 + const [linkModalOpen, setLinkModalOpen] = useState(false); 14 + const [linkModalState, setLinkModalState] = useState({ 15 + initialUrl: "", 16 + initialText: "", 17 + showTextInput: false, 18 + // Range of existing link being edited, if any 19 + linkRange: null as { from: number; to: number } | null, 20 + }); 21 + 22 + const handleLinkSubmit = useCallback( 23 + (url: string, text?: string) => { 24 + if (!view) return; 25 + // Default to https:// if no protocol provided 26 + const href = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`; 27 + const linkMark = view.state.schema.marks.link.create({ href }); 28 + const tr = view.state.tr; 29 + 30 + if (linkModalState.linkRange) { 31 + // Editing existing link - remove old mark first, then add new one 32 + const { from, to } = linkModalState.linkRange; 33 + tr.removeMark(from, to, view.state.schema.marks.link); 34 + tr.addMark(from, to, linkMark); 35 + } else { 36 + // New link 37 + const { from, to } = view.state.selection; 38 + const selectedText = view.state.doc.textBetween(from, to); 39 + 40 + if (selectedText) { 41 + tr.addMark(from, to, linkMark); 42 + } else { 43 + const linkText = text || url; 44 + tr.insertText(linkText, from); 45 + tr.addMark(from, from + linkText.length, linkMark); 46 + } 47 + } 48 + 49 + view.dispatch(tr); 50 + view.focus(); 51 + }, 52 + [view, linkModalState.linkRange], 53 + ); 54 + 11 55 if (!view) return null; 12 56 13 57 const { state } = view; ··· 28 72 }; 29 73 }; 30 74 31 - const insertLink = () => { 32 - const { from, to } = state.selection; 75 + const openLinkModal = () => { 76 + const { from, to, $from } = state.selection; 33 77 const selectedText = state.doc.textBetween(from, to); 34 - const url = prompt("Enter URL:", "https://"); 35 - if (!url) return; 36 78 37 - const linkMark = schema.marks.link.create({ href: url }); 38 - const tr = state.tr; 79 + // Check for existing link mark - either at cursor or in selection 80 + let linkMark = schema.marks.link.isInSet($from.marks()); 81 + let existingUrl = linkMark?.attrs.href as string | undefined; 39 82 40 - if (selectedText) { 41 - tr.addMark(from, to, linkMark); 42 - } else { 43 - const linkText = prompt("Enter link text:", url) || url; 44 - tr.insertText(linkText, from); 45 - tr.addMark(from, from + linkText.length, linkMark); 83 + // If selection spans text, check if it contains a link 84 + if (!linkMark && from !== to) { 85 + state.doc.nodesBetween(from, to, (node) => { 86 + if (linkMark) return false; // Already found one 87 + const mark = schema.marks.link.isInSet(node.marks); 88 + if (mark) { 89 + linkMark = mark; 90 + existingUrl = mark.attrs.href as string; 91 + return false; 92 + } 93 + }); 46 94 } 47 95 48 - view.dispatch(tr); 49 - view.focus(); 96 + // Find the full extent of the link mark if editing 97 + let linkRange: { from: number; to: number } | null = null; 98 + if (linkMark) { 99 + // Walk through parent's inline content to find link boundaries 100 + // We check actual node marks, not insertion marks (which differ for non-inclusive marks) 101 + const $pos = from !== to ? state.doc.resolve(from + 1) : $from; 102 + const parent = $pos.parent; 103 + const parentOffset = $pos.start(); 104 + 105 + let linkStart: number | null = null; 106 + let linkEnd: number | null = null; 107 + let pos = parentOffset; 108 + let foundEnd = false; 109 + 110 + parent.forEach((node) => { 111 + if (foundEnd) return; 112 + 113 + const nodeEnd = pos + node.nodeSize; 114 + const nodeLinkMark = schema.marks.link.isInSet(node.marks); 115 + 116 + if (nodeLinkMark?.attrs.href === existingUrl) { 117 + if (linkStart === null) linkStart = pos; 118 + linkEnd = nodeEnd; 119 + } else if (linkStart !== null) { 120 + foundEnd = true; 121 + } 122 + 123 + pos = nodeEnd; 124 + }); 125 + 126 + if (linkStart !== null && linkEnd !== null) { 127 + linkRange = { from: linkStart, to: linkEnd }; 128 + } 129 + } 130 + 131 + setLinkModalState({ 132 + initialUrl: existingUrl ?? "", 133 + initialText: selectedText, 134 + showTextInput: !selectedText && !existingUrl, 135 + linkRange, 136 + }); 137 + setLinkModalOpen(true); 50 138 }; 51 139 52 140 return ( 53 - <div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> 54 - <ToolbarButton 55 - onClick={toggleMarkCommand(schema.marks.strong)} 56 - active={isMarkActive(schema.marks.strong)} 57 - title="Bold (Cmd+B)" 58 - > 59 - <Bold className="w-4 h-4" /> 60 - </ToolbarButton> 61 - <ToolbarButton 62 - onClick={toggleMarkCommand(schema.marks.em)} 63 - active={isMarkActive(schema.marks.em)} 64 - title="Italic (Cmd+I)" 65 - > 66 - <Italic className="w-4 h-4" /> 67 - </ToolbarButton> 68 - <ToolbarButton 69 - onClick={toggleMarkCommand(schema.marks.code)} 70 - active={isMarkActive(schema.marks.code)} 71 - title="Code (Cmd+`)" 72 - > 73 - <Code className="w-4 h-4" /> 74 - </ToolbarButton> 75 - <div className="w-px h-5 bg-gray-300 dark:bg-slate-600 mx-1" /> 76 - <ToolbarButton 77 - onClick={insertLink} 78 - active={isMarkActive(schema.marks.link)} 79 - title="Insert Link" 80 - > 81 - <Link className="w-4 h-4" /> 82 - </ToolbarButton> 83 - </div> 141 + <> 142 + <div className="flex items-center gap-1 p-2 border-b border-gray-300 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"> 143 + <ToolbarButton 144 + onClick={toggleMarkCommand(schema.marks.strong)} 145 + active={isMarkActive(schema.marks.strong)} 146 + title="Bold (Cmd+B)" 147 + > 148 + <Bold className="w-4 h-4" /> 149 + </ToolbarButton> 150 + <ToolbarButton 151 + onClick={toggleMarkCommand(schema.marks.em)} 152 + active={isMarkActive(schema.marks.em)} 153 + title="Italic (Cmd+I)" 154 + > 155 + <Italic className="w-4 h-4" /> 156 + </ToolbarButton> 157 + <ToolbarButton 158 + onClick={toggleMarkCommand(schema.marks.code)} 159 + active={isMarkActive(schema.marks.code)} 160 + title="Code (Cmd+`)" 161 + > 162 + <Code className="w-4 h-4" /> 163 + </ToolbarButton> 164 + <div className="w-px h-5 bg-gray-300 dark:bg-slate-600 mx-1" /> 165 + <ToolbarButton 166 + onClick={openLinkModal} 167 + active={isMarkActive(schema.marks.link)} 168 + title={isMarkActive(schema.marks.link) ? "Edit Link" : "Insert Link"} 169 + > 170 + <Link className="w-4 h-4" /> 171 + </ToolbarButton> 172 + </div> 173 + <LinkModal 174 + isOpen={linkModalOpen} 175 + onClose={() => setLinkModalOpen(false)} 176 + onSubmit={handleLinkSubmit} 177 + initialUrl={linkModalState.initialUrl} 178 + initialText={linkModalState.initialText} 179 + showTextInput={linkModalState.showTextInput} 180 + /> 181 + </> 84 182 ); 85 183 } 86 184