feat: find an article and drop its link, from wherever you are writing
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
963ca04cf8
commit
3c7931a350
5 changed files with 104 additions and 5 deletions
|
|
@ -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(<MarkdownToolbar textareaRef={{ current: field }} value="See for more" onChange={onChange} />)
|
||||
|
||||
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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</button>
|
||||
))}
|
||||
<span className="mdbar-link">
|
||||
<button type="button" title="Link to an article" aria-label="Link to an article"
|
||||
aria-expanded={linking} onClick={() => setLinking(v => !v)}>
|
||||
🔗 Link
|
||||
</button>
|
||||
{linking && (
|
||||
<Suspense fallback={null}>
|
||||
<LinkPicker onClose={() => setLinking(false)} onInsert={insert} />
|
||||
</Suspense>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="fc-edit">
|
||||
<label className="form-label" htmlFor={`front-${card.id}`}>Front</label>
|
||||
<textarea id={`front-${card.id}`} className="input" rows={2} value={editFront}
|
||||
onFocus={e => { cardFieldRef.current = { which: 'front', el: e.target } }}
|
||||
onChange={e => setEditFront(e.target.value)} />
|
||||
<label className="form-label" htmlFor={`back-${card.id}`}>Back</label>
|
||||
<textarea id={`back-${card.id}`} className="input" rows={4} value={editBack}
|
||||
onFocus={e => { cardFieldRef.current = { which: 'back', el: e.target } }}
|
||||
onChange={e => setEditBack(e.target.value)} />
|
||||
<p className="fc-edit-hint">
|
||||
Markdown, and <code>{'[[264|label]]'}</code> links an article.
|
||||
<code>{'[[264#section|label]]'}</code> lands on one of its sections.
|
||||
</p>
|
||||
<div className="fc-edit-tools">
|
||||
{/* Beside the writing. Finding the number meant opening the
|
||||
library in another tab and reading it out of the address
|
||||
bar, which is how a deck ends up linking to nothing. */}
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
aria-expanded={linkingCard} onClick={() => setLinkingCard(v => !v)}>
|
||||
🔗 Link an article
|
||||
</button>
|
||||
<p className="fc-edit-hint">
|
||||
Markdown, and <code>{'[[264|label]]'}</code> links an article.
|
||||
<code>{'[[264#section|label]]'}</code> lands on one of its sections.
|
||||
</p>
|
||||
</div>
|
||||
{linkingCard && (
|
||||
<LinkPicker onClose={() => setLinkingCard(false)} onInsert={insertMarker} />
|
||||
)}
|
||||
<div className="fc-edit-image">
|
||||
{editImage ? (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Reference in a new issue