{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-staff-notation",
  "type": "registry:ui",
  "title": "Staff Notation",
  "description": "Portable SVG music engraving with spelled notes, rests, chords, beams, dots, triplets, ties and producer-supplied playback colors.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0",
    "vexflow@4.2.5"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/staff-notation.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/staff-notation\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/** Portable SVG engraving. Producers own pitch spelling, rhythm and playback. */\nimport { useEffect, useRef, useState, type CSSProperties } from \"react\"\nimport { Accidental, Annotation, Beam, Dot, Formatter, Renderer, Stave, StaveNote, StaveTie, Tuplet, Voice } from \"vexflow\"\nimport { useElementSize } from \"maruaudio/ui/shared/use-element-size\"\nimport { layoutStaffNotation } from \"maruaudio/ui/shared/staff-notation-layout\"\n\nexport interface StaffNotationNote {\n  /** VexFlow spelling, e.g. c#/5. An empty array is a rest. */\n  readonly keys: readonly string[]\n  /** w, h, q, 8, 16, optionally followed by d. */\n  readonly duration: string\n  readonly accidentals?: readonly (string | null)[]\n  readonly color?: string\n  readonly label?: string\n  /** Three equal notes in the time of two, grouped in consecutive threes. */\n  readonly triplet?: boolean\n  readonly tieFromPrevious?: boolean\n  /** Caller-owned absolute quarter-beat MIDI gate, including swing/articulation. */\n  readonly startBeat?: number\n  readonly endBeat?: number\n}\nexport interface StaffNotationMeasure {\n  readonly label?: string\n  readonly notes: readonly StaffNotationNote[]\n  /** Caller-owned absolute quarter-beat bounds, including bars containing rests. */\n  readonly startBeat?: number\n  readonly endBeat?: number\n}\nexport interface StaffNotationProps {\n  readonly measures: readonly StaffNotationMeasure[]\n  readonly clef?: \"treble\" | \"bass\"\n  readonly beatsPerBar?: number\n  readonly beatValue?: number\n  readonly keySignature?: string\n  readonly showMeter?: boolean\n  /** Keep measures intact on multiple rows; only an oversized measure scrolls. */\n  readonly wrap?: boolean\n  /** Authoritative caller position; null clears highlights and the cursor. No clock is created. */\n  readonly playbackBeat?: number | null\n  readonly playbackColor?: string\n  /** False retains a stationary cursor while clearing all sounding-note highlights. */\n  readonly playbackRunning?: boolean\n  readonly ariaLabel: string\n  readonly ink?: string\n  readonly className?: string\n  readonly style?: CSSProperties\n}\n\ninterface PlaybackNote {\n  readonly source: StaffNotationNote\n  readonly element: Element\n  readonly paint: readonly { element: SVGElement; fill: string | null; stroke: string | null }[]\n  activeColor: string | null\n}\ninterface PlaybackMeasure {\n  readonly start: number\n  readonly end: number\n  readonly cursor: SVGLineElement\n  readonly anchors: readonly { beat: number; x: number }[]\n}\ninterface PlaybackEngraving {\n  readonly cursors: SVGLineElement[]\n  readonly notes: PlaybackNote[]\n  readonly measures: PlaybackMeasure[]\n}\nconst finiteBeat = (beat: number | undefined): beat is number => typeof beat === \"number\" && Number.isFinite(beat)\n\n/** DOM-only playback projection over recorded engraving geometry; no timer or timeline ownership. */\nfunction updatePlayback(engraving: PlaybackEngraving, beat: number | null | undefined, color: string, running: boolean) {\n  const positioned = finiteBeat(beat ?? undefined)\n  engraving.notes.forEach(note => {\n    const active = running && positioned && note.source.keys.length > 0 && finiteBeat(note.source.startBeat) && finiteBeat(note.source.endBeat)\n      && beat! >= note.source.startBeat && beat! < note.source.endBeat\n    const nextColor = active ? color : null\n    if (nextColor === note.activeColor) return\n    note.element.setAttribute(\"data-active-note\", active ? \"true\" : \"false\")\n    note.paint.forEach(paint => {\n      for (const attribute of [\"fill\", \"stroke\"] as const) {\n        const original = paint[attribute]\n        if (active && original !== \"none\") paint.element.setAttribute(attribute, color)\n        else if (original === null) paint.element.removeAttribute(attribute)\n        else paint.element.setAttribute(attribute, original)\n      }\n    })\n    note.activeColor = nextColor\n  })\n  engraving.cursors.forEach(cursor => cursor.setAttribute(\"visibility\", \"hidden\"))\n  if (!positioned) return\n  const last = engraving.measures.at(-1)\n  const measure = engraving.measures.find(measure => beat! >= measure.start && beat! < measure.end)\n    ?? (last && beat === last.end ? last : undefined)\n  if (!measure) return\n  let left = measure.anchors[0]!\n  let right = measure.anchors.at(-1)!\n  for (let index = 1; index < measure.anchors.length; index++) {\n    if (measure.anchors[index]!.beat > beat!) { right = measure.anchors[index]!; break }\n    left = measure.anchors[index]!\n  }\n  const fraction = right.beat > left.beat ? (beat! - left.beat) / (right.beat - left.beat) : 0\n  const x = left.x + (right.x - left.x) * fraction\n  measure.cursor.setAttribute(\"x1\", String(x))\n  measure.cursor.setAttribute(\"x2\", String(x))\n  measure.cursor.setAttribute(\"stroke\", color)\n  measure.cursor.setAttribute(\"data-playback-beat\", String(beat))\n  measure.cursor.setAttribute(\"data-playback-running\", String(running))\n  measure.cursor.setAttribute(\"visibility\", \"visible\")\n}\n\n/** No device, MIDI, clock or theory dependencies; only draws supplied score data. */\nexport function StaffNotation({ measures, clef = \"treble\", beatsPerBar = 4, beatValue = 4,\n  keySignature = \"C\", showMeter = true, wrap = false, playbackBeat, playbackColor = \"#237e56\", playbackRunning = true, ariaLabel, ink = \"#20272b\", className, style }: StaffNotationProps) {\n  const [measureRef, size] = useElementSize<HTMLDivElement>()\n  const target = useRef<HTMLDivElement>(null)\n  const [error, setError] = useState<string | null>(null)\n  const playback = useRef<PlaybackEngraving>({ cursors: [], notes: [], measures: [] })\n  useEffect(() => {\n    const element = target.current\n    if (!element) return\n    element.replaceChildren()\n    playback.current = { cursors: [], notes: [], measures: [] }\n    setError(null)\n    if (!measures.length) return\n    try {\n      const rows = layoutStaffNotation(measures.map(measure => measure.notes.length), size.width, wrap)\n      rows.forEach((row, rowIndex) => {\n        const rowElement = wrap ? element.appendChild(element.ownerDocument.createElement(\"div\")) : element\n        if (wrap) {\n          rowElement.dataset.staffRow = String(rowIndex)\n          rowElement.style.overflowX = \"auto\"\n          rowElement.style.overflowY = \"hidden\"\n        }\n        const renderer = new Renderer(rowElement, Renderer.Backends.SVG)\n        renderer.resize(row.width, 142)\n        const context = renderer.getContext()\n        const svg = rowElement.querySelector(\"svg\")!\n        const rowHasTiming = row.widths.some((_, index) => {\n          const measure = measures[row.firstMeasureIndex + index]!\n          return finiteBeat(measure.startBeat) && finiteBeat(measure.endBeat) && measure.endBeat > measure.startBeat\n        })\n        const cursor = rowHasTiming ? element.ownerDocument.createElementNS(\"http://www.w3.org/2000/svg\", \"line\") : null\n        if (cursor) {\n          cursor.setAttribute(\"data-playback-cursor\", String(rowIndex))\n          cursor.setAttribute(\"y1\", \"24\")\n          cursor.setAttribute(\"y2\", \"118\")\n          cursor.setAttribute(\"stroke-width\", \"2\")\n          cursor.setAttribute(\"pointer-events\", \"none\")\n          cursor.setAttribute(\"visibility\", \"hidden\")\n          playback.current.cursors.push(cursor)\n        }\n        context.setFillStyle(ink).setStrokeStyle(ink)\n        let x = 8\n        const total = row.widths.reduce((sum, value) => sum + value, 0)\n        row.widths.forEach((minimumWidth, rowMeasureIndex) => {\n          const measureIndex = row.firstMeasureIndex + rowMeasureIndex\n          const measure = measures[measureIndex]!\n          const measureWidth = minimumWidth * (row.width - 16) / total\n          const stave = new Stave(x, 10, measureWidth)\n          if (rowMeasureIndex === 0) {\n            stave.addClef(clef).addKeySignature(keySignature)\n            if (showMeter && measureIndex === 0) stave.addTimeSignature(`${beatsPerBar}/${beatValue}`)\n          }\n          if (measure.label) stave.setText(measure.label, 3, { shift_y: -8 })\n          stave.setContext(context).draw()\n          const notes = measure.notes.map((note) => {\n            const rest = note.keys.length === 0\n            const dotted = note.duration.endsWith(\"d\")\n            const duration = dotted ? note.duration.slice(0, -1) : note.duration\n            const rendered = new StaveNote({ clef, keys: rest ? [clef === \"bass\" ? \"d/3\" : \"b/4\"] : [...note.keys], duration: `${duration}${rest ? \"r\" : \"\"}`, auto_stem: true })\n            note.accidentals?.forEach((accidental, index) => {\n              if (!rest && accidental) rendered.addModifier(new Accidental(accidental), index)\n            })\n            if (dotted) Dot.buildAndAttach([rendered], { all: true })\n            if (note.color) rendered.setStyle({ fillStyle: note.color, strokeStyle: note.color })\n            if (note.label) rendered.addModifier(new Annotation(note.label).setFont(\"Arial\", 10).setVerticalJustification(Annotation.VerticalJustify.BOTTOM), 0)\n            return rendered\n          })\n          if (notes.length) {\n            const voice = new Voice({ num_beats: beatsPerBar, beat_value: beatValue }).setStrict(false).addTickables(notes)\n            if (measure.notes.every(note => note.accidentals === undefined)) Accidental.applyAccidentals([voice], keySignature)\n            const tuplets: Tuplet[] = []\n            for (let index = 0; index < notes.length; index++) {\n              if (measure.notes[index]?.triplet && measure.notes[index + 1]?.triplet && measure.notes[index + 2]?.triplet) {\n                tuplets.push(new Tuplet(notes.slice(index, index + 3), { num_notes: 3, notes_occupied: 2 }))\n                index += 2\n              }\n            }\n            const beams = Beam.generateBeams(notes)\n            new Formatter().joinVoices([voice]).formatToStave([voice], stave)\n            voice.draw(context, stave)\n            notes.forEach((rendered, index) => {\n              const source = measure.notes[index]!\n              const group = rendered.getSVGElement()\n              if (!group || !finiteBeat(source.startBeat) || !finiteBeat(source.endBeat)) return\n              group.setAttribute(\"data-active-note\", \"false\")\n              group.setAttribute(\"data-note-start-beat\", String(source.startBeat))\n              group.setAttribute(\"data-note-end-beat\", String(source.endBeat))\n              const paint = [group, ...Array.from(group.querySelectorAll(\"[fill], [stroke]\"))].map(element => ({\n                element: element as SVGElement, fill: element.getAttribute(\"fill\"), stroke: element.getAttribute(\"stroke\"),\n              }))\n              playback.current.notes.push({ source, element: group, paint, activeColor: null })\n            })\n            measure.notes.forEach((note, index) => {\n              if (note.tieFromPrevious && index > 0) new StaveTie({ first_note: notes[index - 1], last_note: notes[index], first_indices: [0], last_indices: [0] }).setContext(context).draw()\n            })\n            beams.forEach((beam) => beam.setContext(context).draw())\n            tuplets.forEach((tuplet) => tuplet.setContext(context).draw())\n          }\n          if (cursor && finiteBeat(measure.startBeat) && finiteBeat(measure.endBeat) && measure.endBeat > measure.startBeat) {\n            const anchors = [{ beat: measure.startBeat, x: stave.getNoteStartX() },\n              ...measure.notes.flatMap((note, index) => finiteBeat(note.startBeat) && note.startBeat >= measure.startBeat! && note.startBeat < measure.endBeat!\n                ? [{ beat: note.startBeat, x: notes[index]!.getAbsoluteX() }] : []),\n              { beat: measure.endBeat, x: stave.getNoteEndX() },\n            ].sort((left, right) => left.beat - right.beat)\n            playback.current.measures.push({ start: measure.startBeat, end: measure.endBeat, cursor,\n              anchors: anchors.filter((anchor, index) => anchors[index + 1]?.beat !== anchor.beat) })\n          }\n          x += measureWidth\n        })\n        if (cursor) svg.appendChild(cursor)\n        rowElement.querySelector(\"svg\")?.setAttribute(\"aria-hidden\", \"true\")\n      })\n    } catch (cause) {\n      element.replaceChildren()\n      setError(cause instanceof Error ? cause.message : \"Notation could not be drawn.\")\n    }\n  }, [measures, clef, beatsPerBar, beatValue, keySignature, showMeter, wrap, ink, size.width])\n  useEffect(() => {\n    updatePlayback(playback.current, playbackBeat, playbackColor, playbackRunning)\n  }, [playbackBeat, playbackColor, playbackRunning, measures, clef, beatsPerBar, beatValue, keySignature, showMeter, wrap, ink, size.width])\n  return <div ref={measureRef} className={className} style={{ minWidth: 0, ...style }}>\n    <div ref={target} role=\"img\" aria-label={ariaLabel} style={{ overflowX: wrap ? \"hidden\" : \"auto\", overflowY: \"hidden\" }} />\n    {error ? <p role=\"status\" style={{ fontSize: 12, color: \"#963727\", padding: 12 }}>Staff unavailable: {error}</p> : null}\n  </div>\n}\n",
      "type": "registry:component",
      "target": "@ui/staff-notation.tsx"
    }
  ]
}
