diff --git a/app/(tabs)/quill/actions.ts b/app/(tabs)/quill/actions.ts index 2ce8cc0..1aa18f3 100644 --- a/app/(tabs)/quill/actions.ts +++ b/app/(tabs)/quill/actions.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import { ensureScribe } from "@/lib/auth/scribe"; import { getDb, schema } from "@/lib/db"; import { classifyArc, MIN_ARC_SENTENCES, movingAverage } from "@/lib/quill/arc"; -import { rewriteFromDiff, type TargetRewrite } from "@/lib/quill/diff"; +import type { TargetRewrite } from "@/lib/quill/diff"; import { type HueSegment, tileInfluences } from "@/lib/quill/explain"; import { fingerprintDistance } from "@/lib/quill/fingerprint"; import { clampForModel, MAX_BAND_PARAGRAPHS } from "@/lib/quill/limits"; @@ -288,30 +288,7 @@ Rewrite the draft so it FEELS like the target while preserving: Make changes at the level of word choice, sentence rhythm, image-density, and connective tissue. Don't add new facts, characters, or events. Don't moralise. -Return your answer as an aligned diff: - -- "diff": an ordered list of text segments that, read together, spell out BOTH the - original and the rewrite. Each segment has "op": - - "same" — unchanged text shared by both versions (include the surrounding - untouched words, punctuation and spaces verbatim) - - "remove" — text present in the ORIGINAL but cut or toned down - - "add" — text present only in the REWRITE - Keep segments tight: wrap only the words that actually changed in remove/add, - and put a remove immediately before its replacement add. Preserve all - whitespace inside segments so the views read naturally. Concatenating every - non-"remove" segment MUST equal the rewritten prose; concatenating every - non-"add" segment MUST equal the original prose exactly.`; - -const RewriteResponseSchema = z.object({ - diff: z - .array( - z.object({ - text: z.string(), - op: z.enum(["same", "add", "remove"]), - }), - ) - .min(1), -}); +Return ONLY the rewritten prose — no preamble, no quotation marks, no commentary. Keep the paragraph breaks of the original.`; const INTENSITY_INSTRUCTIONS: Record = { 1: "Whisper — change at most one word every 2–3 sentences. Only the most natural synonym swap. The text must feel untouched.", @@ -323,8 +300,9 @@ const INTENSITY_INSTRUCTIONS: Record = { /** * Asks Claude to rewrite the user's draft toward a free-form target descriptor. - * Returns a structured rewrite — an aligned word-level diff — so the client can - * show green/pink highlighting and per-hunk accept/reject. + * Returns the rewritten prose as plain text; the client computes the word-level + * inline diff itself (diffWords), so we don't round-trip prose through a + * model-emitted segment array — that reconstruction dropped boundary spaces. * * @param intensity 1–5 controlling how aggressively to change the prose. * Defaults to 3 (Moderate). 1 is a near-invisible whisper; 5 is a @@ -343,18 +321,14 @@ export async function suggestRewrite(input: { const intensity = Math.min(5, Math.max(1, Math.round(input.intensity ?? 3))); const intensityLine = `Intensity: ${intensity}/5 — ${INTENSITY_INSTRUCTIONS[intensity]}`; - const { object } = await generateObject({ + const { text: out } = await generateText({ model: anthropic("claude-sonnet-4-6"), - schema: RewriteResponseSchema, system: REWRITE_SYSTEM_PROMPT, prompt: `${intensityLine}\nTarget: ${target}\n\nOriginal:\n${clampForModel(text)}`, maxRetries: 2, }); - return { - diff: object.diff, - rewrite: rewriteFromDiff(object.diff).trim(), - }; + return { rewrite: out.trim() }; } // --------------------------------------------------------------------------- diff --git a/app/(tabs)/quill/page.tsx b/app/(tabs)/quill/page.tsx index 6b9cfd9..e823688 100644 --- a/app/(tabs)/quill/page.tsx +++ b/app/(tabs)/quill/page.tsx @@ -14,8 +14,19 @@ import { import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { ArcChart } from "@/components/quill/arc-chart"; +import { + ColourPalette, + ColourSplash, + colourDropByKey, + type SplashState, +} from "@/components/quill/colour-drop"; import { DiffActions, DiffText } from "@/components/quill/diff-view"; -import { Editor, type EditorHandle, type SelectionRange } from "@/components/quill/editor"; +import { + type ColourDropDetail, + Editor, + type EditorHandle, + type SelectionRange, +} from "@/components/quill/editor"; import { HueExplainer } from "@/components/quill/hue-explainer"; import { TargetWidgets } from "@/components/quill/target-widgets"; import { useDiff } from "@/components/quill/use-diff"; @@ -65,7 +76,7 @@ type PanelPreset = "essentials" | "analyse" | "rewrite" | "custom"; const PANEL_PRESETS: Record, readonly string[]> = { essentials: ["hue", "save"], analyse: ["hue", "fingerprint", "arc", "neighbours", "save"], - rewrite: ["hue", "target", "save"], + rewrite: ["hue", "target", "colour", "save"], }; const CUSTOM_PANEL_OPTIONS = [ @@ -74,6 +85,7 @@ const CUSTOM_PANEL_OPTIONS = [ { key: "arc", label: "Emotional arc" }, { key: "neighbours", label: "Nearest authors" }, { key: "target", label: "Rewrite" }, + { key: "colour", label: "Colour drop" }, { key: "save", label: "Save to scribe" }, ] as const; @@ -118,6 +130,9 @@ export default function QuillPage() { const [rewriteError, setRewriteError] = useState(null); const [isRewriting, startRewrite] = useTransition(); const [highlightPending, setHighlightPending] = useState(false); + // Colour-drop splash overlay (null when idle). The page owns the two-step + // timing; the overlay just paints whatever phase it's handed. + const [splash, setSplash] = useState(null); // Pre-rewrite snapshots. Accepting a rewrite remounts the editor and wipes // TipTap's undo stack — this is the way back. const [versions, setVersions] = useState([]); @@ -130,6 +145,19 @@ export default function QuillPage() { rewrite ? (committedSelection?.text ?? plainText(draft)) : "", rewrite?.rewrite ?? "", ); + // Surrounding context for the diff. When the span opens mid-paragraph, its + // immediate before/after fragment hugs the diff inline (leadIn/tailOut) so a + // sub-paragraph rewrite reads as one paragraph; the remaining whole paragraphs + // render as greyed blocks above/below. + const beforeParts = committedSelection?.beforeText?.split("\n\n").filter(Boolean) ?? []; + const afterParts = committedSelection?.afterText?.split("\n\n").filter(Boolean) ?? []; + const leadIn = + committedSelection?.openStart && beforeParts.length + ? beforeParts[beforeParts.length - 1] + : undefined; + const tailOut = committedSelection?.openEnd && afterParts.length ? afterParts[0] : undefined; + const blockBefore = leadIn ? beforeParts.slice(0, -1) : beforeParts; + const blockAfter = tailOut ? afterParts.slice(1) : afterParts; // Bumping this remounts the Editor with new initialContent — TipTap // doesn't expose a reactive `value` prop and remount is the least // invasive way to replace the buffer when the user accepts a rewrite @@ -470,6 +498,47 @@ export default function QuillPage() { }); }; + // A colour swatch was dropped on a word. Splash lands at the drop point while + // the rewrite is in flight; when it returns we bloom the full splash over the + // affected span, then hand off to the same inline diff the Rewrite panel uses. + const handleColourDrop = (detail: ColourDropDetail) => { + if (splash) return; // one drop at a time + const colour = colourDropByKey(detail.colourKey); + if (!colour) return; + const colourCss = hueFromHSL(colour.hsl.hue, colour.hsl.saturation, colour.hsl.lightness).css; + setSplash({ colourCss, origin: detail.origin, ripples: detail.ripples, phase: "landing" }); + setCommittedSelection(detail.span); + setRewriteError(null); + setRewrite(null); + startRewrite(async () => { + try { + const result = await suggestRewrite({ + text: detail.span.text, + target: colour.target, + intensity, + }); + if (!result) { + setSplash(null); + setCommittedSelection(null); + toast.error( + "Drop on a fuller passage — the rewrite needs at least 8 words around the word.", + ); + return; + } + // Step two: bloom the splash, then reveal the diff once it settles. + setSplash((s) => (s ? { ...s, phase: "splash" } : s)); + window.setTimeout(() => { + setRewrite(result); + setSplash(null); + }, 950); + } catch (err) { + setSplash(null); + setCommittedSelection(null); + setRewriteError((err as Error).message); + } + }); + }; + const acceptRewrite = (text: string) => { const html = text .split(/\n\s*\n+/) @@ -539,9 +608,10 @@ export default function QuillPage() { aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-transparent via-ink-bleed to-transparent opacity-60" /> + {splash && } - {targetActive && rewrite ? ( -
+ {rewrite && ( +
setHighlightPending(true)} onHighlightLeave={() => setHighlightPending(false)} /> - {committedSelection?.beforeText?.split("\n\n").map((para) => ( -

- {para} -

- ))} - - {committedSelection?.afterText?.split("\n\n").map((para) => ( -

- {para} -

- ))} + {/* One block-flow prose container mirroring the editor exactly, + so before/diff/after paragraphs collapse margins uniformly. */} +
+ {blockBefore.map((para) => ( +

+ {para} +

+ ))} + + {blockAfter.map((para) => ( +

+ {para} +

+ ))} +
- ) : ( + )} + {/* Editor stays mounted under the diff (hidden) so editorRef stays + live — acceptRewrite's replaceRange splices into the real doc. */} +
setLiveSelection(sel?.text ?? null)} highlightBlock={highlight} + onColourDrop={handleColourDrop} /> - )} +
{stats.words > 0 && } @@ -646,6 +720,7 @@ export default function QuillPage() { /> )} {panelVisible("target") && } + {panelVisible("colour") && } {panelVisible("save") && ( c.key === key); +} + +export function ColourPalette() { + return ( + + +

Colour drop

+
+ {COLOUR_DROPS.map((c) => { + const css = hueFromHSL(c.hsl.hue, c.hsl.saturation, c.hsl.lightness).css; + return ( +
+

+ Drag a colour onto a word — the splash marks what gets rewritten toward that mood. +

+
+
+ ); +} + +export type SplashState = { + /** CSS colour of the dropped swatch. */ + colourCss: string; + /** Targeted word, in viewport px. */ + origin: { x: number; y: number }; + /** Sampled points across the affected span, in viewport px. */ + ripples: { x: number; y: number }[]; + /** "landing" while the rewrite is in flight, "splash" once it has arrived. */ + phase: "landing" | "splash"; +}; + +// Short radial spray of droplets flung from the impact point — angle (deg), +// distance (px) and size factor. Deterministic so it doesn't reshuffle on +// re-render, but irregular enough to read as spatter, not a tidy ring. +const SPRAY = [ + { angle: 18, dist: 32, size: 0.5, spin: 40 }, + { angle: 96, dist: 48, size: 0.34, spin: 210 }, + { angle: 168, dist: 38, size: 0.62, spin: 120 }, + { angle: 232, dist: 54, size: 0.42, spin: 300 }, + { angle: 304, dist: 30, size: 0.55, spin: 70 }, +] as const; + +/** + * The splash overlay, painted over the editor card. Step one (`landing`) is an + * ink drop quivering at the impact point with an expanding ring — the stone + * hitting the water — held while Claude rewrites. Step two (`splash`) blooms a + * big organic ink blot on the word, throws a radial spray, then spatters along + * the words about to change. Edges are irregular (turbulence-displaced circles), + * so it reads as ink, not a disc. Pure visual; the page owns the timing. + */ +export function ColourSplash({ splash }: { splash: SplashState }) { + const ref = useRef(null); + const [rect, setRect] = useState(null); + + // Splash coords arrive in viewport px; convert to this overlay's local space. + useLayoutEffect(() => { + if (ref.current) setRect(ref.current.getBoundingClientRect()); + }, []); + + const local = (p: { x: number; y: number }) => + rect ? { x: p.x - rect.left, y: p.y - rect.top } : null; + const origin = local(splash.origin); + const colour = splash.colourCss; + + return ( + + ); +} + +/** Turbulence-displaced circle → an organic ink blot. `spatter` uses a rougher, + * smaller-feature filter for the flung droplets; otherwise it blooms. */ +function InkBlob({ + x, + y, + colour, + size, + className, + spatter = false, + spin = 0, + delay = 0, +}: { + x: number; + y: number; + colour: string; + size: number; + className?: string; + spatter?: boolean; + spin?: number; + delay?: number; +}) { + return ( + + ); +} + +/** The impact ring for the landing phase — a distorted stroked circle. */ +function InkRing({ x, y, colour, size }: { x: number; y: number; colour: string; size: number }) { + return ( + + ); +} + +/** Two turbulence filters shared by every blot: soft (the bloom) and rough (the + * smaller, spikier spatter). Rendered once, referenced by id. */ +function InkFilters() { + return ( + + ); +} diff --git a/components/quill/diff-view.tsx b/components/quill/diff-view.tsx index 51e3081..1446153 100644 --- a/components/quill/diff-view.tsx +++ b/components/quill/diff-view.tsx @@ -66,17 +66,30 @@ export function DiffText({ states, setHunkState, highlightPending = false, + leadIn, + tailOut, }: { segments: DiffSegment[]; states: Record; setHunkState: (id: string, next: HunkState) => void; highlightPending?: boolean; + /** Unchanged in-paragraph context hugging the start of the span, rendered + * greyed and inline so a mid-paragraph rewrite reads as one paragraph. */ + leadIn?: string; + /** Unchanged in-paragraph context hugging the end of the span. */ + tailOut?: string; }) { const paragraphs = buildParagraphGroups(segments); + const lastIndex = paragraphs.length - 1; + // Returns a bare run of

s (no wrapper) so the diff paragraphs sit in the + // same block flow as the surrounding before/after context the page renders — + // margins collapse uniformly and `first:mt-0` resolves against the real first + // paragraph, matching the Tiptap editor's rhythm exactly. return ( -

- {paragraphs.map((para) => ( + <> + {paragraphs.map((para, pIndex) => (

+ {pIndex === 0 && leadIn && {leadIn}} {para.items.map((item) => { if (item.kind === "br") return
; if (item.kind === "text") return {item.value}; @@ -92,9 +105,12 @@ export function DiffText({ /> ); })} + {pIndex === lastIndex && tailOut && ( + {tailOut} + )}

))} -
+ ); } diff --git a/components/quill/editor.tsx b/components/quill/editor.tsx index fde6530..e5a193c 100644 --- a/components/quill/editor.tsx +++ b/components/quill/editor.tsx @@ -1,5 +1,6 @@ "use client"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { @@ -40,7 +41,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { hueFromHSL } from "@/lib/colour/placeholder"; -import { textblockRanges } from "@/lib/quill/blocks"; +import { sentenceWindowAt, textblockRanges } from "@/lib/quill/blocks"; import { NUDGE_PRESETS } from "@/lib/quill/nudge-presets"; import { cn } from "@/lib/utils"; @@ -60,6 +61,26 @@ export type SelectionRange = { beforeText?: string; /** Text in the document after the selection — populated by getSelection(), not onSelectionChange. */ afterText?: string; + /** Span begins mid-paragraph — the preceding context continues inline with it. */ + openStart?: boolean; + /** Span ends mid-paragraph — the following context continues inline with it. */ + openEnd?: boolean; +}; + +/** Drag-and-drop mime carrying a colour-drop swatch key from the palette. */ +export const COLOUR_DROP_MIME = "application/x-inklings-colour"; + +/** What a colour-drop resolves to: the swatch, the span to rewrite, and the + * viewport coordinates the splash should bloom from. */ +export type ColourDropDetail = { + /** The dropped swatch's key — the page maps it to a rewrite target. */ + colourKey: string; + /** Sentence-window around the drop, ready to feed the diff/rewrite flow. */ + span: SelectionRange; + /** Where the swatch landed (the targeted word), in viewport px. */ + origin: { x: number; y: number }; + /** Sampled points across the span for the radiating secondary splashes. */ + ripples: { x: number; y: number }[]; }; type EditorProps = { @@ -83,6 +104,8 @@ type EditorProps = { * the ink accent. Pass null to clear the highlight. */ highlightBlock?: { index: number; tint?: string | null } | null; + /** A colour swatch was dropped on the prose — resolved span + splash coords. */ + onColourDrop?: (detail: ColourDropDetail) => void; }; /** Imperative handle for the Quill editor, exposed via `ref`. */ @@ -95,6 +118,22 @@ export type EditorHandle = { replaceRange: (from: number, to: number, html: string) => void; }; +// Whether the span begins/ends mid-paragraph — when it does, the adjacent +// context belongs to the same block and should read inline with the rewrite +// rather than as a separate paragraph above/below it. +function openness( + doc: ProseMirrorNode, + from: number, + to: number, +): { openStart: boolean; openEnd: boolean } { + const $from = doc.resolve(from); + const $to = doc.resolve(to); + return { + openStart: $from.parentOffset > 0, + openEnd: $to.parentOffset < $to.parent.content.size, + }; +} + // Marks the top-level block holding the caret with `quill-focus-active`, so // focus mode can dim everything else via CSS. Always installed — it's inert // until the wrapper opts into the dimming classes. @@ -182,6 +221,7 @@ export function Editor({ onDeriveHue, onRewriteSelection, highlightBlock, + onColourDrop, }: EditorProps & { ref?: Ref }) { // Track selection emptiness so the right-click menu can disable // selection-only actions; updated on every selection change. @@ -306,6 +346,47 @@ export function Editor({ toast.success("Copied"); }; + // Allow the palette's swatch drag to drop on the prose — without + // preventDefault on dragover the browser refuses the drop. + const handleColourDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes(COLOUR_DROP_MIME)) e.preventDefault(); + }; + + // Resolve a dropped swatch to its target word, the sentence-window to rewrite, + // and the screen coordinates the splash plays over. We preventDefault so + // ProseMirror's own drop handling never sees it. + const handleColourDrop = (e: React.DragEvent) => { + if (!editor) return; + const colourKey = e.dataTransfer.getData(COLOUR_DROP_MIME); + if (!colourKey) return; // not our drag — let the editor handle it normally + e.preventDefault(); + e.stopPropagation(); + const { view } = editor; + const at = view.posAtCoords({ left: e.clientX, top: e.clientY }); + if (!at) return; + const window = sentenceWindowAt(view.state.doc, at.pos); + if (!window) return; + const { from, to } = window; + const { doc } = view.state; + const span: SelectionRange = { + text: doc.textBetween(from, to, "\n\n"), + from, + to, + beforeText: doc.textBetween(0, from, "\n\n").trimStart(), + afterText: doc.textBetween(to, doc.content.size, "\n\n").trimEnd(), + ...openness(doc, from, to), + }; + // Sample evenly across the span so the secondary splashes trace the words + // about to change, radiating out from the drop point. + const STEPS = 5; + const ripples = Array.from({ length: STEPS + 1 }, (_, i) => { + const p = Math.min(to, Math.max(from, Math.round(from + ((to - from) * i) / STEPS))); + const c = view.coordsAtPos(p); + return { x: c.left, y: (c.top + c.bottom) / 2 }; + }); + onColourDrop?.({ colourKey, span, origin: { x: e.clientX, y: e.clientY }, ripples }); + }; + const canHue = hasSelection && !!onDeriveHue; const canRewrite = hasSelection && !!onRewriteSelection; @@ -353,6 +434,7 @@ export function Editor({ to, beforeText: doc.textBetween(0, from, "\n\n").trimStart(), afterText: doc.textBetween(to, doc.content.size, "\n\n").trimEnd(), + ...openness(doc, from, to), }; }, replaceRange(from: number, to: number, html: string) { @@ -373,7 +455,10 @@ export function Editor({
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only colour-drop zone over the editable area; the same rewrite is keyboard-reachable via the Rewrite panel */}
offset < s.end); + if (i === -1) i = spans.length - 1; + const lo = spans[Math.max(0, i - 1)]; + const hi = spans[Math.min(spans.length - 1, i + 1)]; + if (!lo || !hi) return null; + return { from: blockStart + lo.start, to: blockStart + hi.end }; +} diff --git a/lib/quill/diff.ts b/lib/quill/diff.ts index 1a93f8c..80ed21a 100644 --- a/lib/quill/diff.ts +++ b/lib/quill/diff.ts @@ -19,9 +19,11 @@ export type RewriteSegment = { }; export type TargetRewrite = { - /** Full rewritten prose, reconstructed from the diff (`add` + `same`). */ + /** Full rewritten prose. The client diffs this against the original itself + * (diffWords), so the structured `diff` is optional and no longer the source + * of the rewrite text. */ rewrite: string; - diff: RewriteSegment[]; + diff?: RewriteSegment[]; }; /** Reconstruct the rewritten text: everything except removed spans. */ diff --git a/tests/unit/quill/sentence-window.test.ts b/tests/unit/quill/sentence-window.test.ts new file mode 100644 index 0000000..af9847a --- /dev/null +++ b/tests/unit/quill/sentence-window.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { sentenceSpans } from "@/lib/quill/blocks"; + +describe("sentenceSpans", () => { + it("splits on sentence-ending punctuation", () => { + const text = "One fish. Two fish! Red fish? Blue fish."; + const spans = sentenceSpans(text); + expect(spans.map((s) => text.slice(s.start, s.end))).toEqual([ + "One fish.", + "Two fish!", + "Red fish?", + "Blue fish.", + ]); + }); + + it("keeps a trailing fragment with no terminator", () => { + const text = "Done. Not done"; + const spans = sentenceSpans(text); + expect(spans).toHaveLength(2); + const last = spans[1]; + expect(last && text.slice(last.start, last.end)).toBe("Not done"); + }); + + it("treats abbreviation-free single sentences as one span", () => { + expect(sentenceSpans("just one sentence")).toEqual([{ start: 0, end: 17 }]); + }); +});