{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "select-replace",
  "title": "Select Replace",
  "description": "A text selection highlights a phrase and is replaced by new styled words.",
  "dependencies": ["remotion", "@remotion/google-fonts"],
  "files": [
    {
      "path": "registry/remocn/select-replace/index.tsx",
      "content": "\"use client\";\n\nimport { loadFont as loadGeist } from \"@remotion/google-fonts/Geist\";\nimport { loadFont as loadNewsreader } from \"@remotion/google-fonts/Newsreader\";\nimport type React from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AbsoluteFill,\n  continueRender,\n  delayRender,\n  Easing,\n  interpolate,\n  useCurrentFrame,\n  useVideoConfig,\n} from \"remotion\";\n\nconst geist = loadGeist(\"normal\", { weights: [\"500\"], subsets: [\"latin\"] });\nconst serif = loadNewsreader(\"italic\", {\n  weights: [\"400\"],\n  subsets: [\"latin\"],\n});\n\nexport interface SelectReplaceEdit {\n  /** Text to select; the first match in the current line is used. */\n  from: string;\n  /** Text typed in its place. */\n  to: string;\n}\n\nexport interface SelectReplaceProps {\n  text?: string;\n  edits?: SelectReplaceEdit[];\n  accent?: string;\n  theme?: \"dark\" | \"light\";\n  speed?: number;\n}\n\nconst CLAMP = {\n  extrapolateLeft: \"clamp\",\n  extrapolateRight: \"clamp\",\n} as const;\n\nconst EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);\nconst EASE_IO = Easing.bezier(0.65, 0, 0.35, 1);\n\nconst WEIGHT = 500;\nconst TRACK = -0.032;\n// Typed words are set in Newsreader italic, a touch larger so its x-height\n// meets Geist's, and tracked a hair tight.\nconst SERIF_SCALE = 1.1;\nconst SERIF_TRACK = -0.012;\nconst INTRO = 14;\nconst CARET_IN = 4;\nconst SELECT = 12;\nconst SELECTED_HOLD = 9;\nconst TYPE_DELAY = 5;\nconst BETWEEN = 16;\nconst RECENTER = 11;\nconst BLINK = 30;\n\n// gradients.sh \"mesh\" palette (#ecdcf2 #8796ee #2e45b7 #0e1645). The selection\n// is a panel of it; what gets typed into the selection keeps it as a fill.\nconst THEMES = {\n  dark: {\n    bg: \"#0A0A0B\",\n    fg: \"#F3F2EF\",\n    onSelect: \"#FFFFFF\",\n    band: [\"#2E45B7\", \"\", \"#8796EE\"],\n    // White glyphs ride the band: its pale end runs past the edge.\n    bandEnd: 150,\n    bandLight: \"#ECDCF2\",\n    bandLightAlpha: 32,\n    ink: [\"#ECDCF2\", \"#8796EE\", \"\"],\n  },\n  light: {\n    bg: \"#F5F4F0\",\n    fg: \"#121214\",\n    onSelect: \"#0E1645\",\n    band: [\"#ECDCF2\", \"#C9CDF6\", \"#8796EE\"],\n    bandEnd: 100,\n    bandLight: \"#FFFFFF\",\n    bandLightAlpha: 55,\n    ink: [\"\", \"#2E45B7\", \"#0E1645\"],\n  },\n} as const;\n\ninterface Seg {\n  text: string;\n  serif: boolean;\n}\n\nfunction hash(n: number) {\n  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n  return x - Math.floor(x);\n}\n\nconst plain = (segs: Seg[]) => segs.map((s) => s.text).join(\"\");\n\n/** Characters [a, b) of a styled line, keeping each run's style. */\nfunction slice(segs: Seg[], a: number, b: number): Seg[] {\n  const out: Seg[] = [];\n  let at = 0;\n  for (const s of segs) {\n    const chars = [...s.text];\n    const lo = Math.max(a, at);\n    const hi = Math.min(b, at + chars.length);\n    if (hi > lo)\n      out.push({\n        text: chars.slice(lo - at, hi - at).join(\"\"),\n        serif: s.serif,\n      });\n    at += chars.length;\n  }\n  return out;\n}\n\nfunction join(...lists: Seg[][]): Seg[] {\n  const out: Seg[] = [];\n  for (const s of lists.flat()) {\n    if (!s.text) continue;\n    const last = out[out.length - 1];\n    if (last && last.serif === s.serif) last.text += s.text;\n    else out.push({ ...s });\n  }\n  return out;\n}\n\nfunction useMeasurer() {\n  const [ready, setReady] = useState(false);\n  const [handle] = useState(() => delayRender(\"select-replace: measuring\"));\n  const released = useRef(false);\n  const sansFont = `${WEIGHT} 100px \"${geist.fontFamily}\"`;\n  const serifFont = `italic 400 100px \"${serif.fontFamily}\"`;\n\n  useEffect(() => {\n    let live = true;\n    Promise.all([\n      geist.waitUntilDone(),\n      serif.waitUntilDone(),\n      document.fonts.load(sansFont),\n      document.fonts.load(serifFont),\n    ])\n      .catch(() => undefined)\n      .then(() => {\n        if (!live) return;\n        setReady(true);\n        if (!released.current) {\n          released.current = true;\n          continueRender(handle);\n        }\n      });\n    return () => {\n      live = false;\n    };\n  }, [sansFont, serifFont, handle]);\n\n  const ctx = useMemo(\n    () => (ready ? document.createElement(\"canvas\").getContext(\"2d\") : null),\n    [ready],\n  );\n  /** Width of a styled line in em of the base size. */\n  const measure = useCallback(\n    (segs: Seg[]) =>\n      segs.reduce((acc, s) => {\n        const n = [...s.text].length;\n        if (!ctx) return acc + n * 0.55;\n        ctx.font = s.serif ? serifFont : sansFont;\n        const w = ctx.measureText(s.text).width / 100;\n        return (\n          acc + (s.serif ? (w + SERIF_TRACK * n) * SERIF_SCALE : w + TRACK * n)\n        );\n      }, 0),\n    [ctx, sansFont, serifFont],\n  );\n  return ready ? measure : null;\n}\n\ninterface Step {\n  start: number;\n  before: Seg[];\n  from: string;\n  after: Seg[];\n  to: string;\n  selectAt: number;\n  deleteAt: number;\n  /** Frame each typed character lands on. */\n  keys: number[];\n  end: number;\n}\n\nfunction plan(text: string, edits: SelectReplaceEdit[]) {\n  const steps: Step[] = [];\n  let line: Seg[] = [{ text, serif: false }];\n  let t = INTRO + 2;\n  edits.forEach((edit, k) => {\n    const flat = plain(line);\n    const idx = flat.indexOf(edit.from);\n    if (idx < 0) return;\n    const a = [...flat.slice(0, idx)].length;\n    const b = a + [...edit.from].length;\n    const before = slice(line, 0, a);\n    const after = slice(line, b, Number.POSITIVE_INFINITY);\n    const selectAt = t + CARET_IN;\n    const deleteAt = selectAt + SELECT + SELECTED_HOLD;\n    const keys: number[] = [];\n    let at = deleteAt + TYPE_DELAY;\n    [...edit.to].forEach((_, i) => {\n      keys.push(at);\n      at += 2 + Math.round(hash(k * 31 + i) * 2);\n    });\n    const end = keys.length ? keys[keys.length - 1] : deleteAt;\n    steps.push({\n      start: t,\n      before,\n      from: edit.from,\n      after,\n      to: edit.to,\n      selectAt,\n      deleteAt,\n      keys,\n      end,\n    });\n    line = join(before, [{ text: edit.to, serif: true }], after);\n    t = end + BETWEEN;\n  });\n  return { steps, final: line, end: t - BETWEEN };\n}\n\nexport function SelectReplace({\n  text = \"Draft it next week.\",\n  edits = [\n    { from: \"Draft\", to: \"Ship\" },\n    { from: \"next week\", to: \"tonight\" },\n  ],\n  accent = \"#3B5BFD\",\n  theme = \"dark\",\n  speed = 1,\n}: SelectReplaceProps) {\n  const frame = useCurrentFrame() * speed;\n  const { width, height } = useVideoConfig();\n  const t = THEMES[theme];\n  const measure = useMeasurer();\n  const { steps } = useMemo(() => plan(text, edits), [text, edits]);\n\n  if (!measure)\n    return <div style={{ position: \"absolute\", inset: 0, background: t.bg }} />;\n\n  const typed = (s: Step, n: number): Seg[] => [\n    { text: [...s.to].slice(0, n).join(\"\"), serif: true },\n  ];\n\n  // Every distinct line state, in time order, for size and re-centering.\n  const states: { at: number; line: Seg[] }[] = [\n    { at: 0, line: [{ text, serif: false }] },\n  ];\n  for (const s of steps) {\n    states.push({ at: s.deleteAt, line: join(s.before, s.after) });\n    s.keys.forEach((k, i) => {\n      states.push({ at: k, line: join(s.before, typed(s, i + 1), s.after) });\n    });\n  }\n  const widest = Math.max(...states.map((s) => measure(s.line)));\n  const size = Math.min(height * 0.2, (width * 0.84) / widest);\n  const em = (v: number) => v * size;\n\n  // Current line, caret and selection.\n  let line: Seg[] = states[0].line;\n  let caretAt: Seg[] | null = null;\n  let selection: { a: number; b: number; full: number } | null = null;\n  let lastMove = Number.NEGATIVE_INFINITY;\n  for (const s of steps) {\n    if (frame < s.start) break;\n    lastMove = s.start;\n    caretAt = s.before;\n    if (frame >= s.selectAt && frame < s.deleteAt) {\n      const p = interpolate(frame, [s.selectAt, s.selectAt + SELECT], [0, 1], {\n        ...CLAMP,\n        easing: EASE_IO,\n      });\n      const n = Math.ceil(p * [...s.from].length);\n      selection = {\n        a: measure(s.before),\n        b: measure(\n          join(s.before, [\n            { text: [...s.from].slice(0, n).join(\"\"), serif: false },\n          ]),\n        ),\n        full: measure([{ text: s.from, serif: false }]),\n      };\n      lastMove = Math.min(frame, s.selectAt + SELECT);\n    }\n    if (frame >= s.deleteAt) {\n      const n = s.keys.filter((k) => frame >= k).length;\n      line = join(s.before, typed(s, n), s.after);\n      caretAt = join(s.before, typed(s, n));\n      lastMove = n > 0 ? s.keys[n - 1] : s.deleteAt;\n    }\n  }\n\n  // Line re-centers by easing between each state's centered position.\n  const leftFor = (l: Seg[]) => (width - em(measure(l))) / 2;\n  let left = leftFor(states[0].line);\n  for (let i = 1; i < states.length; i++) {\n    const p = interpolate(\n      frame,\n      [states[i].at, states[i].at + RECENTER],\n      [0, 1],\n      { ...CLAMP, easing: EASE_OUT },\n    );\n    left += (leftFor(states[i].line) - leftFor(states[i - 1].line)) * p;\n  }\n\n  const cy = height / 2;\n  const lineTop = cy - em(0.5);\n  const intro = interpolate(frame, [0, INTRO], [0, 1], {\n    ...CLAMP,\n    easing: EASE_OUT,\n  });\n  // The palette turns a few degrees over the shot; never more than that.\n  const drift = interpolate(frame, [0, 170], [0, 1], CLAMP);\n\n  const caretW = Math.max(2, em(0.03));\n  const barTop = lineTop + em(0.06);\n  const barH = em(1.04);\n  const knob = em(0.05);\n  const caretOn =\n    caretAt !== null && !selection && (frame - lastMove) % BLINK < BLINK * 0.55;\n\n  const bar = (x: number, key: string, knobAt?: \"top\" | \"bottom\") => (\n    <div\n      key={key}\n      style={{\n        position: \"absolute\",\n        left: x - caretW / 2,\n        top: barTop,\n        width: caretW,\n        height: barH,\n        background: accent,\n        borderRadius: caretW / 2,\n      }}\n    >\n      {knobAt ? (\n        <div\n          style={{\n            position: \"absolute\",\n            left: caretW / 2 - knob,\n            top: knobAt === \"top\" ? -knob * 1.6 : barH - knob * 0.4,\n            width: knob * 2,\n            height: knob * 2,\n            borderRadius: knob,\n            background: accent,\n          }}\n        />\n      ) : null}\n    </div>\n  );\n\n  const band = t.band.map((c) => c || accent);\n  const ink = t.ink.map((c) => c || accent);\n  // One gradient field across the whole finished line, so every typed word\n  // shows its own slice of the same light.\n  const lineW = em(widest);\n  const inkFill = (segLeft: number): React.CSSProperties => ({\n    color: \"transparent\",\n    backgroundImage: `linear-gradient(${100 + 10 * drift}deg, ${ink[0]} 0%, ${ink[1]} 48%, ${ink[2]} 100%)`,\n    backgroundRepeat: \"no-repeat\",\n    backgroundSize: `${lineW}px 100%`,\n    backgroundPosition: `${(width - lineW) / 2 - segLeft}px 0px`,\n    WebkitBackgroundClip: \"text\",\n    backgroundClip: \"text\",\n  });\n\n  const lineStyle: React.CSSProperties = {\n    position: \"absolute\",\n    left,\n    top: lineTop,\n    fontFamily: geist.fontFamily,\n    fontWeight: WEIGHT,\n    fontSize: size,\n    lineHeight: 1,\n    letterSpacing: `${TRACK}em`,\n    whiteSpace: \"pre\",\n    color: t.fg,\n    fontKerning: \"normal\",\n  };\n\n  // Selected glyphs flip to the on-selection color: overlay the selected run\n  // clipped to the band.\n  const runs = (segs: Seg[], color?: string) => {\n    let x = left;\n    return segs.map((s, i) => {\n      const segLeft = x;\n      x += em(measure([s]));\n      if (!s.serif) {\n        return (\n          <span\n            key={`${String(i)}-${s.text}`}\n            style={color ? { color } : undefined}\n          >\n            {s.text}\n          </span>\n        );\n      }\n      return (\n        <span\n          key={`${String(i)}-${s.text}`}\n          style={{\n            display: \"inline-block\",\n            fontFamily: serif.fontFamily,\n            fontStyle: \"italic\",\n            fontWeight: 400,\n            fontSize: `${SERIF_SCALE}em`,\n            lineHeight: `${1 / SERIF_SCALE}`,\n            letterSpacing: `${SERIF_TRACK}em`,\n            // Room for ascenders, descenders and the italic overhang inside\n            // the clipped background; the negative margin keeps the layout.\n            padding: \"0.3em 0.12em\",\n            margin: \"-0.3em -0.12em\",\n            ...inkFill(segLeft - em(0.12 * SERIF_SCALE)),\n          }}\n        >\n          {s.text}\n        </span>\n      );\n    });\n  };\n\n  return (\n    <AbsoluteFill style={{ background: t.bg, overflow: \"hidden\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          opacity: 0.15 + 0.85 * intro,\n          transform: `translateY(${em(0.12) * (1 - intro)}px)`,\n        }}\n      >\n        {selection ? (\n          <div\n            style={{\n              position: \"absolute\",\n              left: left + em(selection.a),\n              top: barTop,\n              width: Math.max(0, em(selection.b - selection.a)),\n              height: barH,\n              backgroundImage: `radial-gradient(60% 90% at ${22 + 10 * drift}% 0%, color-mix(in oklab, ${t.bandLight} ${t.bandLightAlpha}%, transparent) 0%, transparent 70%), linear-gradient(in oklab ${96 + 8 * drift}deg, ${band[0]} 0%, ${band[1]} 55%, ${band[2]} ${t.bandEnd}%)`,\n              // Sized to the whole word, so the gradient is revealed, not stretched.\n              backgroundSize: `${em(selection.full)}px 100%`,\n              backgroundRepeat: \"no-repeat\",\n            }}\n          />\n        ) : null}\n        <div style={lineStyle}>{runs(line)}</div>\n        {selection ? (\n          <div\n            style={{\n              ...lineStyle,\n              clipPath: `inset(0 ${Math.max(0, em(measure(line)) - em(selection.b))}px 0 ${em(selection.a)}px)`,\n            }}\n          >\n            {runs(line, t.onSelect)}\n          </div>\n        ) : null}\n        {selection\n          ? [\n              bar(left + em(selection.a), \"a\", \"top\"),\n              bar(left + em(selection.b), \"b\", \"bottom\"),\n            ]\n          : null}\n        {caretOn && caretAt !== null\n          ? bar(\n              left +\n                em(measure(caretAt)) +\n                // Clear the italic overhang when the caret follows a typed word.\n                (caretAt[caretAt.length - 1]?.serif ? em(0.06) : 0),\n              \"caret\",\n            )\n          : null}\n      </div>\n    </AbsoluteFill>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/select-replace.tsx"
    }
  ],
  "type": "registry:component"
}
