From 3c7931a3503890d360c5337830e86d847196f628 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:45:45 +0200 Subject: [PATCH] feat: find an article and drop its link, from wherever you are writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker already existed and did the right thing — search by title, expand an article to pick one section, click to get `[[264|Croup]]` or `[[264#workup|the workup]]` on the clipboard. It was wired into exactly one place: the article editor. Everywhere else, linking still meant opening the library in another tab and reading the id out of the address bar. It is on the markdown toolbar now, so every field that has one gets it: a question's stem, its explanation, and each option's reasoning — seven buttons on the question editor, which is most of the places a cross-reference is actually written. The marker lands at the caret rather than at the end, and still goes to the clipboard, so it works the same whether you insert it or paste it elsewhere. The card editor has its own button, because its two faces are plain textareas with no toolbar. It remembers which face the caret was last in — a marker belongs where somebody was typing, not always on the back. Lazily loaded: it carries a search box and a request, and most edits link nothing. Verified on the live question editor: search "croup" returns four articles and clicking one appends the marker to the stem. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/LinkPicker.test.jsx | 19 +++++++++ frontend/src/components/MarkdownToolbar.css | 3 ++ frontend/src/components/MarkdownToolbar.jsx | 38 +++++++++++++++++ frontend/src/pages/FlashcardsPage.css | 4 ++ frontend/src/pages/FlashcardsPage.jsx | 45 ++++++++++++++++++--- 5 files changed, 104 insertions(+), 5 deletions(-) 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() {