diff --git a/components/action-dropdown.js b/components/action-dropdown.js index b922967d1..ea19ebaca 100644 --- a/components/action-dropdown.js +++ b/components/action-dropdown.js @@ -8,7 +8,7 @@ export default function ActionDropdown ({ children }) { } return ( - + e.preventDefault()}> diff --git a/components/comment.js b/components/comment.js index 3be44802b..e9bdface2 100644 --- a/components/comment.js +++ b/components/comment.js @@ -136,6 +136,8 @@ export default function Comment ({ ? 'fwd' : null const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) + const replyRef = useRef() + const contentContainerRef = useRef() return (
{op}} + onQuoteReply={replyRef?.current?.quoteReply} extraInfo={ <> {includeParent && } @@ -207,7 +210,7 @@ export default function Comment ({ /> ) : ( -
+
{item.searchText ? : ( @@ -224,7 +227,7 @@ export default function Comment ({ : (
{!noReply && - + {root.bounty && !bountyPaid && } } {children} diff --git a/components/form.js b/components/form.js index b4bcf2347..d135256c5 100644 --- a/components/form.js +++ b/components/form.js @@ -92,6 +92,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH const [, meta, helpers] = useField(props) const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 }) innerRef = innerRef || useRef(null) + const previousTab = useRef(tab) props.as ||= TextareaAutosize props.rows ||= props.minRows || 6 @@ -100,6 +101,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH !meta.value && setTab('write') }, [meta.value]) + useEffect(() => { + // focus on input when switching to write tab from preview tab + if (innerRef?.current && tab === 'write' && previousTab?.current !== 'write') { + innerRef.current.focus() + } + previousTab.current = tab + }, [tab]) + useEffect(() => { if (selectionRange.start <= selectionRange.end && innerRef?.current) { const { start, end } = selectionRange @@ -125,53 +134,49 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH - {tab === 'write' - ? ( -
- { - if (onChange) onChange(formik, e) - if (setHasImgLink) { - setHasImgLink(mdHas(e.target.value, ['link', 'image'])) - } - }} - innerRef={innerRef} - onKeyDown={(e) => { - const metaOrCtrl = e.metaKey || e.ctrlKey - if (metaOrCtrl) { - if (e.key === 'k') { - // some browsers use CTRL+K to focus search bar so we have to prevent that behavior - e.preventDefault() - insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - if (e.key === 'b') { - // some browsers use CTRL+B to open bookmarks so we have to prevent that behavior - e.preventDefault() - insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - if (e.key === 'i') { - // some browsers might use CTRL+I to do something else so prevent that behavior too - e.preventDefault() - insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - if (e.key === 'Tab' && e.altKey) { - e.preventDefault() - insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) - } - } - - if (onKeyDown) onKeyDown(e) - }} - /> -
) - : ( -
-
- {meta.value} -
-
- )} +
+ { + if (onChange) onChange(formik, e) + if (setHasImgLink) { + setHasImgLink(mdHas(e.target.value, ['link', 'image'])) + } + }} + innerRef={innerRef} + onKeyDown={(e) => { + const metaOrCtrl = e.metaKey || e.ctrlKey + if (metaOrCtrl) { + if (e.key === 'k') { + // some browsers use CTRL+K to focus search bar so we have to prevent that behavior + e.preventDefault() + insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'b') { + // some browsers use CTRL+B to open bookmarks so we have to prevent that behavior + e.preventDefault() + insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'i') { + // some browsers might use CTRL+I to do something else so prevent that behavior too + e.preventDefault() + insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + if (e.key === 'Tab' && e.altKey) { + e.preventDefault() + insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange) + } + } + if (onKeyDown) onKeyDown(e) + }} + /> +
+ {tab !== 'write' && +
+
+ {meta.value} +
+
}
) @@ -225,7 +230,8 @@ function FormGroup ({ className, label, children }) { function InputInner ({ prepend, append, hint, showValid, onChange, onBlur, overrideValue, - innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength, ...props + innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, maxLength, + ...props }) { const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const formik = noForm ? null : useFormikContext() @@ -483,7 +489,7 @@ export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, const StorageKeyPrefixContext = createContext() export function Form ({ - initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props + initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props }) { const toaster = useToast() const initialErrorToasted = useRef(false) @@ -538,6 +544,7 @@ export function Form ({ toaster.danger(err.message || err.toString?.()) } }} + innerRef={innerRef} > diff --git a/components/item-full.js b/components/item-full.js index d40e44d8f..6c05f3176 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -13,7 +13,7 @@ import Button from 'react-bootstrap/Button' import { TwitterTweetEmbed } from 'react-twitter-embed' import YouTube from 'react-youtube' import useDarkMode from './dark-mode' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import Poll from './poll' import { commentsViewed } from '../lib/new-comments' import Related from './related' @@ -122,10 +122,13 @@ function FwdUsers ({ forwards }) { function TopLevelItem ({ item, noReply, ...props }) { const ItemComponent = item.isJob ? ItemJob : Item + const replyRef = useRef() + const contentContainerRef = useRef() return ( 0 && } {...props} > -
+
{item.text && } {item.url && } {item.poll && } @@ -157,7 +160,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
{!noReply && <> - + {!item.position && !item.isJob && !item.parentId && !item.bounty > 0 && } {item.bounty > 0 && } } diff --git a/components/item-info.js b/components/item-info.js index e37191b48..783d70536 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -2,6 +2,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import Badge from 'react-bootstrap/Badge' +import Dropdown from 'react-bootstrap/Dropdown' import Countdown from './countdown' import { abbrNum, numWithUnits } from '../lib/format' import { newComments, commentsViewedAt } from '../lib/new-comments' @@ -20,7 +21,8 @@ import MuteDropdownItem from './mute' export default function ItemInfo ({ item, pendingSats, full, commentsText = 'comments', - commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText + commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, + onQuoteReply }) { const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const me = useMe() @@ -131,6 +133,8 @@ export default function ItemInfo ({ } + {(item.parentId || item.text) && onQuoteReply && + quote reply} {me && } {me && !item.mine && } {item.otsHash && diff --git a/components/item.js b/components/item.js index 34ad35ca1..ce12a6749 100644 --- a/components/item.js +++ b/components/item.js @@ -24,7 +24,7 @@ export function SearchTitle ({ title }) { }) } -export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments }) { +export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, replyRef }) { const titleRef = useRef() const router = useRouter() const [pendingSats, setPendingSats] = useState(0) @@ -85,6 +85,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
AD} /> {belowTitle} diff --git a/components/reply.js b/components/reply.js index 859a695ee..9bdd16bf2 100644 --- a/components/reply.js +++ b/components/reply.js @@ -3,12 +3,13 @@ import { gql, useMutation } from '@apollo/client' import styles from './reply.module.css' import { COMMENTS } from '../fragments/comments' import { useMe } from './me' -import { useEffect, useState, useRef, useCallback } from 'react' +import { forwardRef, useCallback, useEffect, useState, useRef, useImperativeHandle } from 'react' import Link from 'next/link' import FeeButton from './fee-button' import { commentsViewedAfterComment } from '../lib/new-comments' import { commentSchema } from '../lib/validate' import Info from './info' +import { quote } from '../lib/md' export function ReplyOnAnotherPage ({ parentId }) { return ( @@ -33,10 +34,36 @@ function FreebieDialog () { ) } -export default function Reply ({ item, onSuccess, replyOpen, children, placeholder }) { +export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children, placeholder, contentContainerRef }, ref) { const [reply, setReply] = useState(replyOpen) const me = useMe() const parentId = item.id + const replyInput = useRef(null) + const formInnerRef = useRef() + useImperativeHandle(ref, () => ({ + quoteReply: ({ selectionOnly }) => { + if (!reply) { + setReply(true) + } + const selection = window.getSelection() + const selectedText = selection.isCollapsed ? undefined : selection.toString() + const isSelectedTextInTarget = contentContainerRef?.current?.contains(selection.anchorNode) + if ((selection.isCollapsed || !isSelectedTextInTarget) && selectionOnly) return + const textToQuote = isSelectedTextInTarget ? selectedText : item.text + let updatedValue + if (formInnerRef.current && formInnerRef.current.values && !formInnerRef.current.values.text) { + updatedValue = quote(textToQuote) + } else if (formInnerRef.current?.values?.text) { + // append quote reply text if the input already has content + updatedValue = `${replyInput.current.value}\n${quote(textToQuote)}` + } + if (updatedValue) { + replyInput.current.value = updatedValue + formInnerRef.current.setValues({ text: updatedValue }) + window.localStorage.setItem(`reply-${parentId}-text`, updatedValue) + } + } + }), [reply, item]) useEffect(() => { setReply(replyOpen || !!window.localStorage.getItem('reply-' + parentId + '-' + 'text')) @@ -96,7 +123,6 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold setReply(replyOpen || false) }, [upsertComment, setReply, parentId]) - const replyInput = useRef(null) useEffect(() => { if (replyInput.current && reply && !replyOpen) replyInput.current.focus() }, [reply]) @@ -108,45 +134,51 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold : (
setReply(!reply)} + onPointerDown={e => { + if (!reply) { + e.preventDefault() + ref?.current?.quoteReply({ selectionOnly: true }) + } + setReply(!reply) + }} > {reply ? 'cancel' : 'reply'}
{/* HACK if we need more items, we should probably do a comment toolbar */} {children}
)} - {reply && -
-
- } - innerRef={replyInput} - /> - {reply && -
- -
} - -
} +
+
+ } + innerRef={replyInput} + /> + {reply && +
+ +
} + +
) -} +}) export function ReplySkeleton () { return ( diff --git a/lib/md.js b/lib/md.js index 1084e274e..35dc48892 100644 --- a/lib/md.js +++ b/lib/md.js @@ -35,3 +35,8 @@ export function extractUrls (md) { return Array.from(urls) } + +export const quote = (orig) => + orig.split('\n') + .map(line => `> ${line}`) + .join('\n') + '\n'