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:
@@ -367,6 +367,30 @@ export default function App() {
|
||||
[removeSuggestion, resolveServerId],
|
||||
)
|
||||
|
||||
// Accept-all: EditorCore has already applied the whole category in one editor
|
||||
// transaction, so this is only the bookkeeping. Each row is filed individually
|
||||
// (there's no batch endpoint, and each accept plants its own word in the
|
||||
// garden), but the kitten cheers once — five cheers for one click would read as
|
||||
// five separate congratulations for a decision she made once.
|
||||
const handleAcceptMany = useCallback(
|
||||
async (list: Suggestion[]) => {
|
||||
if (list.length === 0) return
|
||||
for (const s of list) removeSuggestion(s.id)
|
||||
setAcceptTick((n) => n + 1)
|
||||
await Promise.all(
|
||||
list.map(async (s) => {
|
||||
try {
|
||||
const id = await resolveServerId(s)
|
||||
if (id) await api.acceptSuggestion(id)
|
||||
} catch (err) {
|
||||
console.error('accept failed', err)
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
[removeSuggestion, resolveServerId],
|
||||
)
|
||||
|
||||
// After restoring a version, swap the restored doc into the editor. Bumping
|
||||
// editorEpoch remounts EditorCore so it picks up the restored content.
|
||||
const handleRestored = useCallback(
|
||||
@@ -569,6 +593,7 @@ export default function App() {
|
||||
onChange={handleEditorChange}
|
||||
suggestions={suggestions}
|
||||
onAccept={handleAccept}
|
||||
onAcceptMany={handleAcceptMany}
|
||||
onDismiss={handleDismiss}
|
||||
onVoiceCheck={runVoice}
|
||||
voicing={voicing}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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})`
|
||||
}
|
||||
|
||||
@@ -314,6 +314,19 @@ button, a, input {
|
||||
color: var(--color-plum);
|
||||
}
|
||||
|
||||
/* Accept-all: the quieter sibling of Accept, on both card surfaces. It's outlined
|
||||
in its category's own colour rather than filled like Accept, because it acts on
|
||||
cards she can't see from here — an equally loud button for a larger action would
|
||||
invite the click she meant to give the one suggestion in front of her. */
|
||||
.petal-accept-all {
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.petal-accept-all:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
/* --- Find & Replace ---------------------------------------------------------
|
||||
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
|
||||
current match is brighter with a rose ring so it stands out as you step
|
||||
|
||||
Reference in New Issue
Block a user