/**
* Header controls for a raw-HTML or live-app annotation surface: the optional
* back (leave a linked document), the eye (show/hide the floating tools over
* the page), the optional refresh, and the pen (Annotate/Interact toggle).
* Presentation only; every state lives in the host. Each control renders only
* when its handler is passed, so a read-only document can show the eye
* without a pen.
*
* Back exists because the sidebar's "Viewing / Back to …" header is not a
* dependable way out of a raw-HTML linked document: an HTML surface opens
* with the sidebar closed and navigating between HTML documents deliberately
* leaves it closed, so the way back has to live in the header. It carries no
* keyboard shortcut — `Alt`+`Left` and the browser's own Back are the user's,
* not ours.
*
* The markup, data attributes (`data-html-back`, `data-html-tools-toggle`,
* `data-html-refresh`, `data-html-annotate-toggle`), aria state and the pixel-stable pen border
* are the exact ones Plannotator's header shipped with; hosts get the same
* control, and `labels` overrides the strings without touching the DOM.
*
* Each control's description rides the app's `Tooltip` (hover AND
* focus-visible, theme tokens, portal) instead of a native `title`, with the
* control's keyboard shortcut rendered under it as keycaps through the
* shortcut registry's platform-aware formatter — never a hardcoded "Cmd".
* The strings are the same `labels` values the titles used, so a host's
* overrides keep working; because `title` no longer supplies the accessible
* name, each button carries it explicitly (the pen an `aria-label`, the eye
* its sr-only text, the refresh its existing `aria-label`), and the shortcut
* is also attached as a persistent `aria-describedby` so it is announced
* rather than only drawn.
*/
import React from 'react';
import { Tooltip } from './Tooltip';
import { modKey } from '../utils/platform';
import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
/** Binding overrides, in the registry's normalized syntax (`Mod+Shift+X`).
* `null` renders no shortcut row for that control. Defaults come from the
* `html-annotate` scope, so the tooltips can never drift from the chords the
* app actually dispatches; `refresh` has no chord and defaults to none. */
export interface HtmlSurfaceControlShortcuts {
annotate?: string | null;
tools?: string | null;
refresh?: string | null;
back?: string | null;
}
const DEFAULT_HTML_SURFACE_CONTROL_SHORTCUTS: Required = {
annotate: htmlAnnotateShortcuts.shortcuts.toggleAnnotateMode.bindings[0] ?? null,
tools: htmlAnnotateShortcuts.shortcuts.toggleTools.bindings[0] ?? null,
refresh: null,
// Deliberately none: Alt+Left and the browser's Back belong to the user.
back: null,
};
/** Description + keycaps. Two lines, so the tooltip answers both "what does
* this do" and "what do I press" without a second hover. */
function ControlTooltip({
description,
binding,
children,
}: {
description: string;
binding: string | null;
children: React.ReactElement;
}) {
const keys = binding ? formatShortcutBindingTokens(binding) : null;
return (
{description}
{keys && keys.length > 0 && (
{keys.map((key, index) => (
{key}
))}
)}
)}
>
{children}
);
}
/** The shortcut as prose for assistive tech ("Cmd+Shift+X"), which keycaps
* alone would not announce. */
function ShortcutDescription({ id, binding }: { id: string; binding: string | null }) {
if (!binding) return null;
return (
{`Shortcut: ${formatShortcutBindingText(binding)}`}
);
}
/** String overrides. Every key optional; defaults are Plannotator's strings. */
export interface HtmlSurfaceControlLabels {
/** Pen tooltip description while Annotate is armed. */
annotateTitle?: string;
/** Pen tooltip description while in Interact mode. */
interactTitle?: string;
/** Pen aria-label while armed. Default: the armed description. */
annotateLabel?: string;
/** Pen aria-label while in Interact mode. Default: the interact description. */
interactLabel?: string;
/** Eye tooltip description and screen-reader text while the tools are visible. */
hideTools?: string;
/** Eye tooltip description and screen-reader text while the tools are hidden. */
showTools?: string;
/** Refresh visible text while idle. */
refresh?: string;
/** Refresh visible text while a refresh is in flight. */
refreshing?: string;
/** Refresh tooltip description and aria-label while idle. */
refreshTitle?: string;
/** Refresh tooltip description and aria-label while a refresh is in flight. */
refreshingTitle?: string;
/** Back visible text. */
back?: string;
/** Back tooltip description and aria-label. `backDescription` overrides it
* per render, which is how a host names the document Back returns to. */
backTitle?: string;
}
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
Omit
> = {
annotateTitle: `Annotate mode: click an element or select text to comment; links open (${modKey}-click one to comment on it). Esc to interact`,
interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
hideTools: 'Hide tools',
showTools: 'Show tools',
refresh: 'Refresh',
refreshing: 'Refreshing',
refreshTitle: 'Refresh document',
refreshingTitle: 'Refreshing document',
back: 'Back',
backTitle: 'Back to the document this one was opened from',
};
// Stable ids: one HTML surface renders at most one of each control.
const ANNOTATE_SHORTCUT_ID = 'pn-html-annotate-shortcut';
const TOOLS_SHORTCUT_ID = 'pn-html-tools-shortcut';
const REFRESH_SHORTCUT_ID = 'pn-html-refresh-shortcut';
const BACK_SHORTCUT_ID = 'pn-html-back-shortcut';
export interface HtmlSurfaceControlsProps {
/** Whether Annotate is armed (pen pressed). */
armed: boolean;
/** Flip Annotate/Interact. The pen renders only when provided. */
onToggleArmed?: () => void;
/** Whether the floating tools over the page are hidden (eye-off). */
toolsHidden?: boolean;
/** Flip the tools. The eye renders only when provided. */
onToggleTools?: () => void;
/** Whether a refresh is offered for this document. The refresh renders
* whenever this is true and `onRefresh` is passed, with or without the eye. */
canRefresh?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
/** Leave the linked document for the one the session opened from. The back
* control renders only when provided — i.e. while a linked document is open. */
onBack?: () => void;
/** Tooltip description and aria-label for back, e.g. "Back to index.html".
* Default: `labels.backTitle`. */
backDescription?: string;
/** Compact touch shells put these actions in a menu instead: render nothing. */
compact?: boolean;
labels?: HtmlSurfaceControlLabels;
/** Per-control keyboard shortcuts shown in the tooltips. */
shortcuts?: HtmlSurfaceControlShortcuts;
}
export function HtmlSurfaceControls({
armed,
onToggleArmed,
toolsHidden = false,
onToggleTools,
canRefresh = false,
onRefresh,
isRefreshing = false,
onBack,
backDescription,
compact = false,
labels,
shortcuts,
}: HtmlSurfaceControlsProps) {
if (compact) return null;
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
const keys = { ...DEFAULT_HTML_SURFACE_CONTROL_SHORTCUTS, ...shortcuts };
const penDescription = armed ? text.annotateTitle : text.interactTitle;
// `title` used to carry the pen's accessible name; with the tooltip in its
// place the name has to be stated, or the pen becomes an unnamed button.
const penLabel = (armed ? labels?.annotateLabel : labels?.interactLabel) ?? penDescription;
const toolsDescription = toolsHidden ? text.showTools : text.hideTools;
const refreshDescription = isRefreshing ? text.refreshingTitle : text.refreshTitle;
const showRefresh = canRefresh && !!onRefresh;
const backLabel = backDescription ?? text.backTitle;
return (
<>
{/* Back, leftmost: the way out of a linked document that does not
depend on the sidebar being open. */}
{onBack && (
<>
>
)}
{/* The refresh and the eye share one group, left of the pen and right
of back. Each
renders on its own terms: the refresh whenever it is offered
(canRefresh + onRefresh), the eye whenever onToggleTools is passed,
so a host without the tools toggle still gets its refresh.
Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
the comment/attachments cluster) from the DOM, leaving nothing over
the page. This button is the only way back, so it never hides
itself. Eye = tools visible, eye-off = hidden. */}
{(showRefresh || onToggleTools) && (
)}
{/* Interact/Annotate toggle. A PEN icon (deliberately not a speech
bubble: an annotations-panel bubble beside it must stay
distinguishable at a glance, and so must AI sparkles). Always the
same icon: armed shows the accent color plus a visible border;
unarmed is muted with a TRANSPARENT border of the same width, so
the button's box is pixel-identical in both states. */}
{onToggleArmed && (
)}
{onToggleArmed && }
>
);
}