"use client";
import * as React from "react";
export interface SelectionToolbarContext {
/** The selected text (trimmed). */
text: string;
/** A copy of the selected range; still valid after the toolbar takes focus. */
range: Range;
/** The scoped container element. */
container: HTMLElement;
/** Closes the toolbar (and returns focus to the content if the toolbar had it). */
close: () => void;
}
export interface SelectionToolbarAction {
id: string;
label: string;
icon?: React.ReactNode;
/** Keep the toolbar open after the action runs (e.g. bold, then italic). */
keepOpen?: boolean;
/** Draw a divider before this button. */
separator?: boolean;
/** Runs the action. Return (or resolve) a short string to flash it as feedback, e.g. "Copied". */
onSelect?: (context: SelectionToolbarContext) => void | string | Promise<void | string>;
}
export interface SelectionToolbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
/** The content whose selections show the toolbar. Selections elsewhere are ignored. */
children: React.ReactNode;
actions?: SelectionToolbarAction[];
/** Called for every action, after its own onSelect. */
onAction?: (id: string, context: SelectionToolbarContext) => void;
/** Preferred side of the selection; flips when there is no room. */
placement?: "top" | "bottom";
/** Gap between selection and toolbar in px. */
offset?: number;
/** Minimum selected characters before the toolbar appears. */
minLength?: number;
/** Show text labels next to the icons. */
showLabels?: boolean;
variant?: "contrast" | "surface";
/** Accessible name of the toolbar. */
label?: string;
accent?: string;
}
const EDGE = 8;
const icon = (path: React.ReactNode) => (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4">
{path}
</svg>
);
const stroke = { stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
/** Bold, italic, link, highlight, comment and copy. Only copy does something by itself (clipboard). */
export const DEFAULT_SELECTION_ACTIONS: SelectionToolbarAction[] = [
{ id: "bold", label: "Bold", keepOpen: true, icon: icon(<path d="M5 3h4a2.5 2.5 0 0 1 0 5H5zM5 8h4.6a2.5 2.5 0 0 1 0 5H5z" {...stroke} strokeWidth={1.7} />) },
{ id: "italic", label: "Italic", keepOpen: true, icon: icon(<path d="M10 3H6.5M9.5 13H6M8.8 3 7.2 13" {...stroke} />) },
{ id: "link", label: "Link", icon: icon(<path d="M6.8 9.2a2.6 2.6 0 0 0 3.7 0l2-2a2.6 2.6 0 0 0-3.7-3.7l-.8.8M9.2 6.8a2.6 2.6 0 0 0-3.7 0l-2 2a2.6 2.6 0 0 0 3.7 3.7l.8-.8" {...stroke} />) },
{ id: "highlight", label: "Highlight", keepOpen: true, icon: icon(<path d="m9.5 3 3.5 3.5L7.5 12H4v-3.5zM3 14h10" {...stroke} />) },
{ id: "comment", label: "Comment", separator: true, icon: icon(<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v5a1.5 1.5 0 0 1-1.5 1.5H7l-3 2.5V11h.5" {...stroke} />) },
{
id: "copy",
label: "Copy",
keepOpen: true,
icon: icon(<path d="M6 6h6.5v7H6zM3.5 10V3H10" {...stroke} />),
onSelect: async ({ text }) => {
await navigator.clipboard?.writeText(text);
return "Copied";
},
},
];
/**
* Wraps every text node inside `range` in its own element (so selections across paragraphs stay valid
* HTML), selects the result and returns the new elements. Handy inside action callbacks.
*/
export function wrapRange(range: Range, tagName: string, attributes: Record<string, string> = {}): HTMLElement[] {
const root = range.commonAncestorContainer;
const scope = root.nodeType === Node.TEXT_NODE ? root.parentNode : root;
if (!scope) return [];
const nodes: Text[] = [];
const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) if (range.intersectsNode(n)) nodes.push(n as Text);
const { startContainer, startOffset, endContainer, endOffset } = range;
const wrapped: HTMLElement[] = [];
for (const node of nodes) {
const start = node === startContainer ? startOffset : 0;
const end = node === endContainer ? endOffset : node.length;
if (start >= end || !node.data.slice(start, end).trim()) continue;
if (end < node.length) node.splitText(end);
const target = start > 0 ? node.splitText(start) : node;
const el = document.createElement(tagName);
for (const [k, v] of Object.entries(attributes)) el.setAttribute(k, v);
target.parentNode?.insertBefore(el, target);
el.appendChild(target);
wrapped.push(el);
}
if (wrapped.length) {
const next = document.createRange();
next.setStartBefore(wrapped[0].firstChild ?? wrapped[0]);
next.setEndAfter(wrapped[wrapped.length - 1].lastChild ?? wrapped[wrapped.length - 1]);
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(next);
}
return wrapped;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const VARIANTS = {
contrast: "bg-zinc-900 text-zinc-100 shadow-[0_12px_32px_-12px_rgb(0_0_0/0.5)] dark:bg-zinc-100 dark:text-zinc-900",
surface: "border border-zinc-900/10 bg-white text-zinc-800 shadow-[0_14px_36px_-14px_rgb(0_0_0/0.3)] dark:border-white/10 dark:bg-[#1f2328] dark:text-zinc-100",
} as const;
const BUTTON_HOVER = {
contrast: "hover:bg-white/10 dark:hover:bg-zinc-900/10",
surface: "hover:bg-zinc-900/[0.06] dark:hover:bg-white/[0.08]",
} as const;
/**
* A Medium-style floating toolbar for text selections inside one container. It listens to
* selectionchange only while the container is in use and ignores selections elsewhere. It floats
* above the selection in the top layer and flips / clamps to stay on screen. Buttons never steal the
* selection (mousedown is prevented). Alt+F10 moves focus into the toolbar (roving focus with the arrow
* keys) and Escape closes it and puts focus and the selection back. Callbacks get the text and the range.
*/
export function SelectionToolbar({
children,
actions = DEFAULT_SELECTION_ACTIONS,
onAction,
placement = "top",
offset = 10,
minLength = 1,
showLabels = false,
variant = "contrast",
label = "Text formatting",
accent = "#FF7448",
className = "",
style,
onPointerDown,
onKeyDown,
onFocus,
onBlur,
...props
}: SelectionToolbarProps) {
const reduceMotion = usePrefersReducedMotion();
const [open, setOpen] = React.useState(false);
const [focusIndex, setFocusIndex] = React.useState(0);
const [feedback, setFeedback] = React.useState<{ id: string; text: string } | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const toolbarRef = React.useRef<HTMLDivElement>(null);
const saved = React.useRef<{ range: Range; text: string } | null>(null);
const returnFocus = React.useRef<HTMLElement | null>(null);
// The selection the user dismissed with Escape: it doesn't reopen the toolbar until it changes.
const dismissed = React.useRef<Range | null>(null);
const listening = React.useRef(false);
const pressing = React.useRef(false);
const timers = React.useRef({ settle: 0, feedback: 0 });
const openRef = React.useRef(open);
const settings = React.useRef({ minLength, placement, offset });
React.useEffect(() => {
openRef.current = open;
settings.current = { minLength, placement, offset };
});
/** Places the toolbar over (or under) the selection, flipped and clamped to the viewport. */
const position = React.useCallback(() => {
const bar = toolbarRef.current;
const range = saved.current?.range;
if (!bar || !range) return;
let r = range.getBoundingClientRect();
if (!r.width && !r.height) r = range.getClientRects()[0] ?? r;
const { placement: side, offset: gap } = settings.current;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = bar.offsetWidth;
const h = bar.offsetHeight;
const above = r.top - gap - h;
const below = r.bottom + gap;
let finalSide = side;
if (side === "top" && above < EDGE && below + h <= vh - EDGE) finalSide = "bottom";
if (side === "bottom" && below + h > vh - EDGE && above >= EDGE) finalSide = "top";
const y = Math.max(EDGE, Math.min(finalSide === "top" ? above : below, vh - h - EDGE));
const centre = r.left + r.width / 2;
const x = Math.max(EDGE, Math.min(centre - w / 2, vw - w - EDGE));
bar.style.left = `${Math.round(x)}px`;
bar.style.top = `${Math.round(y)}px`;
bar.style.setProperty("--stb-arrow", `${Math.round(Math.max(12, Math.min(w - 12, centre - x)))}px`);
bar.dataset.side = finalSide;
}, []);
const hide = React.useCallback(() => {
window.clearTimeout(timers.current.settle);
if (openRef.current) setOpen(false);
}, []);
/** Reads the document selection and shows, moves or hides the toolbar. */
const check = React.useCallback(() => {
const container = containerRef.current;
const bar = toolbarRef.current;
if (!container) return;
// While the toolbar has focus, keep the saved selection even if the live one changes.
if (bar?.contains(document.activeElement)) return;
const selection = document.getSelection();
const range = selection && selection.rangeCount ? selection.getRangeAt(0) : null;
const inside = !!range && !range.collapsed && container.contains(range.commonAncestorContainer);
const text = inside ? (selection?.toString() ?? "").trim() : "";
if (!range || !inside || text.length < settings.current.minLength) {
dismissed.current = null;
hide();
return;
}
const d = dismissed.current;
if (d) {
try {
if (d.compareBoundaryPoints(Range.START_TO_START, range) === 0 && d.compareBoundaryPoints(Range.END_TO_END, range) === 0) return;
} catch {
// Different documents / detached: treat as changed.
}
dismissed.current = null;
}
saved.current = { range: range.cloneRange(), text };
if (!openRef.current) setOpen(true);
else position();
}, [hide, position]);
// selectionchange is a document event: subscribe only while this container is being used.
const onSelectionChange = React.useCallback(() => {
if (pressing.current) {
// Mid-drag: hide if the selection collapsed, otherwise wait for pointerup.
const s = document.getSelection();
if (!s || s.isCollapsed) hide();
return;
}
window.clearTimeout(timers.current.settle);
timers.current.settle = window.setTimeout(check, 120);
}, [check, hide]);
const stopListening = React.useCallback(() => {
if (!listening.current) return;
listening.current = false;
document.removeEventListener("selectionchange", onSelectionChange);
}, [onSelectionChange]);
const startListening = () => {
if (listening.current) return;
listening.current = true;
document.addEventListener("selectionchange", onSelectionChange);
};
React.useEffect(() => {
const t = timers.current;
return () => {
stopListening();
window.clearTimeout(t.settle);
window.clearTimeout(t.feedback);
};
}, [stopListening]);
// Show / hide the top-layer toolbar with a small rise-in.
React.useLayoutEffect(() => {
const bar = toolbarRef.current;
if (!bar) return;
if (open) {
if (!bar.matches(":popover-open")) {
try {
bar.showPopover();
} catch {
// No Popover API: position: fixed still works.
}
}
bar.style.visibility = "visible";
position();
const dy = bar.dataset.side === "bottom" ? -4 : 4;
bar.animate(
reduceMotion ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: `translateY(${dy}px) scale(0.97)` }, { opacity: 1, transform: "none" }],
{ duration: reduceMotion ? 120 : 160, easing: "cubic-bezier(0.2, 0.9, 0.1, 1)" },
);
return;
}
if (bar.style.visibility !== "visible") return;
const a = bar.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 90 });
a.onfinish = () => {
if (openRef.current) return;
bar.style.visibility = "hidden";
try {
bar.hidePopover();
} catch {
// not shown as a popover
}
};
}, [open, position, reduceMotion]);
/** Closes; with `restore`, puts focus and the saved selection back where they were. */
const closeAndRestore = React.useCallback(
(restore: boolean) => {
hide();
if (!restore) return;
const target = returnFocus.current?.isConnected ? returnFocus.current : null;
target?.focus({ preventScroll: true });
const range = saved.current?.range;
if (range && containerRef.current?.contains(range.commonAncestorContainer)) {
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
},
[hide],
);
// While open: follow scroll / resize, and let Escape close only this layer.
React.useEffect(() => {
if (!open) return;
let frame = 0;
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(position);
};
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
dismissed.current = saved.current?.range.cloneRange() ?? null;
closeAndRestore(toolbarRef.current?.contains(document.activeElement) ?? false);
};
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
window.addEventListener("keydown", onKey, true);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
window.removeEventListener("keydown", onKey, true);
};
}, [open, position, closeAndRestore]);
const focusToolbar = () => {
const bar = toolbarRef.current;
if (!bar) return;
const active = document.activeElement;
returnFocus.current = active instanceof HTMLElement && active !== document.body ? active : null;
const buttons = bar.querySelectorAll<HTMLButtonElement>("button");
(buttons[focusIndex] ?? buttons[0])?.focus({ preventScroll: true });
};
const run = async (action: SelectionToolbarAction, index: number) => {
const container = containerRef.current;
const current = saved.current;
if (!container || !current) return;
setFocusIndex(index);
const viaKeyboard = toolbarRef.current?.contains(document.activeElement) ?? false;
const context: SelectionToolbarContext = { text: current.text, range: current.range.cloneRange(), container, close: () => closeAndRestore(viaKeyboard) };
let result: void | string = undefined;
try {
result = await action.onSelect?.(context);
} catch {
result = undefined;
}
onAction?.(action.id, context);
if (typeof result === "string" && result) {
setFeedback({ id: action.id, text: result });
setAnnouncement(result);
window.clearTimeout(timers.current.feedback);
timers.current.feedback = window.setTimeout(() => setFeedback(null), 1400);
}
if (!action.keepOpen) {
closeAndRestore(viaKeyboard);
return;
}
// The action may have changed the DOM or the selection: pick up the new one.
const selection = document.getSelection();
if (selection && selection.rangeCount && !selection.isCollapsed && container.contains(selection.getRangeAt(0).commonAncestorContainer)) {
saved.current = { range: selection.getRangeAt(0).cloneRange(), text: selection.toString().trim() || current.text };
}
// Selecting inside an editor can pull focus back into it; keyboard users stay in the toolbar.
if (viaKeyboard && !toolbarRef.current?.contains(document.activeElement)) {
toolbarRef.current?.querySelectorAll<HTMLButtonElement>("button")[index]?.focus({ preventScroll: true });
}
requestAnimationFrame(position);
};
const onToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const count = actions.length;
let next = focusIndex;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (focusIndex + 1) % count;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (focusIndex - 1 + count) % count;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = count - 1;
else return;
event.preventDefault();
setFocusIndex(next);
toolbarRef.current?.querySelectorAll<HTMLButtonElement>("button")[next]?.focus();
};
const announceOpen = open ? "Text toolbar available. Press Alt+F10 to reach it." : "";
return (
<div
ref={containerRef}
className={className}
style={{ ["--stb-accent" as string]: accent, ...style }}
{...props}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.button !== 0) return;
startListening();
pressing.current = true;
const up = () => {
pressing.current = false;
document.removeEventListener("pointerup", up, true);
document.removeEventListener("pointercancel", up, true);
// Let the browser finish the selection (double / triple click) before reading it.
window.clearTimeout(timers.current.settle);
timers.current.settle = window.setTimeout(check, 10);
};
document.addEventListener("pointerup", up, true);
document.addEventListener("pointercancel", up, true);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
startListening();
if (event.key === "F10" && event.altKey) {
event.preventDefault();
if (!openRef.current) check();
// The toolbar mounts its position in a layout effect; focus it on the next frame.
requestAnimationFrame(() => {
if (openRef.current) focusToolbar();
});
}
}}
onFocus={(event) => {
onFocus?.(event);
startListening();
}}
onBlur={(event) => {
onBlur?.(event);
const next = event.relatedTarget as Node | null;
if (next && (containerRef.current?.contains(next) || toolbarRef.current?.contains(next))) return;
// Focus left for good: stop listening; the toolbar stays only if a mouse selection is still there.
if (!openRef.current) stopListening();
}}
>
{children}
<div
ref={toolbarRef}
role="toolbar"
aria-label={label}
aria-orientation="horizontal"
popover="manual"
// Never steal the selection: buttons don't take focus from a mouse press.
onMouseDown={(event) => event.preventDefault()}
onKeyDown={onToolbarKeyDown}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (next && (toolbarRef.current?.contains(next) || containerRef.current?.contains(next))) return;
hide();
stopListening();
}}
data-side={placement}
className={`group/stb invisible fixed inset-auto m-0 flex w-max items-center gap-0.5 overflow-visible rounded-xl p-1 ${VARIANTS[variant] ?? VARIANTS.contrast}`}
style={{ ["--stb-accent" as string]: accent }}
>
{actions.map((action, i) => {
const flashing = feedback?.id === action.id;
return (
<React.Fragment key={action.id}>
{action.separator ? <span aria-hidden="true" className={`mx-0.5 h-5 w-px ${variant === "surface" ? "bg-zinc-900/10 dark:bg-white/10" : "bg-white/15 dark:bg-zinc-900/15"}`} /> : null}
<button
type="button"
tabIndex={i === focusIndex ? 0 : -1}
aria-label={showLabels ? undefined : action.label}
title={showLabels ? undefined : action.label}
onFocus={() => setFocusIndex(i)}
onClick={() => void run(action, i)}
className={`relative inline-flex h-8 items-center justify-center gap-1.5 rounded-lg text-[12.5px] font-medium tracking-[-0.005em] outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--stb-accent)] ${showLabels ? "px-2.5" : "w-8"} ${BUTTON_HOVER[variant] ?? BUTTON_HOVER.contrast} ${flashing ? "text-[var(--stb-accent)]" : ""}`}
>
{flashing ? (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4">
<path d="m3.5 8.5 3 3 6-7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
action.icon
)}
{showLabels ? <span>{flashing ? feedback.text : action.label}</span> : null}
</button>
</React.Fragment>
);
})}
<span
aria-hidden="true"
className={`pointer-events-none absolute left-[var(--stb-arrow)] size-2.5 -translate-x-1/2 rotate-45 bg-inherit group-data-[side=bottom]/stb:-top-[5px] group-data-[side=top]/stb:-bottom-[5px] ${
variant === "surface" ? "border-zinc-900/10 group-data-[side=bottom]/stb:border-l group-data-[side=bottom]/stb:border-t group-data-[side=top]/stb:border-b group-data-[side=top]/stb:border-r dark:border-white/10" : ""
}`}
/>
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{feedback ? announcement : announceOpen}
</span>
</div>
);
}