Compare commits
4 Commits
feat/write
...
adc0cdff1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adc0cdff1c | ||
|
|
9d6698dcc6 | ||
|
|
6783ce7a51 | ||
|
|
3ac6382696 |
@@ -154,6 +154,14 @@ var (
|
|||||||
// replacePending swaps a document's pending suggestions within one family for a
|
// replacePending swaps a document's pending suggestions within one family for a
|
||||||
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||||
// other family's pending rows are left untouched.
|
// other family's pending rows are left untouched.
|
||||||
|
//
|
||||||
|
// Suggestions the user already accepted or dismissed are suppressed from the
|
||||||
|
// fresh batch: the model has no memory between passes, so without this it would
|
||||||
|
// re-propose the identical edit on the very next checkpoint — re-nagging a
|
||||||
|
// sentence the user already resolved. This matters most when an accept silently
|
||||||
|
// no-ops (the `original` text couldn't be anchored in the editor, so the doc
|
||||||
|
// never changed): the sentence is unaltered, yet the user shouldn't see the same
|
||||||
|
// card again.
|
||||||
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||||
tx, err := h.DB.Begin()
|
tx, err := h.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,7 +176,15 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actioned, err := actionedKeys(tx, docID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range raw {
|
for _, s := range raw {
|
||||||
|
if _, seen := actioned[suggestionKey(s.Original, s.Replacement)]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
typ := scope.forceType
|
typ := scope.forceType
|
||||||
if typ == "" {
|
if typ == "" {
|
||||||
typ = normalizeType(s.Type)
|
typ = normalizeType(s.Type)
|
||||||
@@ -186,6 +202,39 @@ func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggest
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actionedKeys returns the set of original→replacement keys the user has already
|
||||||
|
// accepted or rejected for this document, so a fresh checkpoint can skip
|
||||||
|
// re-proposing them. Keyed on the trimmed original+replacement pair, matching the
|
||||||
|
// normalization ParseCheckpoint applies, so a genuinely different edit on the same
|
||||||
|
// sentence is not suppressed.
|
||||||
|
func actionedKeys(tx *sql.Tx, docID string) (map[string]struct{}, error) {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
`SELECT original, replacement FROM suggestions
|
||||||
|
WHERE doc_id = ? AND status IN (?, ?)`,
|
||||||
|
docID, db.SuggestionStatusAccepted, db.SuggestionStatusRejected,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
keys := make(map[string]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var original, replacement string
|
||||||
|
if err := rows.Scan(&original, &replacement); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys[suggestionKey(original, replacement)] = struct{}{}
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestionKey is the dedup identity for a suggestion: its trimmed original and
|
||||||
|
// replacement text. Two suggestions with the same key are "the same edit."
|
||||||
|
func suggestionKey(original, replacement string) string {
|
||||||
|
return strings.TrimSpace(original) + "\x00" + strings.TrimSpace(replacement)
|
||||||
|
}
|
||||||
|
|
||||||
// listForDoc returns the document's current pending suggestions (used when the
|
// listForDoc returns the document's current pending suggestions (used when the
|
||||||
// editor loads a document, before any new checkpoint fires).
|
// editor loads a document, before any new checkpoint fires).
|
||||||
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -127,6 +127,44 @@ func TestCheckpointFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResolvedSuggestionsNotReproposed proves a checkpoint won't re-nag a
|
||||||
|
// sentence the user already accepted or dismissed: the model returns the same
|
||||||
|
// raw batch on the next pass (it has no memory), but the resolved edits are
|
||||||
|
// suppressed. This is the "accept it, then it nags again moments later" bug.
|
||||||
|
func TestResolvedSuggestionsNotReproposed(t *testing.T) {
|
||||||
|
client := &stubClient{response: `{"suggestions":[
|
||||||
|
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||||
|
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||||
|
]}`}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
// Zero the checkpoint floor so the second pass runs instead of being throttled.
|
||||||
|
h.Limit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
var got []db.Suggestion
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("first pass: want 2, got %d", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept one, dismiss the other.
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", "")
|
||||||
|
do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", "")
|
||||||
|
|
||||||
|
// Second checkpoint returns the identical raw batch — both must be suppressed.
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second check: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var again []db.Suggestion
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("resolved suggestions should not be re-proposed, got %d: %+v", len(again), again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||||
// disjoint pending families: running one never wipes the other's flags, and
|
// disjoint pending families: running one never wipes the other's flags, and
|
||||||
// each endpoint returns the unified pending set.
|
// each endpoint returns the unified pending set.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface Props {
|
|||||||
animationData?: object
|
animationData?: object
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
// Mirror the animation left↔right (for assets drawn facing the wrong way).
|
||||||
|
flip?: boolean
|
||||||
fallback: React.ReactNode
|
fallback: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ function fitToContent(anim: AnimationItem, svg: SVGSVGElement) {
|
|||||||
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
// A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully
|
||||||
// offline). Reloads the animation whenever the data changes — moods swap by
|
// offline). Reloads the animation whenever the data changes — moods swap by
|
||||||
// passing a different `animationData`.
|
// passing a different `animationData`.
|
||||||
export function LottiePlayer({ animationData, loop = true, className, fallback }: Props) {
|
export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const anim = useRef<AnimationItem | null>(null)
|
const anim = useRef<AnimationItem | null>(null)
|
||||||
|
|
||||||
@@ -74,5 +76,12 @@ export function LottiePlayer({ animationData, loop = true, className, fallback }
|
|||||||
}, [animationData, loop])
|
}, [animationData, loop])
|
||||||
|
|
||||||
if (!animationData) return <>{fallback}</>
|
if (!animationData) return <>{fallback}</>
|
||||||
return <div ref={ref} className={className} aria-hidden />
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
style={flip ? { transform: 'scaleX(-1)' } : undefined}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
onMouseEnter={holdBubble}
|
onMouseEnter={holdBubble}
|
||||||
onMouseLeave={releaseBubble}
|
onMouseLeave={releaseBubble}
|
||||||
className="petal-bubble pointer-events-auto max-w-[260px] cursor-pointer p-3 pr-3.5"
|
className="petal-bubble pointer-events-auto max-w-[420px] cursor-pointer p-4 pr-5"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
@@ -158,10 +158,16 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
}}
|
}}
|
||||||
title="Click to dismiss"
|
title="Click to dismiss"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-bold leading-snug" style={{ color: 'var(--color-plum)' }}>
|
<p
|
||||||
|
className="font-bold leading-snug"
|
||||||
|
style={{ color: 'var(--color-plum)', fontSize: '1.4rem' }}
|
||||||
|
>
|
||||||
{bubble.zh}
|
{bubble.zh}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs leading-snug" style={{ color: 'var(--color-muted)' }}>
|
<p
|
||||||
|
className="mt-0.5 leading-snug"
|
||||||
|
style={{ color: 'var(--color-muted)', fontSize: '1.15rem' }}
|
||||||
|
>
|
||||||
{bubble.en}
|
{bubble.en}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,8 +180,9 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
aria-label="Choose a companion"
|
aria-label="Choose a companion"
|
||||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
width: 144,
|
// Size scales with the viewport — see --petal-companion-size in index.css.
|
||||||
height: 144,
|
width: 'var(--petal-companion-size)',
|
||||||
|
height: 'var(--petal-companion-size)',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
borderRadius: 'var(--radius-pill)',
|
borderRadius: 'var(--radius-pill)',
|
||||||
background: 'var(--color-surface-alt)',
|
background: 'var(--color-surface-alt)',
|
||||||
@@ -189,8 +196,13 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
|
|||||||
<LottiePlayer
|
<LottiePlayer
|
||||||
key={companion.id}
|
key={companion.id}
|
||||||
animationData={animationData}
|
animationData={animationData}
|
||||||
className="h-32 w-32"
|
flip={companion.flip}
|
||||||
fallback={<span style={{ fontSize: 72, lineHeight: 1 }}>{MOOD_EMOJI[renderMood]}</span>}
|
className="petal-companion-art"
|
||||||
|
fallback={
|
||||||
|
<span style={{ fontSize: 'calc(var(--petal-companion-size) * 0.5)', lineHeight: 1 }}>
|
||||||
|
{MOOD_EMOJI[renderMood]}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{napping && (
|
{napping && (
|
||||||
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
<span className="petal-zzz absolute" aria-hidden style={{ color: 'var(--color-muted)' }}>
|
||||||
|
|||||||
1
web/src/components/Companion/animations/butterfly.json
Normal file
1
web/src/components/Companion/animations/butterfly.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/parrot.json
Normal file
1
web/src/components/Companion/animations/parrot.json
Normal file
File diff suppressed because one or more lines are too long
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
1
web/src/components/Companion/animations/wiggle-dog.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,9 @@
|
|||||||
import type { Mood } from './useCompanion'
|
import type { Mood } from './useCompanion'
|
||||||
import sleepingCat from './animations/sleeping-cat.json'
|
import sleepingCat from './animations/sleeping-cat.json'
|
||||||
import happyDog from './animations/happy-dog.json'
|
import happyDog from './animations/happy-dog.json'
|
||||||
|
import wiggleDog from './animations/wiggle-dog.json'
|
||||||
|
import butterfly from './animations/butterfly.json'
|
||||||
|
import parrot from './animations/parrot.json'
|
||||||
|
|
||||||
export interface Companion {
|
export interface Companion {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,6 +15,9 @@ export interface Companion {
|
|||||||
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
||||||
// (e.g. a cat that's always asleep but still talks in its sleep).
|
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||||
alwaysAsleep?: boolean
|
alwaysAsleep?: boolean
|
||||||
|
// When true the rendered animation is mirrored left↔right — for assets drawn
|
||||||
|
// facing the "wrong" way for our bottom-right corner (e.g. the parrot).
|
||||||
|
flip?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||||
@@ -44,6 +50,43 @@ export const COMPANIONS: Companion[] = [
|
|||||||
// no sleeping clip → stays in its idle pose instead of visibly napping
|
// no sleeping clip → stays in its idle pose instead of visibly napping
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wiggle-dog',
|
||||||
|
name: 'Wiggle Dog',
|
||||||
|
zh: '摇尾狗',
|
||||||
|
emoji: '🐕',
|
||||||
|
animations: {
|
||||||
|
idle: wiggleDog,
|
||||||
|
happy: wiggleDog,
|
||||||
|
talking: wiggleDog,
|
||||||
|
celebrate: wiggleDog,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'butterfly',
|
||||||
|
name: 'Butterfly',
|
||||||
|
zh: '蝴蝶',
|
||||||
|
emoji: '🦋',
|
||||||
|
animations: {
|
||||||
|
idle: butterfly,
|
||||||
|
happy: butterfly,
|
||||||
|
talking: butterfly,
|
||||||
|
celebrate: butterfly,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'parrot',
|
||||||
|
name: 'Parrot',
|
||||||
|
zh: '鹦鹉',
|
||||||
|
emoji: '🦜',
|
||||||
|
flip: true, // asset faces left; mirror it to face into the page
|
||||||
|
animations: {
|
||||||
|
idle: parrot,
|
||||||
|
happy: parrot,
|
||||||
|
talking: parrot,
|
||||||
|
celebrate: parrot,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DEFAULT_COMPANION = 'cat'
|
export const DEFAULT_COMPANION = 'cat'
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ const PROACTIVE_GAP = 40_000 // floor between any two unsolicited bubbles
|
|||||||
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
// How long a bubble lingers. These are *floors* — readBubbleMs extends them by
|
||||||
// how much there is to read, since she reads both the Mandarin and the English
|
// how much there is to read, since she reads both the Mandarin and the English
|
||||||
// (and tips now quote a slice of her own sentence, so they run longer).
|
// (and tips now quote a slice of her own sentence, so they run longer).
|
||||||
const BUBBLE_MS = 9_000 // baseline for a tip / break
|
const BUBBLE_MS = 14_000 // baseline for a tip / break
|
||||||
const CHEER_MS = 6_000 // a short cheer still needs a beat in two languages
|
const CHEER_MS = 9_000 // a short cheer still needs a beat in two languages
|
||||||
const READ_MS_PER_CHAR = 45 // ~22 chars/sec, generous for a bilingual read
|
const READ_MS_PER_CHAR = 55 // ~18 chars/sec, generous for a bilingual ESL read
|
||||||
const MAX_BUBBLE_MS = 20_000 // cap so a long quote can't pin the bubble forever
|
const MAX_BUBBLE_MS = 32_000 // cap so a long quote can't pin the bubble forever
|
||||||
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
const HOVER_GRACE_MS = 3_000 // lingers this long after she stops hovering
|
||||||
const now = () => Date.now()
|
const now = () => Date.now()
|
||||||
|
|
||||||
|
|||||||
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
54
web/src/components/Editor/SuggestionHighlight.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { foldTypography, findRange } from './SuggestionHighlight'
|
||||||
|
import { Schema, type Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
|
||||||
|
// A minimal doc/paragraph/text schema, enough to exercise findRange's textblock
|
||||||
|
// walk without pulling in the full editor.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build a single-paragraph doc with the given plaintext.
|
||||||
|
const para = (text: string): PMNode => schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||||
|
|
||||||
|
describe('foldTypography', () => {
|
||||||
|
it('folds curly quotes back to straight ASCII', () => {
|
||||||
|
expect(foldTypography('“He’s here,” she said').folded).toBe('"He\'s here," she said')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folds dashes and ellipsis, keeping a source-index map across length changes', () => {
|
||||||
|
const { folded, map } = foldTypography('wait—really...')
|
||||||
|
expect(folded).toBe('wait—really…')
|
||||||
|
// The em dash is already one glyph; the `...` collapsed 3 source chars to 1,
|
||||||
|
// so the trailing sentinel still points just past the last source char.
|
||||||
|
expect(map[map.length - 1]).toBe('wait—really...'.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats `--` and `...` typed-but-unconverted as their canonical glyphs', () => {
|
||||||
|
expect(foldTypography('a--b...c').folded).toBe('a—b…c')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findRange typographic anchoring', () => {
|
||||||
|
it('anchors a model echo with straight quotes onto curly-quote document text', () => {
|
||||||
|
const doc = para('She said “hello” to me')
|
||||||
|
const range = findRange(doc, '“hello”'.replace(/[“”]/g, '"')) // model echoed "hello"
|
||||||
|
expect(range).not.toBeNull()
|
||||||
|
// The matched span must cover the curly-quoted region in the real document.
|
||||||
|
expect(doc.textBetween(range!.from - 1, range!.to - 1)).toContain('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anchors an ASCII `--` echo onto an em-dash in the document', () => {
|
||||||
|
const doc = para('I waited—forever')
|
||||||
|
const range = findRange(doc, 'waited--forever')
|
||||||
|
expect(range).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still returns null when the text genuinely is not present', () => {
|
||||||
|
expect(findRange(para('nothing to see'), 'absent phrase')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,21 +42,87 @@ export function mapOffset(block: PMNode, blockPos: number, targetOffset: number)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// foldTypography canonicalizes the typographic variants that the editor's
|
||||||
|
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
|
||||||
|
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
|
||||||
|
// suggestion's `original` from the same text but often normalizes these back to
|
||||||
|
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
|
||||||
|
// the suggestion could be neither highlighted nor applied.
|
||||||
|
//
|
||||||
|
// It returns the folded string plus `map`, where map[k] is the source index at
|
||||||
|
// which folded character k begins. A multi-character source run (`...`, `--`)
|
||||||
|
// folds to one glyph, so lengths differ; map projects a match in folded space
|
||||||
|
// back onto real document offsets. map has a trailing sentinel (= source length)
|
||||||
|
// so map[start + folded.length] yields the offset just past the match.
|
||||||
|
export function foldTypography(text: string): { folded: string; map: number[] } {
|
||||||
|
let folded = ''
|
||||||
|
const map: number[] = []
|
||||||
|
let i = 0
|
||||||
|
while (i < text.length) {
|
||||||
|
// Multi-character ASCII sequences the Typography rules would have replaced.
|
||||||
|
if (text.startsWith('...', i)) {
|
||||||
|
folded += '…'
|
||||||
|
map.push(i)
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text[i] === '-' && text[i + 1] === '-') {
|
||||||
|
folded += '—'
|
||||||
|
map.push(i)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folded += foldChar(text[i])
|
||||||
|
map.push(i)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
map.push(text.length)
|
||||||
|
return { folded, map }
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldChar maps a single character onto its canonical form (length-preserving).
|
||||||
|
function foldChar(c: string): string {
|
||||||
|
switch (c) {
|
||||||
|
case '‘':
|
||||||
|
case '’':
|
||||||
|
case '‚':
|
||||||
|
case '‛':
|
||||||
|
return "'"
|
||||||
|
case '“':
|
||||||
|
case '”':
|
||||||
|
case '„':
|
||||||
|
case '‟':
|
||||||
|
return '"'
|
||||||
|
case '–': // en dash → em dash, the canonical form `--` also folds to
|
||||||
|
return '—'
|
||||||
|
case ' ': // non-breaking space
|
||||||
|
case ' ': // thin space
|
||||||
|
case ' ': // narrow no-break space
|
||||||
|
return ' '
|
||||||
|
default:
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// findRange locates the first occurrence of `search` within a single textblock
|
// findRange locates the first occurrence of `search` within a single textblock
|
||||||
// and returns its ProseMirror range, or null if the string isn't present (the
|
// and returns its ProseMirror range, or null if the string isn't present (the
|
||||||
// user may have edited or removed it since the checkpoint ran). Exported so the
|
// user may have edited or removed it since the checkpoint ran). Matching is done
|
||||||
// accept flow can resolve the same span to replace.
|
// in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
|
||||||
|
// differences between the model's echo and the live document don't strand the
|
||||||
|
// anchor. Exported so the accept flow can resolve the same span to replace.
|
||||||
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||||||
if (!search) return null
|
const needle = foldTypography(search).folded
|
||||||
|
if (!needle) return null
|
||||||
let result: { from: number; to: number } | null = null
|
let result: { from: number; to: number } | null = null
|
||||||
doc.descendants((node, pos) => {
|
doc.descendants((node, pos) => {
|
||||||
if (result) return false
|
if (result) return false
|
||||||
if (!node.isTextblock) return true // keep descending to the textblock
|
if (!node.isTextblock) return true // keep descending to the textblock
|
||||||
const idx = node.textContent.indexOf(search)
|
const { folded, map } = foldTypography(node.textContent)
|
||||||
|
const idx = folded.indexOf(needle)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
result = {
|
result = {
|
||||||
from: mapOffset(node, pos, idx),
|
from: mapOffset(node, pos, map[idx]),
|
||||||
to: mapOffset(node, pos, idx + search.length),
|
to: mapOffset(node, pos, map[idx + needle.length]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false // never descend into a textblock's inline children
|
return false // never descend into a textblock's inline children
|
||||||
|
|||||||
@@ -319,9 +319,17 @@ button, a, input {
|
|||||||
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
The cozy corner mascot. Gently bobs while awake, settles and sways slowly
|
||||||
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
while napping; its speech bubble pops in; little zzz drift up when asleep. */
|
||||||
.petal-companion {
|
.petal-companion {
|
||||||
|
/* Mascot size scales with the viewport width: ~original on a laptop, up to
|
||||||
|
~2× on a large desktop. Tune the middle (vw) term to taste. */
|
||||||
|
--petal-companion-size: clamp(10rem, 17vw, 20rem);
|
||||||
animation: petal-bob 3.2s ease-in-out infinite;
|
animation: petal-bob 3.2s ease-in-out infinite;
|
||||||
transition: transform 200ms ease;
|
transition: transform 200ms ease;
|
||||||
}
|
}
|
||||||
|
/* The Lottie art sits inside the round badge with a little breathing room. */
|
||||||
|
.petal-companion-art {
|
||||||
|
width: 88%;
|
||||||
|
height: 88%;
|
||||||
|
}
|
||||||
.petal-companion:hover {
|
.petal-companion:hover {
|
||||||
transform: translateY(-2px) scale(1.04);
|
transform: translateY(-2px) scale(1.04);
|
||||||
}
|
}
|
||||||
@@ -347,10 +355,10 @@ button, a, input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.petal-zzz {
|
.petal-zzz {
|
||||||
top: 6px;
|
top: calc(var(--petal-companion-size) * 0.04);
|
||||||
right: 14px;
|
right: calc(var(--petal-companion-size) * 0.1);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.1rem;
|
font-size: calc(var(--petal-companion-size) * 0.12);
|
||||||
animation: petal-zzz 2.4s ease-in-out infinite;
|
animation: petal-zzz 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@keyframes petal-zzz {
|
@keyframes petal-zzz {
|
||||||
|
|||||||
Reference in New Issue
Block a user