Suggestions: right-margin comment rail + Mandarin explanations
Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
|
|||||||
{Role: "user", Content: text},
|
{Role: "user", Content: text},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// translateSystemPrompt drives the explanation translator: it renders a
|
||||||
|
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
|
||||||
|
// the "why" in her first language. Strict about returning ONLY the translation
|
||||||
|
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
|
||||||
|
// bubble. Kept warm and plain — these are short, friendly one-liners.
|
||||||
|
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
|
||||||
|
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
|
||||||
|
`suggestion, written for a native Chinese speaker learning English.
|
||||||
|
|
||||||
|
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
|
||||||
|
`just the translated sentence.`
|
||||||
|
|
||||||
|
// TranslateMessages builds the message array for translating one short English
|
||||||
|
// explanation into Simplified Chinese.
|
||||||
|
func TranslateMessages(text string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: translateSystemPrompt},
|
||||||
|
{Role: "user", Content: text},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
23
internal/llm/translate.go
Normal file
23
internal/llm/translate.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunTranslate renders a short English explanation into Simplified Chinese. It
|
||||||
|
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
|
||||||
|
// temperature so the translation is faithful rather than creative. Output is
|
||||||
|
// trimmed of any stray surrounding quotes the model may add.
|
||||||
|
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
|
||||||
|
out, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: TranslateMessages(text),
|
||||||
|
MaxTokens: 512,
|
||||||
|
Temperature: 0.2,
|
||||||
|
TopP: 0.9,
|
||||||
|
RepetitionPenalty: 1.1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cleanRewrite(out), nil
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r.Post("/{id}/accept", h.accept)
|
r.Post("/{id}/accept", h.accept)
|
||||||
r.Post("/{id}/dismiss", h.dismiss)
|
r.Post("/{id}/dismiss", h.dismiss)
|
||||||
r.Post("/{id}/chat", h.chat)
|
r.Post("/{id}/chat", h.chat)
|
||||||
|
r.Post("/{id}/translate", h.translate)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
internal/suggestions/translate.go
Normal file
57
internal/suggestions/translate.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package suggestions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type translateResponse struct {
|
||||||
|
Translation string `json:"translation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// translate renders a suggestion's English explanation into Simplified Chinese
|
||||||
|
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
|
||||||
|
// language instead of a second copy of the same English text. The explanation is
|
||||||
|
// loaded server-side from the suggestion id (scoped to the local user) and never
|
||||||
|
// trusted from the client, mirroring chat (spec Note #10).
|
||||||
|
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sugID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
var explanation string
|
||||||
|
err := h.DB.QueryRow(
|
||||||
|
`SELECT s.explanation
|
||||||
|
FROM suggestions s
|
||||||
|
JOIN documents d ON d.id = s.doc_id
|
||||||
|
WHERE s.id = ? AND d.user_id = ?`,
|
||||||
|
sugID, db.LocalUserID,
|
||||||
|
).Scan(&explanation)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
errorJSON(w, http.StatusNotFound, "suggestion not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
explanation = strings.TrimSpace(explanation)
|
||||||
|
if explanation == "" {
|
||||||
|
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
|
||||||
|
if err != nil {
|
||||||
|
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
|
||||||
|
}
|
||||||
@@ -147,6 +147,10 @@ export const api = {
|
|||||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||||
dismissSuggestion: (id: string) =>
|
dismissSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
|
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
|
||||||
|
// opening bubble (the explanation itself stays English in the card body).
|
||||||
|
translateSuggestion: (id: string) =>
|
||||||
|
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
|
||||||
|
|
||||||
// Version history. listVersions returns metadata only (no bodies); getVersion
|
// Version history. listVersions returns metadata only (no bodies); getVersion
|
||||||
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
// loads one full snapshot for preview; snapshotDoc takes an explicit restore
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
|
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestionId: string
|
suggestionId: string
|
||||||
// Pre-populates Petal's first bubble so the conversation opens with context.
|
// The English explanation (shown in the card body). Petal's opening bubble is
|
||||||
|
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
|
||||||
|
// just repeat the same English text twice. Falls back to this on failure.
|
||||||
explanation: string
|
explanation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
|
|||||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||||
// token into the latest assistant bubble.
|
// token into the latest assistant bubble.
|
||||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
// Opening bubble starts empty (caret-only) and fills with the Mandarin
|
||||||
{ role: 'assistant', content: explanation },
|
// translation once it lands; `seeding` drives that loading caret.
|
||||||
])
|
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||||
|
const [seeding, setSeeding] = useState(true)
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [streaming, setStreaming] = useState(false)
|
const [streaming, setStreaming] = useState(false)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch the Chinese translation of the explanation to seed the first bubble.
|
||||||
|
// Only replaces the seed bubble if the user hasn't started chatting yet (the
|
||||||
|
// conversation always opens with this one assistant turn). Falls back to the
|
||||||
|
// English explanation if the translation can't be fetched.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
api
|
||||||
|
.translateSuggestion(suggestionId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const text = res.translation.trim() || explanation
|
||||||
|
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSeeding(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [suggestionId, explanation])
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || streaming) return
|
if (!text || streaming) return
|
||||||
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
style={{ maxHeight: 220 }}
|
style={{ maxHeight: 220 }}
|
||||||
>
|
>
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
|
<Bubble
|
||||||
|
key={i}
|
||||||
|
role={m.role}
|
||||||
|
content={m.content}
|
||||||
|
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Toolbar } from '../Toolbar/Toolbar'
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
import { SuggestionCard } from './SuggestionCard'
|
import { SuggestionCard } from './SuggestionCard'
|
||||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||||
|
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||||
import { MisspellCard } from './MisspellCard'
|
import { MisspellCard } from './MisspellCard'
|
||||||
import { WordCard } from './WordCard'
|
import { WordCard } from './WordCard'
|
||||||
@@ -219,6 +220,18 @@ export function EditorCore({
|
|||||||
const rewriteReqRef = useRef(0)
|
const rewriteReqRef = useRef(0)
|
||||||
// The in-document Find & Replace bar (Ctrl/Cmd+F).
|
// The in-document Find & Replace bar (Ctrl/Cmd+F).
|
||||||
const [findOpen, setFindOpen] = useState(false)
|
const [findOpen, setFindOpen] = useState(false)
|
||||||
|
// The right-margin comment rail. `railItems` carries each anchored suggestion's
|
||||||
|
// vertical offset; `railEnabled` is true only when the viewport has room for the
|
||||||
|
// column beside the editor (otherwise we fall back to the inline hover cards).
|
||||||
|
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
|
||||||
|
// `activeId` is the suggestion currently emphasized (hovered text or card).
|
||||||
|
const [railItems, setRailItems] = useState<RailItem[]>([])
|
||||||
|
const [railEnabled, setRailEnabled] = useState(false)
|
||||||
|
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
// A stable handle to the latest recompute so the editor's onUpdate (captured
|
||||||
|
// once at construction) can trigger a re-measure without stale closures.
|
||||||
|
const recomputeRailRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -282,6 +295,8 @@ export function EditorCore({
|
|||||||
content_text: editor.getText(),
|
content_text: editor.getText(),
|
||||||
word_count: editor.storage.characterCount.words(),
|
word_count: editor.storage.characterCount.words(),
|
||||||
})
|
})
|
||||||
|
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||||
|
recomputeRailRef.current()
|
||||||
},
|
},
|
||||||
onSelectionUpdate: ({ editor }) => {
|
onSelectionUpdate: ({ editor }) => {
|
||||||
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
// A selection supersedes the hover gloss; an empty one clears the bubble.
|
||||||
@@ -333,8 +348,87 @@ export function EditorCore({
|
|||||||
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
setSuggestions(editor.state, editor.view.dispatch, suggestions)
|
||||||
// Close the card if its suggestion is gone.
|
// Close the card if its suggestion is gone.
|
||||||
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
|
||||||
|
// Drop any rail expand/emphasis that points at a now-removed suggestion.
|
||||||
|
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||||
|
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
|
||||||
}, [editor, suggestions])
|
}, [editor, suggestions])
|
||||||
|
|
||||||
|
// Re-measure the margin rail: whether there's room for the column beside the
|
||||||
|
// editor, and where each suggestion's highlight sits vertically. Anchors are
|
||||||
|
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
|
||||||
|
// the wrapper — stable under scroll since text and wrapper scroll together.
|
||||||
|
// Deferred a frame so decoration DOM and reflow have settled.
|
||||||
|
const recomputeRail = useCallback(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
||||||
|
// a little breathing space to the viewport edge.
|
||||||
|
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const items: RailItem[] = []
|
||||||
|
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||||
|
const id = el.getAttribute('data-suggestion-id')
|
||||||
|
if (!id || seen.has(id)) return
|
||||||
|
const s = suggestions.find((x) => x.id === id)
|
||||||
|
if (!s) return
|
||||||
|
seen.add(id)
|
||||||
|
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
|
||||||
|
})
|
||||||
|
setRailItems(items)
|
||||||
|
})
|
||||||
|
}, [suggestions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recomputeRailRef.current = recomputeRail
|
||||||
|
}, [recomputeRail])
|
||||||
|
|
||||||
|
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||||
|
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
||||||
|
useEffect(() => {
|
||||||
|
recomputeRail()
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
||||||
|
if (wrapper && ro) ro.observe(wrapper)
|
||||||
|
window.addEventListener('resize', recomputeRail)
|
||||||
|
return () => {
|
||||||
|
ro?.disconnect()
|
||||||
|
window.removeEventListener('resize', recomputeRail)
|
||||||
|
}
|
||||||
|
}, [recomputeRail])
|
||||||
|
|
||||||
|
// Emphasize the flagged text for the active suggestion, mirroring the rail
|
||||||
|
// card ↔ text link both ways. Driven through the decoration plugin (not an
|
||||||
|
// imperative DOM class) so it survives the repaints that fire on every edit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
|
||||||
|
}, [editor, activeId, railEnabled])
|
||||||
|
|
||||||
|
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
|
||||||
|
// rail card jumps the editor to the flagged text).
|
||||||
|
const scrollToSuggestion = useCallback((id: string) => {
|
||||||
|
const el = wrapperRef.current?.querySelector(
|
||||||
|
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
|
||||||
|
)
|
||||||
|
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clicking a rail card's body jumps to the text and toggles its detail panel.
|
||||||
|
const activateRailCard = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveId(id)
|
||||||
|
scrollToSuggestion(id)
|
||||||
|
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||||
|
},
|
||||||
|
[scrollToSuggestion],
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleRailExpand = useCallback((id: string) => {
|
||||||
|
setRailExpandedId((cur) => (cur === id ? null : id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openCardFor = useCallback(
|
const openCardFor = useCallback(
|
||||||
(id: string, el: HTMLElement) => {
|
(id: string, el: HTMLElement) => {
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
@@ -366,10 +460,17 @@ export function EditorCore({
|
|||||||
if (!target) return
|
if (!target) return
|
||||||
const id = target.getAttribute('data-suggestion-id')
|
const id = target.getAttribute('data-suggestion-id')
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
// With the rail open the card already lives in the margin — hovering the
|
||||||
|
// text just emphasizes its card (and the highlight) rather than popping a
|
||||||
|
// second, redundant floating card.
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
openCardFor(id, target)
|
openCardFor(id, target)
|
||||||
},
|
},
|
||||||
[openCardFor],
|
[openCardFor, railEnabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
const scheduleClose = useCallback(() => {
|
const scheduleClose = useCallback(() => {
|
||||||
@@ -389,35 +490,53 @@ export function EditorCore({
|
|||||||
// pointer can bridge the small gap from text to card.
|
// pointer can bridge the small gap from text to card.
|
||||||
const handleMouseOut = useCallback(
|
const handleMouseOut = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
|
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduleClose()
|
||||||
},
|
},
|
||||||
[scheduleClose],
|
[scheduleClose, railEnabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||||
|
|
||||||
// Accept applies the replacement to the document, plays a little confetti
|
// Accept applies the replacement to the document, plays a little confetti
|
||||||
// burst where the card sat, then notifies the parent.
|
// burst over the flagged text, then notifies the parent. The confetti is
|
||||||
|
// 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.
|
||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
(s: Suggestion) => {
|
(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 }
|
||||||
|
}
|
||||||
if (editor && s.replacement.trim() !== '') {
|
if (editor && s.replacement.trim() !== '') {
|
||||||
const range = findRange(editor.state.doc, s.original)
|
const range = findRange(editor.state.doc, s.original)
|
||||||
if (range) {
|
if (range) {
|
||||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHover((h) => {
|
// Fall back to the hover card's position if the highlight wasn't found.
|
||||||
if (h) {
|
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
|
||||||
setConfetti({ top: h.top, left: h.left + 16 })
|
if (burst) {
|
||||||
clearTimeout(confettiTimer.current)
|
setConfetti(burst)
|
||||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
clearTimeout(confettiTimer.current)
|
||||||
}
|
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||||
return h
|
}
|
||||||
})
|
|
||||||
closeCard()
|
closeCard()
|
||||||
|
setRailExpandedId(null)
|
||||||
onAccept(s)
|
onAccept(s)
|
||||||
},
|
},
|
||||||
[editor, onAccept, closeCard],
|
[editor, onAccept, closeCard, hover],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDismiss = useCallback(
|
const handleDismiss = useCallback(
|
||||||
@@ -439,7 +558,16 @@ export function EditorCore({
|
|||||||
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
|
||||||
if (suggestionEl) {
|
if (suggestionEl) {
|
||||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||||
if (id) openCardFor(id, suggestionEl)
|
if (id) {
|
||||||
|
// With the rail open, a tap emphasizes and expands its margin card
|
||||||
|
// instead of opening a floating one.
|
||||||
|
if (railEnabled) {
|
||||||
|
setActiveId(id)
|
||||||
|
setRailExpandedId(id)
|
||||||
|
} else {
|
||||||
|
openCardFor(id, suggestionEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editor || !spellChecker) return
|
if (!editor || !spellChecker) return
|
||||||
@@ -461,7 +589,7 @@ export function EditorCore({
|
|||||||
setWordInfo(null)
|
setWordInfo(null)
|
||||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
},
|
},
|
||||||
[editor, spellChecker, closeCard, openCardFor],
|
[editor, spellChecker, closeCard, openCardFor, railEnabled],
|
||||||
)
|
)
|
||||||
|
|
||||||
const replaceMisspelling = useCallback(
|
const replaceMisspelling = useCallback(
|
||||||
@@ -854,7 +982,7 @@ export function EditorCore({
|
|||||||
onAdd={addMisspellingToDict}
|
onAdd={addMisspellingToDict}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hover && (
|
{hover && !railEnabled && (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
suggestion={hover.suggestion}
|
suggestion={hover.suggestion}
|
||||||
style={{ top: hover.top, left: hover.left }}
|
style={{ top: hover.top, left: hover.left }}
|
||||||
@@ -865,6 +993,18 @@ export function EditorCore({
|
|||||||
onExpandChange={setPinned}
|
onExpandChange={setPinned}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{railEnabled && railItems.length > 0 && (
|
||||||
|
<SuggestionRail
|
||||||
|
items={railItems}
|
||||||
|
activeId={activeId}
|
||||||
|
expandedId={railExpandedId}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onHover={setActiveId}
|
||||||
|
onActivate={activateRailCard}
|
||||||
|
onToggleExpand={toggleRailExpand}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
import type { Suggestion } from '../../api/client'
|
||||||
import { AskPetal } from './AskPetal'
|
import { AskPetal } from './AskPetal'
|
||||||
|
import { TYPE_META } from './suggestionMeta'
|
||||||
// Per-type accent color + human label, mirroring the design tokens.
|
|
||||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
|
||||||
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
|
||||||
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
|
||||||
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
|
||||||
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
|
||||||
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestion: Suggestion
|
suggestion: Suggestion
|
||||||
|
|||||||
@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
|
|||||||
|
|
||||||
interface PluginState {
|
interface PluginState {
|
||||||
suggestions: Suggestion[]
|
suggestions: Suggestion[]
|
||||||
|
// The suggestion currently emphasized from its margin card / a hover, or null.
|
||||||
|
// Carried in plugin state (not an imperative DOM class) so it survives the
|
||||||
|
// decoration repaints that fire on every document change.
|
||||||
|
activeId: string | null
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||||||
|
// list or a change to which suggestion is emphasized.
|
||||||
|
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
|
||||||
|
|
||||||
// mapOffset converts a character offset within a textblock's flattened text into
|
// mapOffset converts a character offset within a textblock's flattened text into
|
||||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||||
// breaks) that occupy a position but contribute no text.
|
// breaks) that occupy a position but contribute no text.
|
||||||
@@ -130,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
|
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
|
||||||
const decos: Decoration[] = []
|
const decos: Decoration[] = []
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
const range = findRange(doc, s.original)
|
const range = findRange(doc, s.original)
|
||||||
if (!range) continue
|
if (!range) continue
|
||||||
|
const active = s.id === activeId ? ' petal-suggestion-active' : ''
|
||||||
decos.push(
|
decos.push(
|
||||||
Decoration.inline(range.from, range.to, {
|
Decoration.inline(range.from, range.to, {
|
||||||
class: `petal-suggestion petal-suggestion-${s.type}`,
|
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
|
||||||
'data-suggestion-id': s.id,
|
'data-suggestion-id': s.id,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -148,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
|
|||||||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||||||
// rebuilt against the current document immediately.
|
// rebuilt against the current document immediately.
|
||||||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||||||
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
|
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
|
||||||
dispatch(tr)
|
dispatch(tr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
|
||||||
|
// the decorations so the flagged text gets the active wash. Driven by the margin
|
||||||
|
// rail's hover/selection so the card↔text link reads both ways.
|
||||||
|
export function setActiveSuggestion(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
activeId: string | null,
|
||||||
|
) {
|
||||||
|
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
|
||||||
|
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||||||
|
}
|
||||||
|
|
||||||
export const SuggestionHighlight = Extension.create({
|
export const SuggestionHighlight = Extension.create({
|
||||||
name: 'suggestionHighlight',
|
name: 'suggestionHighlight',
|
||||||
|
|
||||||
@@ -160,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
|
|||||||
new Plugin<PluginState>({
|
new Plugin<PluginState>({
|
||||||
key: suggestionPluginKey,
|
key: suggestionPluginKey,
|
||||||
state: {
|
state: {
|
||||||
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
|
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
|
||||||
apply(tr, value, _oldState, newState) {
|
apply(tr, value, _oldState, newState) {
|
||||||
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
|
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
||||||
if (meta) {
|
if (meta && 'suggestions' in meta) {
|
||||||
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
|
return {
|
||||||
|
suggestions: meta.suggestions,
|
||||||
|
activeId: value.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meta && 'activeId' in meta) {
|
||||||
|
return {
|
||||||
|
suggestions: value.suggestions,
|
||||||
|
activeId: meta.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// On any document change, re-anchor by string against the new doc.
|
// On any document change, re-anchor by string against the new doc.
|
||||||
if (tr.docChanged) {
|
if (tr.docChanged) {
|
||||||
return {
|
return {
|
||||||
suggestions: value.suggestions,
|
suggestions: value.suggestions,
|
||||||
decorations: buildDecorations(newState.doc, value.suggestions),
|
activeId: value.activeId,
|
||||||
|
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
211
web/src/components/Editor/SuggestionRail.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import type { Suggestion } from '../../api/client'
|
||||||
|
import { AskPetal } from './AskPetal'
|
||||||
|
import { TYPE_META } from './suggestionMeta'
|
||||||
|
|
||||||
|
// Vertical breathing room kept between stacked cards when their natural anchors
|
||||||
|
// would otherwise collide.
|
||||||
|
const CARD_GAP = 12
|
||||||
|
|
||||||
|
export interface RailItem {
|
||||||
|
suggestion: Suggestion
|
||||||
|
// Vertical offset (px) of the suggestion's highlight, relative to the editor
|
||||||
|
// wrapper — the position the card wants to sit beside.
|
||||||
|
anchorTop: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: RailItem[]
|
||||||
|
// The suggestion currently emphasized (its highlight hovered, or this card
|
||||||
|
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
|
||||||
|
activeId: string | null
|
||||||
|
// The card expanded to show the full explanation + Ask Petal, or null.
|
||||||
|
expandedId: string | null
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
// Pointer entering/leaving a card, so the matching highlight can light up.
|
||||||
|
onHover: (id: string | null) => void
|
||||||
|
// A card's body was clicked — scroll its highlight into view and toggle expand.
|
||||||
|
onActivate: (id: string) => void
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionRail is the right-margin "comment column": every outstanding
|
||||||
|
// suggestion as a card, vertically aligned to the text it flags, so the writer
|
||||||
|
// sees the whole queue at once instead of hunting highlight by highlight. Cards
|
||||||
|
// are anchored to their highlight's vertical position and pushed down only as far
|
||||||
|
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
|
||||||
|
export function SuggestionRail({
|
||||||
|
items,
|
||||||
|
activeId,
|
||||||
|
expandedId,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onHover,
|
||||||
|
onActivate,
|
||||||
|
onToggleExpand,
|
||||||
|
}: Props) {
|
||||||
|
// Measured resolved tops keyed by suggestion id (after collision avoidance).
|
||||||
|
const [tops, setTops] = useState<Record<string, number>>({})
|
||||||
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
|
||||||
|
// text) so the stack re-packs and cards below don't get overlapped.
|
||||||
|
const [measureTick, setMeasureTick] = useState(0)
|
||||||
|
const observerRef = useRef<ResizeObserver | null>(null)
|
||||||
|
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
|
||||||
|
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
|
||||||
|
}
|
||||||
|
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
|
||||||
|
|
||||||
|
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
|
||||||
|
// would overlap the previous card, in which case it slides down just enough.
|
||||||
|
// Re-runs whenever the anchors or the expanded card (which changes a height)
|
||||||
|
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
|
||||||
|
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
|
||||||
|
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let cursor = -Infinity
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const { suggestion, anchorTop } of ordered) {
|
||||||
|
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
|
||||||
|
const top = Math.max(anchorTop, cursor)
|
||||||
|
next[suggestion.id] = top
|
||||||
|
cursor = top + h + CARD_GAP
|
||||||
|
}
|
||||||
|
setTops((prev) => {
|
||||||
|
const ids = Object.keys(next)
|
||||||
|
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// layoutKey captures anchor positions; expandedId/measureTick capture height
|
||||||
|
// changes (toggling detail, Ask Petal streaming in).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [layoutKey, expandedId, measureTick])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="petal-rail petal-no-print" aria-label="Suggestions">
|
||||||
|
{ordered.map(({ suggestion }) => (
|
||||||
|
<RailCard
|
||||||
|
key={suggestion.id}
|
||||||
|
ref={(el) => {
|
||||||
|
const prev = cardRefs.current.get(suggestion.id)
|
||||||
|
if (prev && prev !== el) observerRef.current?.unobserve(prev)
|
||||||
|
if (el) {
|
||||||
|
cardRefs.current.set(suggestion.id, el)
|
||||||
|
observerRef.current?.observe(el)
|
||||||
|
} else {
|
||||||
|
cardRefs.current.delete(suggestion.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
suggestion={suggestion}
|
||||||
|
top={tops[suggestion.id] ?? 0}
|
||||||
|
active={activeId === suggestion.id}
|
||||||
|
expanded={expandedId === suggestion.id}
|
||||||
|
onAccept={onAccept}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onHover={onHover}
|
||||||
|
onActivate={onActivate}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
suggestion: Suggestion
|
||||||
|
top: number
|
||||||
|
active: boolean
|
||||||
|
expanded: boolean
|
||||||
|
onAccept: (s: Suggestion) => void
|
||||||
|
onDismiss: (s: Suggestion) => void
|
||||||
|
onHover: (id: string | null) => void
|
||||||
|
onActivate: (id: string) => void
|
||||||
|
onToggleExpand: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
|
||||||
|
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const meta = TYPE_META[suggestion.type]
|
||||||
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-label={`${meta.label} suggestion`}
|
||||||
|
onMouseEnter={() => onHover(suggestion.id)}
|
||||||
|
onMouseLeave={() => onHover(null)}
|
||||||
|
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
|
||||||
|
style={{ top, borderLeftColor: meta.color }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
|
||||||
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Dismiss suggestion"
|
||||||
|
onClick={() => onDismiss(suggestion)}
|
||||||
|
className="petal-rail-x"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The body toggles the expanded explanation + Ask Petal and points the
|
||||||
|
editor at the flagged text. */}
|
||||||
|
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
|
||||||
|
{hasReplacement && (
|
||||||
|
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
|
||||||
|
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{suggestion.original}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.replacement}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
|
||||||
|
style={{ color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{suggestion.explanation}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
{hasReplacement && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAccept(suggestion)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-bold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleExpand(suggestion.id)}
|
||||||
|
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{
|
||||||
|
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
|
||||||
|
color: 'var(--color-accent-hover)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
12
web/src/components/Editor/suggestionMeta.ts
Normal file
12
web/src/components/Editor/suggestionMeta.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { SuggestionType } from '../../api/client'
|
||||||
|
|
||||||
|
// Per-type accent color + human label, mirroring the design tokens. Shared by the
|
||||||
|
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
|
||||||
|
// type reads the same color/name wherever it surfaces.
|
||||||
|
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||||
|
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
|
||||||
|
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
|
||||||
|
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
|
||||||
|
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
|
||||||
|
voice: { color: 'var(--color-honey)', label: 'Voice' },
|
||||||
|
}
|
||||||
@@ -223,6 +223,67 @@ button, a, input {
|
|||||||
animation: petal-suggestion-in 200ms ease both;
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text emphasized from its margin card (or a hover) — a soft wash so the
|
||||||
|
card↔text link reads at a glance, both directions. */
|
||||||
|
.petal-suggestion-active {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-surface-alt);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Suggestion margin rail -------------------------------------------------
|
||||||
|
The right-hand "comment column": every outstanding suggestion as a card,
|
||||||
|
vertically aligned to the text it flags, so the whole queue is visible at a
|
||||||
|
glance instead of one-highlight-at-a-time on hover. It floats in the whitespace
|
||||||
|
just right of the editor column and is only mounted when there's room for it
|
||||||
|
(EditorCore measures the gap). Cards are absolutely positioned by a resolved
|
||||||
|
top (anchor + collision avoidance), so the container only anchors the column. */
|
||||||
|
.petal-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 100%;
|
||||||
|
margin-left: 32px;
|
||||||
|
width: 300px;
|
||||||
|
/* Below the companion mascot (z-40): where a stacked card reaches the
|
||||||
|
bottom-right corner it simply tucks behind the sleeping cat. */
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.petal-rail-card {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 3px solid var(--color-border); /* type color set inline */
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
/* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping.
|
||||||
|
The entrance uses fill `backwards` so its translateY doesn't linger and fight
|
||||||
|
the active-state transform once it's done. */
|
||||||
|
transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease,
|
||||||
|
transform 200ms ease, border-color 200ms ease;
|
||||||
|
animation: petal-suggestion-in 220ms ease backwards;
|
||||||
|
}
|
||||||
|
.petal-rail-card-active {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24);
|
||||||
|
border-top-color: var(--color-accent);
|
||||||
|
border-right-color: var(--color-accent);
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.petal-rail-x {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
.petal-rail-x:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-plum);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Find & Replace ---------------------------------------------------------
|
/* --- Find & Replace ---------------------------------------------------------
|
||||||
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
|
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
|
current match is brighter with a rose ring so it stands out as you step
|
||||||
|
|||||||
Reference in New Issue
Block a user