Skip to main content

ns-ui

Galley Bracket

Remote collaborators' live selections drawn as proofreader's marks — thin corner brackets at each end of a range and a hairline underline, nesting outward by arrival order where ranges overlap, instead of overlapping tinted highlight fills.

Install

npx shadcn add https://design.helpmarq.com/r/galley-bracket.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/galley-bracket/component.tsx
"use client";

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// GalleyBracket — remote collaborators' live selections drawn as proofreader's
// marks, not highlight washes: a thin corner bracket at each end of a range,
// a hairline underline between them, and the selector's initials set small in
// the left margin at the opening bracket. Ranges are measured off the real
// rendered text with Range.getClientRects() (a Range over one text node, the
// same primitive selecting the text and reading getClientRects() would use),
// so a multi-line selection gets one underline per wrapped line and brackets
// only at its true start/end — never one per line. When ranges overlap, the
// only thing that varies per selection is its arrival index within that
// overlap group (arrival order, not z-index or hue): later arrivals' brackets
// stand outward from the text by index*3px and draw slightly lighter, so
// concurrent selections nest like stacked proof brackets instead of blending
// into a third, unreadable color. Marks draw in with a 160ms stroke-dashoffset
// reveal on first arrival only — the same selection re-ranging (a drag) never
// replays it, which is what keeps rapid re-selection from strobing. The SVG
// layer is aria-hidden; the real accessible surface is a debounced (2s)
// role=status summary ("Ana selected 12 words") plus a keyboard-reachable
// list of jump buttons that move the platform's own Selection into the named
// range. Every stroke is --foreground at reduced opacity — no filled
// highlights, ever, because two overlapping fills produce a third value that
// identifies nobody. DOM+SVG+CSS only, no canvas.
// ---------------------------------------------------------------------------

export interface GalleyBracketSelection {
  /** stable id for this collaborator's current range */
  id: string;
  /** full name — used in the debounced summary and the jump list */
  name: string;
  /** short code drawn small in the margin at the opening bracket; derived from `name` if omitted */
  initials?: string;
  /** character offset into `text` where the range starts */
  start: number;
  /** character offset into `text` where the range ends (exclusive) */
  end: number;
  /** arrival order key, lower = earlier. Defaults to this selection's index in the array, so
   *  passing a freshly-appended selection last is enough to make it the latest arrival. */
  arrivedAt?: number;
}

export interface GalleyBracketProps {
  /** the shared paragraph's plain text. One instance renders one paragraph — for a
   *  multi-paragraph editor, render one GalleyBracket per paragraph so range offsets
   *  stay simple character indices into a single text node. */
  text: string;
  /** remote collaborators' current ranges into `text` */
  selections: GalleyBracketSelection[];
  /** named in the debounced summary ("Ana selected 12 words in paragraph 3") */
  contextLabel?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type LocalRect = { left: number; top: number; right: number; bottom: number };

const BRACKET_ARM = 6; // px, horizontal foot length
const OFFSET_STEP = 3; // px, outward nesting distance per arrival index
const UNDER_GAP = 4; // px, underline distance below the line's own baseline box
const BASE_OPACITY = 0.55;
const OPACITY_STEP = 0.09;
const MIN_OPACITY = 0.22;
const DEBOUNCE_MS = 2000;

function clamp(n: number, lo: number, hi: number): number {
  return Math.max(lo, Math.min(n, hi));
}

function deriveInitials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "?";
  if (parts.length === 1) return (parts[0] ?? "").slice(0, 2).toUpperCase();
  return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
}

function wordCount(text: string, start: number, end: number): number {
  const len = text.length;
  const s = clamp(start, 0, len);
  const e = clamp(end, s, len);
  const slice = text.slice(s, e).trim();
  return slice ? slice.split(/\s+/).length : 0;
}

function opacityFor(localIndex: number): number {
  return Math.max(MIN_OPACITY, BASE_OPACITY - localIndex * OPACITY_STEP);
}

function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
  return a.start < b.end && b.start < a.end;
}

// Groups selections whose [start,end) ranges transitively overlap, then ranks each
// member by arrival order within its own group. That local rank — not the raw
// arrivedAt value, not DOM order — is the one scalar every other visual property
// derives from (outward offset, stroke opacity).
function computeArrivalIndex(sels: GalleyBracketSelection[]): Map<string, number> {
  const n = sels.length;
  const parent = Array.from({ length: n }, (_, i) => i);
  const find = (x: number): number => {
    while (parent[x] !== x) {
      parent[x] = parent[parent[x] as number] as number;
      x = parent[x] as number;
    }
    return x;
  };
  const union = (a: number, b: number) => {
    const ra = find(a);
    const rb = find(b);
    if (ra !== rb) parent[ra] = rb;
  };
  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      const a = sels[i];
      const b = sels[j];
      if (a && b && overlaps(a, b)) union(i, j);
    }
  }
  const arrivalKey = (i: number) => sels[i]?.arrivedAt ?? i;
  const groups = new Map<number, number[]>();
  for (let i = 0; i < n; i++) {
    const r = find(i);
    const g = groups.get(r);
    if (g) g.push(i);
    else groups.set(r, [i]);
  }
  const result = new Map<string, number>();
  for (const idxs of groups.values()) {
    idxs.sort((a, b) => arrivalKey(a) - arrivalKey(b));
    idxs.forEach((idx, local) => {
      const sel = sels[idx];
      if (sel) result.set(sel.id, local);
    });
  }
  return result;
}

function toLocal(rects: DOMRect[], originLeft: number, originTop: number): LocalRect[] {
  return rects
    .filter((r) => r.width > 0 && r.height > 0)
    .map((r) => ({
      left: r.left - originLeft,
      top: r.top - originTop,
      right: r.right - originLeft,
      bottom: r.bottom - originTop,
    }))
    .sort((a, b) => a.top - b.top);
}

function BracketMark({
  rect,
  side,
  localIndex,
  opacity,
}: {
  rect: LocalRect;
  side: "open" | "close";
  localIndex: number;
  opacity: number;
}) {
  const topY = rect.top - localIndex * OFFSET_STEP;
  const footY = rect.bottom + UNDER_GAP + localIndex * OFFSET_STEP;
  const x = side === "open" ? rect.left : rect.right;
  const footX = side === "open" ? x + BRACKET_ARM : x - BRACKET_ARM;
  return (
    <>
      <line
        x1={x}
        y1={topY}
        x2={x}
        y2={footY}
        pathLength={1}
        strokeDasharray={1}
        className="ns-galley-draw"
        stroke="var(--foreground)"
        strokeWidth={1}
        strokeLinecap="round"
        opacity={opacity}
      />
      <line
        x1={x}
        y1={footY}
        x2={footX}
        y2={footY}
        pathLength={1}
        strokeDasharray={1}
        className="ns-galley-draw"
        stroke="var(--foreground)"
        strokeWidth={1}
        strokeLinecap="round"
        opacity={opacity}
      />
    </>
  );
}

export function GalleyBracket({ text, selections, contextLabel, className = "" }: GalleyBracketProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLParagraphElement>(null);

  const [size, setSize] = useState({ width: 0, height: 0 });
  const [marks, setMarks] = useState<Record<string, LocalRect[]>>({});
  const [liveMessage, setLiveMessage] = useState("");
  const [reducedMotion, setReducedMotion] = useState(false);

  const prevSelRef = useRef<Map<string, { start: number; end: number }>>(new Map());
  const debounceTimer = useRef(0);

  const arrivalIndex = useMemo(() => computeArrivalIndex(selections), [selections]);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReducedMotion(mq.matches);
    const onChange = () => setReducedMotion(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  const measure = useCallback(() => {
    const container = containerRef.current;
    const p = textRef.current;
    if (!container || !p) return;
    const box = container.getBoundingClientRect();
    setSize({ width: box.width, height: box.height });
    const node = p.firstChild;
    if (!(node instanceof Text)) {
      setMarks({});
      return;
    }
    const len = node.data.length;
    const next: Record<string, LocalRect[]> = {};
    for (const sel of selections) {
      const s = clamp(sel.start, 0, len);
      const e = clamp(sel.end, s, len);
      if (e <= s) continue;
      const range = document.createRange();
      try {
        range.setStart(node, s);
        range.setEnd(node, e);
        const rects = toLocal(Array.from(range.getClientRects()), box.left, box.top);
        if (rects.length) next[sel.id] = rects;
      } catch {
        // stale offsets against a text node mid-update — skip this selection's marks this frame
      }
    }
    setMarks(next);
  }, [selections]);

  useLayoutEffect(() => {
    measure();
    const container = containerRef.current;
    if (!container) return;
    const ro = new ResizeObserver(() => measure());
    ro.observe(container);
    const onResize = () => measure();
    window.addEventListener("resize", onResize);
    document.fonts?.ready?.then(() => measure()).catch(() => {});
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", onResize);
    };
  }, [measure]);

  // debounced accessible summary — announces only the range that most recently
  // changed, 2s after changes stop, so a drag-selection in progress never floods
  // the live region with one announcement per pointermove.
  useEffect(() => {
    const prev = prevSelRef.current;
    let changed: GalleyBracketSelection | undefined;
    for (const sel of selections) {
      const p = prev.get(sel.id);
      if (!p || p.start !== sel.start || p.end !== sel.end) changed = sel;
    }
    prevSelRef.current = new Map(selections.map((s) => [s.id, { start: s.start, end: s.end }]));
    if (!changed) return;
    const target = changed;
    window.clearTimeout(debounceTimer.current);
    debounceTimer.current = window.setTimeout(() => {
      const words = wordCount(text, target.start, target.end);
      const where = contextLabel ? ` in ${contextLabel}` : "";
      setLiveMessage(`${target.name} selected ${words} word${words === 1 ? "" : "s"}${where}.`);
    }, DEBOUNCE_MS);
  }, [selections, text, contextLabel]);

  useEffect(() => {
    const timer = debounceTimer;
    return () => window.clearTimeout(timer.current);
  }, []);

  const jumpTo = (sel: GalleyBracketSelection) => {
    const node = textRef.current?.firstChild;
    if (!(node instanceof Text)) return;
    const len = node.data.length;
    const s = clamp(sel.start, 0, len);
    const e = clamp(sel.end, s, len);
    if (e <= s) return;
    const range = document.createRange();
    try {
      range.setStart(node, s);
      range.setEnd(node, e);
    } catch {
      return;
    }
    const live = window.getSelection();
    live?.removeAllRanges();
    live?.addRange(range);
    textRef.current?.scrollIntoView({
      block: "center",
      behavior: reducedMotion ? "auto" : "smooth",
    });
  };

  return (
    <div className={["ns-galley relative", className].filter(Boolean).join(" ")}>
      <style>{`
.ns-galley-draw{ stroke-dashoffset: 1; animation: ns-galley-draw-in 160ms cubic-bezier(0.16,1,0.3,1) forwards; }
@keyframes ns-galley-draw-in{ from{ stroke-dashoffset: 1; } to{ stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce){
  .ns-galley-draw{ animation: none !important; stroke-dashoffset: 0 !important; }
}
`}</style>

      <div ref={containerRef} className="relative py-2 pl-9 pr-2">
        <p ref={textRef} className="text-base leading-[2.2] text-foreground">
          {text}
        </p>
        <svg
          aria-hidden
          className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
          viewBox={`0 0 ${Math.max(size.width, 1)} ${Math.max(size.height, 1)}`}
          preserveAspectRatio="none"
        >
          {selections.map((sel) => {
            const rects = marks[sel.id];
            if (!rects || rects.length === 0) return null;
            const first = rects[0];
            const last = rects[rects.length - 1];
            if (!first || !last) return null;
            const local = arrivalIndex.get(sel.id) ?? 0;
            const op = opacityFor(local);
            const initials = sel.initials?.trim() || deriveInitials(sel.name);
            return (
              <g key={sel.id} data-selection={sel.id}>
                {rects.map((r, i) => {
                  const y = r.bottom + UNDER_GAP + local * OFFSET_STEP;
                  return (
                    <line
                      key={i}
                      x1={r.left}
                      y1={y}
                      x2={r.right}
                      y2={y}
                      pathLength={1}
                      strokeDasharray={1}
                      className="ns-galley-draw"
                      stroke="var(--foreground)"
                      strokeWidth={1}
                      opacity={op}
                    />
                  );
                })}
                <BracketMark rect={first} side="open" localIndex={local} opacity={op} />
                <BracketMark rect={last} side="close" localIndex={local} opacity={op} />
                <text
                  x={4}
                  y={first.top - local * OFFSET_STEP + 9}
                  className="font-mono text-[8px]"
                  fill="var(--foreground)"
                  opacity={op}
                >
                  {initials}
                </text>
              </g>
            );
          })}
        </svg>
      </div>

      <div role="status" aria-live="polite" className="sr-only">
        {liveMessage}
      </div>

      {selections.length > 0 && (
        <div className="mt-4">
          <p className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-ns-muted">
            Active selections
          </p>
          <ul className="flex flex-wrap gap-2">
            {selections.map((sel) => {
              const words = wordCount(text, sel.start, sel.end);
              return (
                <li key={sel.id}>
                  <button
                    type="button"
                    data-jump
                    onClick={() => jumpTo(sel)}
                    className="rounded-full border border-border px-2.5 py-1 font-mono text-[11px] text-ns-muted outline-none transition-colors hover:border-foreground/25 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                  >
                    {sel.name} · {words} word{words === 1 ? "" : "s"}
                  </button>
                </li>
              );
            })}
          </ul>
        </div>
      )}
    </div>
  );
}
Build spec

A presence primitive for shared/collaborative text editing: given `text` (one paragraph's plain string) and `selections: { id, name, initials?, start, end, arrivedAt? }[]` (character offsets into `text`), it marks where OTHER people currently have something selected — never the viewer's own selection, which stays the platform's native blue. Each range is measured with `Range.getClientRects()` against the paragraph's own single text node (the same primitive a user dragging a selection and reading getClientRects() would hit), so a range that wraps across lines gets one hairline underline per wrapped line, while the corner brackets are drawn only at the range's true start and true end — never once per line. Every mark is a stroke, `var(--foreground)` at a base opacity of 0.55, never a filled highlight: two overlapping translucent fills produce a third color that identifies nobody, so instead, when ranges overlap, each selection's ONLY varying properties are derived from its arrival index within that overlap group (computed by union-find over interval overlap, then ranked by `arrivedAt` — defaulting to array position — within each connected group): outward offset (index × 3px, pushing the bracket's top edge up and its underline down, so later arrivals visibly embrace the earlier ones like nested proofreading brackets) and stroke opacity (index × -0.09, later arrivals slightly lighter, floored at 0.22). Nothing else — no hue, no fill, no z-order trick — varies per selection. The selector's initials (explicit `initials`, or derived from the first letters of `name`) are set small in a reserved left margin gutter, vertically aligned with the opening bracket's (offset) top. Marks draw in over 160ms via `stroke-dashoffset` the first time an id appears in the DOM — because React keys the mark group by `id`, a selection whose `start`/`end` merely change (a drag in progress) updates the existing element's coordinates without remounting it, so the reveal never replays and rapid re-selection never strobes. `prefers-reduced-motion` drops the reveal outright (marks appear at rest). The whole SVG layer is `aria-hidden`; it is decoration on top of an honestly-stated real surface: a `role=status aria-live=polite` region that announces the most recently changed selection 2 seconds after it stops changing ("Ana Kader selected 12 words.", plus " in <contextLabel>" when that prop is set) — the 2s debounce is what keeps a live drag-selection from flooding the region with one announcement per pointermove — and a keyboard-reachable list of jump buttons, one per active selection, each a real `<button>` whose accessible name is its own visible text ("Ana Kader · 5 words"); activating one moves the platform's own `Selection` object to that exact range via the same Range machinery used for measurement, so the collaborator's text is actually, natively highlighted and scrolled into view — not a custom paint standing in for it. Renders one paragraph per instance by design (multi-paragraph editors render one GalleyBracket per paragraph) so `start`/`end` stay simple indices into one text node rather than needing a cross-node offset scheme. Zero dependencies, DOM+SVG+CSS only, no canvas; every color is a token (`--background --foreground --ns-muted --border --ns-accent`) and `--ns-accent` appears only on the jump buttons' focus ring, never on a bracket or underline — the marks are strictly monochrome. Distinct from select-caustic: select-caustic is an effect on a `<select>` trigger/listbox, unrelated in domain. The component this is really meant to be read against is anything that would highlight a remote user's selection with a translucent fill — that approach gets muddier as more people select the same text; galley-bracket gets more articulate as ranges overlap, because overlap is exactly what arrival-order nesting is built to narrate.

Props

PropTypeDefaultDescription
textstringthe shared paragraph's plain text. One instance renders one paragraph — for a multi-paragraph editor, render one GalleyBracket per paragraph so range offsets stay simple character indices into a single text node.
selectionsGalleyBracketSelection[]remote collaborators' current ranges into `text`
contextLabel?stringnamed in the debounced summary ("Ana selected 12 words in paragraph 3")
className?stringextra classes merged onto the rendered root element