A whole category accepted in one click, and one undo

Five article fixes were five clicks, five confetti bursts and five undo
steps. "Accept all Tidy-up (5)" makes them one of each.

The single undo decided the implementation: every replacement goes into one
Tiptap chain, which applies as one transaction and so undoes as one history
event. That only works if the spans can't move under each other, so the
plan resolves every span against the document as it stands and applies them
last-first.

Three outcomes rather than one, because a batch that quietly dropped a card
would be reporting edits it never made: a span she already fixed herself is
settled without an edit (what a single Accept does too), and a card quoting
the same words as one already taken is left on screen, since findRange
would resolve both to the same place.

The control sits on the first card of its kind — the rail can't carry a
category header, its cards are anchored to their own sentences — and only
when the category has company. It is outlined rather than filled: it acts
on cards she can't see from where she's standing.
This commit is contained in:
prosolis
2026-07-28 17:30:44 -07:00
parent 1acc23244e
commit bd92cdc9b6
9 changed files with 508 additions and 27 deletions
+78 -18
View File
@@ -15,7 +15,7 @@ import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import { FontSize } from './FontSize'
import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
import { SuggestionRail, type RailItem } from './SuggestionRail'
@@ -30,7 +30,8 @@ import { SearchHighlight } from './SearchHighlight'
import { FindReplace } from './FindReplace'
import { Typography } from './Typography'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import { planBatch } from './acceptBatch'
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'
import { usePack } from '../../i18n'
@@ -54,6 +55,9 @@ interface Props {
// call + state removal (accept's text replacement happens here in the editor).
suggestions: Suggestion[]
onAccept: (s: Suggestion) => void
// A whole category accepted at once. The text replacement happens here, in one
// transaction; the parent files each row and cheers once for the batch.
onAcceptMany: (list: Suggestion[]) => void
onDismiss: (s: Suggestion) => void
// Triggers the whole-document voice-consistency pass; `voicing` is true while
// it runs (drives the toolbar button's loading state).
@@ -221,6 +225,7 @@ export function EditorCore({
onChange,
suggestions,
onAccept,
onAcceptMany,
onDismiss,
onVoiceCheck,
voicing,
@@ -638,18 +643,28 @@ export function EditorCore({
// anchored to the highlight itself (captured before the replacement removes it),
// so it fires in the right spot whether the accept came from the hover card or
// the margin rail.
// Where a suggestion's highlight sits, in wrapper coordinates — captured before
// the replacement removes it, so the confetti fires over the words that changed.
const burstAt = useCallback((id: string): { top: number; left: number } | null => {
const wrapper = wrapperRef.current
const el = wrapper?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
) as HTMLElement | null
if (!wrapper || !el) return null
const wrapRect = wrapper.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
return { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
}, [])
const showConfetti = useCallback((burst: { top: number; left: number }) => {
setConfetti(burst)
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}, [])
const handleAccept = useCallback(
(s: Suggestion) => {
const wrapper = wrapperRef.current
const el = wrapper?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
) as HTMLElement | null
let burst: { top: number; left: number } | null = null
if (wrapper && el) {
const wrapRect = wrapper.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
}
let burst = burstAt(s.id)
if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original)
if (range) {
@@ -658,16 +673,57 @@ export function EditorCore({
}
// Fall back to the hover card's position if the highlight wasn't found.
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
if (burst) {
setConfetti(burst)
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
if (burst) showConfetti(burst)
closeCard()
setRailExpandedId(null)
onAccept(s)
},
[editor, onAccept, closeCard, hover],
[editor, onAccept, closeCard, hover, burstAt, showConfetti],
)
// How many pending cards of each type could be accepted in one go. A card
// offers "Accept all" only when it has company, so a lone Grammar card doesn't
// grow a second button saying the same thing as the first.
const batchCounts = useMemo(() => {
const counts: Partial<Record<SuggestionType, number>> = {}
for (const s of suggestions) {
if (s.replacement.trim() === '') continue // awareness-only: nothing to accept
counts[s.type] = (counts[s.type] ?? 0) + 1
}
return counts
}, [suggestions])
// Accept a whole category at once. Five tense fixes were five clicks, five
// confetti bursts and five separate undo steps; this is one of each. The single
// undo step is the reason every replacement goes into ONE chain: Tiptap applies
// a chain as a single transaction, and prosemirror-history undoes it as a
// single event, so Ctrl+Z takes back the batch rather than unpicking it.
const handleAcceptAll = useCallback(
(type: SuggestionType) => {
if (!editor) return
const family = suggestions.filter((s) => s.type === type)
const plan = planBatch(family, (original) => findRange(editor.state.doc, original))
const settled = [...plan.steps.map((step) => step.suggestion), ...plan.missing]
if (settled.length === 0) return
// The topmost span that's about to change — the last step, since steps run
// bottom-up. Read before the edit, while the highlights still exist.
const first = plan.steps[plan.steps.length - 1]
const burst = first ? burstAt(first.suggestion.id) : null
if (plan.steps.length > 0) {
let chain = editor.chain().focus()
for (const step of plan.steps) {
chain = chain.insertContentAt({ from: step.from, to: step.to }, step.suggestion.replacement)
}
chain.run()
}
if (burst) showConfetti(burst)
closeCard()
setRailExpandedId(null)
onAcceptMany(settled)
},
[editor, suggestions, onAcceptMany, closeCard, burstAt, showConfetti],
)
const handleDismiss = useCallback(
@@ -1248,7 +1304,9 @@ export function EditorCore({
<SuggestionCard
suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }}
batchCount={batchCounts[hover.suggestion.type] ?? 0}
onAccept={handleAccept}
onAcceptAll={handleAcceptAll}
onDismiss={handleDismiss}
onPointerEnter={keepOpen}
onPointerLeave={scheduleClose}
@@ -1261,7 +1319,9 @@ export function EditorCore({
items={railItems}
activeId={activeId}
expandedId={railExpandedId}
batchCounts={batchCounts}
onAccept={handleAccept}
onAcceptAll={handleAcceptAll}
onDismiss={handleDismiss}
onHover={setActiveId}
onActivate={activateRailCard}
+19 -2
View File
@@ -1,13 +1,17 @@
import { useEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import type { Suggestion, SuggestionType } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal'
import { TYPE_META, typeLabel } from './suggestionMeta'
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
interface Props {
suggestion: Suggestion
style: React.CSSProperties
// How many pending suggestions share this card's type (including this one).
// Two or more offers to take the whole category in one step.
batchCount: number
onAccept: (s: Suggestion) => void
onAcceptAll: (type: SuggestionType) => void
onDismiss: (s: Suggestion) => void
onPointerEnter: () => void
onPointerLeave: () => void
@@ -30,7 +34,9 @@ interface Props {
export function SuggestionCard({
suggestion,
style,
batchCount,
onAccept,
onAcceptAll,
onDismiss,
onPointerEnter,
onPointerLeave,
@@ -144,6 +150,17 @@ export function SuggestionCard({
Dismiss
</button>
</div>
{hasReplacement && batchCount > 1 && (
<button
type="button"
onClick={() => onAcceptAll(suggestion.type)}
className="petal-accept-all mt-2 w-full rounded-full py-1.5 text-xs font-bold"
style={{ color: 'var(--color-plum)', borderColor: meta.color }}
>
{batchLabel(suggestion.type, batchCount)}
</button>
)}
</div>
)
}
+28 -3
View File
@@ -1,8 +1,9 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import type { Suggestion, SuggestionType } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal'
import { TYPE_META, typeLabel } from './suggestionMeta'
import { batchLeaders } from './acceptBatch'
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
@@ -22,7 +23,11 @@ interface Props {
activeId: string | null
// The card expanded to show the full explanation + Ask Petal, or null.
expandedId: string | null
// Pending suggestions per type. A card whose type has company offers to take
// the whole category in one step (and one undo step).
batchCounts: Partial<Record<SuggestionType, number>>
onAccept: (s: Suggestion) => void
onAcceptAll: (type: SuggestionType) => void
onDismiss: (s: Suggestion) => void
// Pointer entering/leaving a card, so the matching highlight can light up.
onHover: (id: string | null) => void
@@ -46,7 +51,9 @@ export function SuggestionRail({
items,
activeId,
expandedId,
batchCounts,
onAccept,
onAcceptAll,
onDismiss,
onHover,
onActivate,
@@ -93,6 +100,9 @@ export function SuggestionRail({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutKey, expandedId, measureTick])
// One card per category carries the batch control — see batchLeaders.
const batchLead = batchLeaders(ordered.map(({ suggestion }) => suggestion))
return (
<div className="petal-rail petal-no-print" aria-label="Suggestions">
{ordered.map(({ suggestion }) => (
@@ -112,7 +122,9 @@ export function SuggestionRail({
top={tops[suggestion.id] ?? 0}
active={activeId === suggestion.id}
expanded={expandedId === suggestion.id}
batchCount={batchLead.has(suggestion.id) ? (batchCounts[suggestion.type] ?? 0) : 0}
onAccept={onAccept}
onAcceptAll={onAcceptAll}
onDismiss={onDismiss}
onHover={onHover}
onActivate={onActivate}
@@ -128,7 +140,9 @@ interface CardProps {
top: number
active: boolean
expanded: boolean
batchCount: number
onAccept: (s: Suggestion) => void
onAcceptAll: (type: SuggestionType) => void
onDismiss: (s: Suggestion) => void
onHover: (id: string | null) => void
onActivate: (id: string) => void
@@ -136,7 +150,7 @@ interface CardProps {
}
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
{ suggestion, top, active, expanded, batchCount, onAccept, onAcceptAll, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const pack = usePack()
@@ -230,6 +244,17 @@ const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
</div>
{hasReplacement && batchCount > 1 && (
<button
type="button"
onClick={() => onAcceptAll(suggestion.type)}
className="petal-accept-all mt-2 w-full rounded-full py-1 text-[0.7rem] font-bold"
style={{ color: 'var(--color-plum)', borderColor: meta.color }}
>
{batchLabel(suggestion.type, batchCount)}
</button>
)}
</div>
)
})
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import type { Suggestion, SuggestionType } from '../../api/client'
import { batchLeaders, planBatch } from './acceptBatch'
function sug(id: string, original: string, replacement: string, type: SuggestionType = 'grammar'): Suggestion {
return {
id,
doc_id: 'd1',
from_pos: 0,
to_pos: 0,
original,
replacement,
explanation: '',
type,
status: 'pending',
created_at: '2026-07-28T00:00:00Z',
}
}
// A stand-in for findRange: first occurrence in a flat string, or null.
function finder(text: string) {
return (needle: string) => {
const i = text.indexOf(needle)
return i < 0 ? null : { from: i, to: i + needle.length }
}
}
describe('planBatch', () => {
it('applies from the end backwards, so earlier spans keep their positions', () => {
const text = 'a apple and a orange and a egg'
const plan = planBatch(
[sug('1', 'a apple', 'an apple'), sug('2', 'a orange', 'an orange'), sug('3', 'a egg', 'an egg')],
finder(text),
)
expect(plan.steps.map((s) => s.suggestion.id)).toEqual(['3', '2', '1'])
expect(plan.missing).toEqual([])
expect(plan.skipped).toEqual([])
// Applying the plan in order against a mutable string must land every
// replacement where it belongs — this is the property the ordering exists
// for, and the one a single shared transaction depends on.
let out = text
for (const step of plan.steps) {
out = out.slice(0, step.from) + step.suggestion.replacement + out.slice(step.to)
}
expect(out).toBe('an apple and an orange and an egg')
})
it('orders by position in the document, not by the order the cards arrived', () => {
const plan = planBatch(
[sug('late', 'a egg', 'an egg'), sug('early', 'a apple', 'an apple')],
finder('a apple and a egg'),
)
expect(plan.steps.map((s) => s.suggestion.id)).toEqual(['late', 'early'])
})
it('settles a card whose span is gone — she already fixed it herself', () => {
const plan = planBatch(
[sug('1', 'a apple', 'an apple'), sug('2', 'a orange', 'an orange')],
finder('an apple and a orange'),
)
expect(plan.steps.map((s) => s.suggestion.id)).toEqual(['2'])
expect(plan.missing.map((s) => s.id)).toEqual(['1'])
})
it('leaves the second of two cards quoting the same words on screen', () => {
// findRange resolves both to the first occurrence, so applying both would
// overwrite the first edit with the second. The batch takes one and says so.
const plan = planBatch(
[sug('1', 'a apple', 'an apple'), sug('2', 'a apple', 'the apple')],
finder('a apple, a apple'),
)
expect(plan.steps.map((s) => s.suggestion.id)).toEqual(['1'])
expect(plan.skipped.map((s) => s.id)).toEqual(['2'])
expect(plan.missing).toEqual([])
})
it('keeps both of two spans that merely touch', () => {
// Adjacent is not overlapping: "to" ends exactly where "the" begins.
const plan = planBatch(
[sug('1', 'aa', 'AA'), sug('2', 'bb', 'BB')],
finder('aabb'),
)
expect(plan.steps.map((s) => s.suggestion.id)).toEqual(['2', '1'])
expect(plan.skipped).toEqual([])
})
it('skips an awareness-only card with nothing to insert', () => {
const plan = planBatch([sug('v', 'a apple', ' ', 'voice')], finder('a apple'))
expect(plan.steps).toEqual([])
expect(plan.skipped.map((s) => s.id)).toEqual(['v'])
})
it('returns an empty plan for an empty family', () => {
const plan = planBatch([], finder('anything'))
expect(plan).toEqual({ steps: [], missing: [], skipped: [] })
})
})
describe('batchLeaders', () => {
const card = (id: string, type: SuggestionType) => ({ id, type })
it('gives each category exactly one leader, the first of its kind', () => {
const leaders = batchLeaders([
card('g1', 'grammar'),
card('m1', 'mechanics'),
card('g2', 'grammar'),
card('m2', 'mechanics'),
card('g3', 'grammar'),
])
expect([...leaders]).toEqual(['g1', 'm1'])
})
it('follows the stacking order it is given, not the order types appear elsewhere', () => {
// The rail sorts by anchor, so "first" means highest on the page — the card
// she reads first, which is where the batch control belongs.
const leaders = batchLeaders([card('lower', 'grammar'), card('upper', 'grammar')])
expect([...leaders]).toEqual(['lower'])
})
it('leads a lone card of its type too — the count is what hides the button', () => {
expect([...batchLeaders([card('only', 'voice')])]).toEqual(['only'])
})
it('has no leaders for an empty stack', () => {
expect(batchLeaders([]).size).toBe(0)
})
})
+84
View File
@@ -0,0 +1,84 @@
import type { Suggestion, SuggestionType } from '../../api/client'
// batchLeaders picks the one card per category that carries the "Accept all
// Grammar (5)" control, given the cards in the order they are stacked. The rail
// can't carry a category header — cards are anchored to their own sentence, so a
// type's cards are scattered down the column — and the closest honest stand-in
// is the first card of its kind. Offering the same batch on all five would be
// five buttons saying one thing.
export function batchLeaders(ordered: { type: SuggestionType; id: string }[]): Set<string> {
const leaders = new Set<string>()
const claimed = new Set<SuggestionType>()
for (const { type, id } of ordered) {
if (claimed.has(type)) continue
claimed.add(type)
leaders.add(id)
}
return leaders
}
export interface BatchStep {
suggestion: Suggestion
from: number
to: number
}
export interface BatchPlan {
// The edits to apply, ordered LAST span first. Every position is resolved
// against the document as it stands *before* any of them are applied, and
// applying from the end backwards means an earlier span's position can't be
// shifted by a later one — so the whole batch can go into a single
// transaction, which is the point: one Ctrl+Z puts it all back.
steps: BatchStep[]
// Cards whose span is no longer in the document — she fixed it herself, or
// edited around it since the check ran. There is nothing to replace, but the
// card is stale and settling it is what a single Accept already does.
missing: Suggestion[]
// Cards deliberately left on screen: two suggestions quoting the same words
// (findRange resolves both to the first occurrence, so applying the second
// would overwrite the first), and anything with nothing to insert. A batch
// that silently dropped these would report edits it never made.
skipped: Suggestion[]
}
// planBatch decides what one "Accept all <category>" click actually does. It is
// pure and takes the span resolver as an argument so the ordering and overlap
// rules can be tested without a ProseMirror document — the part that goes wrong
// is the arithmetic, not the lookup.
export function planBatch(
list: Suggestion[],
resolve: (original: string) => { from: number; to: number } | null,
): BatchPlan {
const located: BatchStep[] = []
const missing: Suggestion[] = []
const skipped: Suggestion[] = []
for (const suggestion of list) {
if (suggestion.replacement.trim() === '') {
skipped.push(suggestion) // awareness-only (voice): nothing to accept
continue
}
const range = resolve(suggestion.original)
if (!range) {
missing.push(suggestion)
continue
}
located.push({ suggestion, from: range.from, to: range.to })
}
// Document order, then greedily keep the non-overlapping ones.
located.sort((a, b) => a.from - b.from || a.to - b.to)
const steps: BatchStep[] = []
let cursor = -Infinity
for (const step of located) {
if (step.from < cursor) {
skipped.push(step.suggestion)
continue
}
steps.push(step)
cursor = step.to
}
steps.reverse()
return { steps, missing, skipped }
}
@@ -28,3 +28,13 @@ export const TYPE_META: Record<SuggestionType, { color: string; label: string }>
export function typeLabel(type: SuggestionType, pack: Pack): string {
return type === 'translate' ? pack.editor.translateLabel : TYPE_META[type].label
}
// The label on the accept-a-whole-category button. It stays English even on a
// translation card, whose pill is bilingual: the pill names the kind of advice
// she is reading, while this names an action taken over the rest of the queue,
// and every other word in the action row — Accept, Dismiss, Ask Petal — is
// English too. A control that switched languages between cards would read as a
// different control.
export function batchLabel(type: SuggestionType, count: number): string {
return `Accept all ${TYPE_META[type].label} (${count})`
}