diff --git a/frontend/src/components/LinkPicker.test.jsx b/frontend/src/components/LinkPicker.test.jsx index 375f9c2..721f307 100644 --- a/frontend/src/components/LinkPicker.test.jsx +++ b/frontend/src/components/LinkPicker.test.jsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import LinkPicker from './LinkPicker' import api from '../api/client' +import MarkdownToolbar from './MarkdownToolbar' vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) @@ -81,3 +82,21 @@ describe('finding an article to link to', () => { expect(onClose).toHaveBeenCalled() }) }) + +it('drops the marker where the caret is, from the toolbar', async () => { + // The picker used to be reachable from the article editor alone, and its + // marker only ever went to the clipboard. Every field with a markdown + // toolbar now has it — the stem, the explanation, each option's reasoning — + // and it lands at the caret rather than at the end. + api.get.mockResolvedValue({ data: [{ id: 264, title: 'Croup', status: 'published', sections: [] }] }) + const onChange = vi.fn() + const field = document.createElement('textarea') + field.value = 'See for more' + document.body.appendChild(field) + field.setSelectionRange(4, 4) + render() + + await userEvent.click(screen.getByRole('button', { name: 'Link to an article' })) + await userEvent.click(await screen.findByRole('button', { name: /Croup/ })) + expect(onChange).toHaveBeenCalledWith('See [[264|Croup]] for more') +}) diff --git a/frontend/src/components/MarkdownToolbar.css b/frontend/src/components/MarkdownToolbar.css index 52a2da7..91841cd 100644 --- a/frontend/src/components/MarkdownToolbar.css +++ b/frontend/src/components/MarkdownToolbar.css @@ -16,6 +16,9 @@ /* The field below joins on to the bar. */ .mdbar + textarea { border-top-left-radius: 0; border-top-right-radius: 0; } +/* The picker floats to the corner of the window, so this wrapper only has to + keep the button in the row rather than position anything. */ +.mdbar-link { display: inline-flex; } @media (max-width: 560px) { /* Touch targets, and the bar scrolls rather than wrapping to three rows. */ diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx index 5d7c9dc..a1fe1d0 100644 --- a/frontend/src/components/MarkdownToolbar.jsx +++ b/frontend/src/components/MarkdownToolbar.jsx @@ -1,5 +1,10 @@ +import { lazy, Suspense, useState } from 'react' import './MarkdownToolbar.css' +// Only fetched when somebody reaches for it: it carries a search box and a +// request, and most edits never link anything. +const LinkPicker = lazy(() => import('./LinkPicker')) + /** * Markdown formatting buttons over a plain textarea. * @@ -41,6 +46,28 @@ const ACTIONS = [ ] export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) { + //: Finding an article to link to, beside the writing rather than in another + //: tab. Every field with this toolbar gets it — the stem, the explanation, + //: each option's reasoning — which is most of the places a cross-reference + //: is actually written. + const [linking, setLinking] = useState(false) + + //: The marker, dropped where the caret is. The picker copies it as well, so + //: it works the same whether you insert it or paste it somewhere else. + const insert = (marker) => { + const field = textareaRef.current + const text = value || '' + const start = field?.selectionStart ?? text.length + const end = field?.selectionEnd ?? text.length + onChange(text.slice(0, start) + marker + text.slice(end)) + setLinking(false) + requestAnimationFrame(() => { + field?.focus() + const at = start + marker.length + field?.setSelectionRange(at, at) + }) + } + const apply = (action) => { const field = textareaRef.current if (!field) return @@ -104,6 +131,17 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = {action.label} ))} + + + {linking && ( + + setLinking(false)} onInsert={insert} /> + + )} + ) } diff --git a/frontend/src/pages/FlashcardsPage.css b/frontend/src/pages/FlashcardsPage.css index 45846f6..9603be5 100644 --- a/frontend/src/pages/FlashcardsPage.css +++ b/frontend/src/pages/FlashcardsPage.css @@ -23,3 +23,7 @@ .fc-study.has-split { grid-template-columns: minmax(0, 1fr); } .fc-study.has-split .fc-study-main { display: none; } } + +/* The link button and the syntax note, on one line under the two faces. */ +.fc-edit-tools { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 8px 0; } +.fc-edit-tools .fc-edit-hint { margin: 0; } diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index cf45bc9..c173542 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import RichText from '../components/RichText' import ImageFigure from '../components/ImageFigure' import ImagePicker from '../components/ImagePicker' +import LinkPicker from '../components/LinkPicker' import api from '../api/client' import './FlashcardsPage.css' @@ -38,6 +39,25 @@ export default function FlashcardsPage() { const [editFront, setEditFront] = useState('') const [editBack, setEditBack] = useState('') const [editImage, setEditImage] = useState('') + //: The article picker, and which of the two faces the caret was last in — + //: a marker belongs where somebody was typing, not always on the back. + const [linkingCard, setLinkingCard] = useState(false) + const cardFieldRef = useRef({ which: 'back', el: null }) + + const insertMarker = (marker) => { + const { which, el } = cardFieldRef.current + const set = which === 'front' ? setEditFront : setEditBack + const text = (which === 'front' ? editFront : editBack) || '' + const start = el?.selectionStart ?? text.length + const end = el?.selectionEnd ?? text.length + set(text.slice(0, start) + marker + text.slice(end)) + setLinkingCard(false) + requestAnimationFrame(() => { + el?.focus() + const at = start + marker.length + el?.setSelectionRange(at, at) + }) + } const [pickingFor, setPickingFor] = useState(null) const [newDeckTitle, setNewDeckTitle] = useState('') const [decks, setDecks] = useState([]) @@ -100,6 +120,7 @@ export default function FlashcardsPage() { setEditFront(card.front || '') setEditBack(card.back || '') setEditImage(card.image_path || '') + setLinkingCard(false) setLinkCardId(null) } @@ -526,14 +547,28 @@ export default function FlashcardsPage() {