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() {