@plannotator/ui 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
package/README.md
CHANGED
|
@@ -27,6 +27,9 @@ configurePlannotatorUI({
|
|
|
27
27
|
skillCatalogTransport, // skill-reference catalog for comment composers
|
|
28
28
|
skillContentTransport, // human-only skill contents for feedback injection
|
|
29
29
|
serverSync,
|
|
30
|
+
webmcp, // browser-agent (WebMCP) provider policy: { enabled, namePrefix }
|
|
31
|
+
mathRendererLoader, // how KaTeX loads when no renderer is registered before first math render
|
|
32
|
+
identityGenerator, // sync generator behind the default "tater" name (no identityProvider)
|
|
30
33
|
});
|
|
31
34
|
```
|
|
32
35
|
|
|
@@ -47,6 +50,16 @@ The sidebar/panel resize handle exposes seams for hosts that want different edge
|
|
|
47
50
|
|
|
48
51
|
Building your own tooltip and removing the built-in double-click reset are host-side concerns (override `onDoubleClick` where you render the handle).
|
|
49
52
|
|
|
53
|
+
### Lazy renderers and the eager entries (`utils/math`, `utils/generateIdentity`, `utils/mermaid`; 0.32.0)
|
|
54
|
+
|
|
55
|
+
The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
|
|
56
|
+
|
|
57
|
+
- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3).
|
|
58
|
+
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
|
|
59
|
+
- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
|
|
60
|
+
|
|
61
|
+
Plannotator's own entries import the eager modules (`math-eager` and `identity-tater` in both `packages/editor/App.tsx` and `packages/review-editor/App.tsx`; `mermaid-eager` in the plan editor only, since the review editor never renders a Mermaid block), which is what keeps its single-file builds byte-identical and its portal entry chunk shaped as before; `tests/entry-assets.test.ts` fails if any of them is dropped. See HANDOFF.md "Lazy renderers and eager entries".
|
|
62
|
+
|
|
50
63
|
### Markdown editor extensions + wiki links (`MarkdownEditor` / `InlineMarkdown`)
|
|
51
64
|
|
|
52
65
|
- **`MarkdownEditor` takes CM6 extensions.** `extensions?: readonly Extension[]` (from `@codemirror/state`) is forwarded verbatim into the underlying editor — the seam for `wikiLinks(config)`, `y-codemirror.next` collab bindings, custom keymaps.
|
|
@@ -60,6 +73,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
|
|
|
60
73
|
const editorExtensions = [wikiLinks({ suggest, resolve, onOpen })]; // stable reference!
|
|
61
74
|
<MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} />
|
|
62
75
|
```
|
|
76
|
+
- **`embedPicker(config)` and `embedSlashItem()` are re-exported from the same surface.** Compose the static item into `slashCommands({ items: [...] })` and pass the picker beside it in the stable `extensions` array. `getTargets`, `buildInsertLine`, optional `uploadTarget`, and optional `getNotice` stay live through callbacks. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exported `planEmbedInsert()`.
|
|
63
77
|
- **The viewer resolves wiki-links synchronously.** `InlineMarkdown` takes `resolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null` — called with the raw stored target (opaque ids like `doc_01XYZ`, no `.md` normalization). Return a `label` to display live titles (stored label is the fallback, target the last resort); return `status: 'deleted'` for a muted non-link ("Document deleted") instead of a live link. Absent or `null` → rendering is unchanged. Sync-only by design: back it with an in-memory cache.
|
|
64
78
|
|
|
65
79
|
Requires `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0`. See HANDOFF.md § "Wiki-link seams (0.27.0)".
|
|
@@ -84,6 +98,37 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
|
|
|
84
98
|
|
|
85
99
|
`components/html-viewer` is supported host surface as of 0.29.0: the overlay-projection annotation viewer for raw HTML (placed comment markers, pinpoint element anchors, shift-click multi-target comments). Its contract is **props plus the validated iframe message protocol** — not `configurePlannotatorUI()`, which only governs the backend surfaces around it. Drive the `annotations` prop (marker numbering derives from its array order); `readOnly` keeps markers painted and clickable while disabling all authoring. **0.29.0 also carries a breaking migration:** highlight.js is gone and `.hljs` selectors are inert — style code via the exported `pn-code` class (`CODE_BLOCK_CLASS` in `utils/codeHighlight`). See HANDOFF.md § "Raw-HTML annotation viewer + syntax-highlighting migration (0.29.0)" before upgrading from 0.28.0.
|
|
86
100
|
|
|
101
|
+
#### HTML annotation parity seams (0.32.0)
|
|
102
|
+
|
|
103
|
+
Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
|
|
104
|
+
|
|
105
|
+
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names.
|
|
106
|
+
- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side.
|
|
107
|
+
- **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
|
|
108
|
+
- **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
|
|
109
|
+
- **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
|
|
110
|
+
- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too).
|
|
111
|
+
- An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
|
|
112
|
+
|
|
113
|
+
See HANDOFF.md § "HTML annotation parity seams".
|
|
114
|
+
|
|
115
|
+
#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
|
|
116
|
+
|
|
117
|
+
- **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
|
|
118
|
+
- **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
|
|
119
|
+
|
|
120
|
+
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
121
|
+
|
|
122
|
+
The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
|
|
123
|
+
|
|
124
|
+
- `modelContext.ts` is the only file that spells the spec surface (local structural types, no `webmcp-types` dependency). A spec rename is a one-file change.
|
|
125
|
+
- `useToolset({ id, active, build, deps, hooks })` attaches a named tool set to the document registry; handlers read through refs, so re-renders never re-register, and `active: false` aborts every registration (what Plannotator's Settings opt-out drives).
|
|
126
|
+
- `AnnotationChangeTracker` / `buildNudges` are pure (no DOM): per-annotation `seq`, tombstones, a per-tab watermark with `since` override, and the nudge vocabulary every response carries.
|
|
127
|
+
- A host with its own document state builds the same adapter-driven catalog Plannotator uses (`packages/editor/webmcp/documentTools.ts`, `buildDocumentTools(adapter, state, options)`) over its own getters and actions; multi-document pages should register one set whose tools take `path` (the folder-session shape) rather than one set per viewer (duplicate names across sets are skipped with a warning, never replaced).
|
|
128
|
+
- Never register tools inside an untrusted iframe: the raw-HTML viewer's `sandbox="allow-scripts"` frame and the live-app frame carry no `allow="tools"`, and that is what keeps a framed page from impersonating the host's tools.
|
|
129
|
+
|
|
130
|
+
The one additive data-model change that rides with it: `Annotation.inReplyTo` (threaded replies; the panel indents them under the parent, the export nests them, share links drop them).
|
|
131
|
+
|
|
87
132
|
## Consuming it (e.g. from Workspaces)
|
|
88
133
|
|
|
89
134
|
```bash
|
|
@@ -107,7 +152,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
107
152
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
108
153
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
|
|
109
154
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
110
|
-
- Versioned in lockstep with the repo. Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow.
|
|
155
|
+
- Versioned in lockstep with the repo (currently `@plannotator/core` 0.25.0 with `@plannotator/ui` 0.32.0). Publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`** — the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
|
|
111
156
|
|
|
112
157
|
## The one rule
|
|
113
158
|
|
|
@@ -68,6 +68,7 @@ interface ActionMenuItemProps {
|
|
|
68
68
|
label: string;
|
|
69
69
|
subtitle?: string;
|
|
70
70
|
badge?: React.ReactNode;
|
|
71
|
+
disabled?: boolean;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
|
|
@@ -76,10 +77,14 @@ export const ActionMenuItem: React.FC<ActionMenuItemProps> = ({
|
|
|
76
77
|
label,
|
|
77
78
|
subtitle,
|
|
78
79
|
badge,
|
|
80
|
+
disabled = false,
|
|
79
81
|
}) => (
|
|
80
82
|
<button
|
|
83
|
+
data-pn-touch-target
|
|
84
|
+
type="button"
|
|
81
85
|
onClick={onClick}
|
|
82
|
-
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent"
|
|
83
88
|
>
|
|
84
89
|
<span className="text-muted-foreground">{icon}</span>
|
|
85
90
|
{subtitle ? (
|
package/components/AgentsTab.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from 'lucide-react';
|
|
16
16
|
import type { AgentJobInfo, AgentCapabilities } from '../types';
|
|
17
17
|
import { isTerminalStatus } from '@plannotator/core/agent-jobs';
|
|
18
|
+
import { GUIDE_ENGINE_LABEL } from '@plannotator/core/guide';
|
|
18
19
|
import { cn } from '../lib/utils';
|
|
19
20
|
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
|
|
20
21
|
import { ClaudeIcon, CodexIcon, CopilotIcon, CursorIcon, OpenCodeIcon, PiIcon } from './icons/AgentIcons';
|
|
@@ -127,14 +128,7 @@ const ENGINE_ICON: Record<AgentEngine, React.FC<{ className?: string }>> = {
|
|
|
127
128
|
// review surface offers the wider set (Cursor/OpenCode). Exported so the guide
|
|
128
129
|
// takeover surfaces (GuideScreen, GuideEmptyState in packages/review-editor)
|
|
129
130
|
// share this one source of truth instead of keeping their own copies in sync.
|
|
130
|
-
export const REVIEW_ENGINE_LABEL: Record<ReviewEngine, string> =
|
|
131
|
-
claude: 'Claude',
|
|
132
|
-
codex: 'Codex',
|
|
133
|
-
cursor: 'Cursor',
|
|
134
|
-
opencode: 'OpenCode',
|
|
135
|
-
pi: 'Pi',
|
|
136
|
-
copilot: 'Copilot',
|
|
137
|
-
};
|
|
131
|
+
export const REVIEW_ENGINE_LABEL: Record<ReviewEngine, string> = GUIDE_ENGINE_LABEL;
|
|
138
132
|
|
|
139
133
|
// Review-only icon map — the wide set. Tour keeps the narrow ENGINE_ICON.
|
|
140
134
|
const REVIEW_ENGINE_ICON: Record<ReviewEngine, React.FC<{ className?: string }>> = {
|
|
@@ -872,7 +866,12 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
|
|
|
872
866
|
? { effort: tourClaudeEffort }
|
|
873
867
|
: { reasoningEffort: tourCodexReasoning, ...(tourCodexFast && { fastMode: true }) }),
|
|
874
868
|
});
|
|
875
|
-
|
|
869
|
+
// Guide extra instructions (#1265) are server-stored: this surface has no
|
|
870
|
+
// editor, so it sends none and the server applies the stored standing
|
|
871
|
+
// instructions itself, keeping sidebar launches identical to launch-page
|
|
872
|
+
// ones without a second read path.
|
|
873
|
+
const buildGuideLaunch = (): LaunchParams => buildGuideEngineParams();
|
|
874
|
+
const buildGuideEngineParams = (): LaunchParams => {
|
|
876
875
|
if (guideEngine === 'cursor') {
|
|
877
876
|
// Same omission rules as buildReviewLaunch: auto/empty ⇒ engine default.
|
|
878
877
|
// Guide-scoped model — deliberately NOT the shared cursorModel (see
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface AnalysisLayerToggleProps {
|
|
4
|
+
readonly checked: boolean;
|
|
5
|
+
readonly onChange: (checked: boolean) => void;
|
|
6
|
+
readonly label: React.ReactNode;
|
|
7
|
+
readonly description: string;
|
|
8
|
+
readonly disabled?: boolean;
|
|
9
|
+
readonly className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Full-row switch shared by Settings and the code-review analysis welcome. */
|
|
13
|
+
export function AnalysisLayerToggle({
|
|
14
|
+
checked,
|
|
15
|
+
onChange,
|
|
16
|
+
label,
|
|
17
|
+
description,
|
|
18
|
+
disabled = false,
|
|
19
|
+
className = '',
|
|
20
|
+
}: AnalysisLayerToggleProps) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
role="switch"
|
|
25
|
+
aria-checked={checked}
|
|
26
|
+
disabled={disabled}
|
|
27
|
+
onClick={() => onChange(!checked)}
|
|
28
|
+
className={`flex min-h-11 w-full items-center justify-between gap-4 bg-transparent text-left disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
|
29
|
+
>
|
|
30
|
+
<span className="min-w-0">
|
|
31
|
+
<span className="block text-sm font-medium text-foreground">{label}</span>
|
|
32
|
+
<span className="mt-0.5 block text-xs leading-relaxed text-muted-foreground">{description}</span>
|
|
33
|
+
</span>
|
|
34
|
+
<span
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none ${
|
|
37
|
+
checked ? 'bg-primary' : 'bg-muted'
|
|
38
|
+
}`}
|
|
39
|
+
>
|
|
40
|
+
<span
|
|
41
|
+
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-150 ease-out motion-reduce:transition-none ${
|
|
42
|
+
checked ? 'translate-x-5' : 'translate-x-0'
|
|
43
|
+
}`}
|
|
44
|
+
/>
|
|
45
|
+
</span>
|
|
46
|
+
</button>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -38,6 +38,52 @@ const TrashCardIcon = () => (
|
|
|
38
38
|
</svg>
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Order annotations so every reply follows its parent (replies among
|
|
43
|
+
* themselves stay in creation order). A reply whose parent is absent renders
|
|
44
|
+
* as a top-level card. Without any `inReplyTo` the input order is returned
|
|
45
|
+
* unchanged, so annotations without replies render exactly as before.
|
|
46
|
+
*/
|
|
47
|
+
export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
|
|
48
|
+
if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
|
|
49
|
+
const ids = new Set(sorted.map((a) => a.id));
|
|
50
|
+
const byParent = new Map<string, Annotation[]>();
|
|
51
|
+
for (const a of sorted) {
|
|
52
|
+
if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) {
|
|
53
|
+
const list = byParent.get(a.inReplyTo) ?? [];
|
|
54
|
+
list.push(a);
|
|
55
|
+
byParent.set(a.inReplyTo, list);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
59
|
+
const emitted = new Set<string>();
|
|
60
|
+
const emit = (a: Annotation, isReply: boolean) => {
|
|
61
|
+
if (emitted.has(a.id)) return;
|
|
62
|
+
emitted.add(a.id);
|
|
63
|
+
out.push({ annotation: a, isReply });
|
|
64
|
+
for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
|
|
65
|
+
};
|
|
66
|
+
for (const a of sorted) {
|
|
67
|
+
if (a.inReplyTo && ids.has(a.inReplyTo) && a.inReplyTo !== a.id) continue;
|
|
68
|
+
emit(a, false);
|
|
69
|
+
}
|
|
70
|
+
for (const a of sorted) emit(a, false);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Timeline position of an annotation: its own time, or its thread root's for replies. */
|
|
75
|
+
function threadTs(annotation: Annotation, all: Annotation[]): number {
|
|
76
|
+
let current = annotation;
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
while (current.inReplyTo && !seen.has(current.id)) {
|
|
79
|
+
seen.add(current.id);
|
|
80
|
+
const parent = all.find((a) => a.id === current.inReplyTo);
|
|
81
|
+
if (!parent) break;
|
|
82
|
+
current = parent;
|
|
83
|
+
}
|
|
84
|
+
return current.createdA;
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
interface DirectEditsPanelItem {
|
|
42
88
|
id: string;
|
|
43
89
|
title?: string;
|
|
@@ -86,6 +132,13 @@ interface PanelProps {
|
|
|
86
132
|
* gates what belongs in it. Selection and scrolling still work.
|
|
87
133
|
* Default false — today's behavior. */
|
|
88
134
|
readOnly?: boolean;
|
|
135
|
+
/** Embed only the timeline body in a host-owned stage. The host owns the
|
|
136
|
+
* title, close control, visible-viewport geometry, and focus boundary. */
|
|
137
|
+
presentation?: 'panel' | 'embedded';
|
|
138
|
+
/** Ids of annotations with no live location in the document (e.g. the
|
|
139
|
+
* HTML viewer's onUnanchoredChange report after a refresh). Matching
|
|
140
|
+
* cards show a small "Unanchored" chip. Absent: no chip, DOM unchanged. */
|
|
141
|
+
unanchoredIds?: ReadonlySet<string>;
|
|
89
142
|
}
|
|
90
143
|
|
|
91
144
|
export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
@@ -111,16 +164,29 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
111
164
|
directEdits = null,
|
|
112
165
|
renderCardFooter,
|
|
113
166
|
readOnly = false,
|
|
167
|
+
presentation = 'panel',
|
|
168
|
+
unanchoredIds,
|
|
114
169
|
}) => {
|
|
115
170
|
const isMobile = useIsMobile();
|
|
171
|
+
const embedded = presentation === 'embedded';
|
|
172
|
+
const mobilePanel = isMobile && !embedded;
|
|
116
173
|
const [copiedText, setCopiedText] = useState(false);
|
|
117
174
|
const listRef = useRef<HTMLDivElement>(null);
|
|
118
175
|
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
|
|
119
176
|
const sortedCodeAnnotations = [...codeAnnotations].sort((a, b) => a.createdAt - b.createdAt);
|
|
177
|
+
// Replies (`inReplyTo`) thread under their parent: each reply is lifted to
|
|
178
|
+
// sit right after its parent (and the parent's earlier replies) at the
|
|
179
|
+
// parent's timeline position. With no replies the order is untouched.
|
|
180
|
+
const threadedAnnotations = threadReplies(sortedAnnotations);
|
|
120
181
|
const timelineEntries = [
|
|
121
|
-
...
|
|
122
|
-
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation })),
|
|
123
|
-
].sort((a, b) =>
|
|
182
|
+
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
|
|
183
|
+
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
|
|
184
|
+
].sort((a, b) => {
|
|
185
|
+
const ta = a.kind === 'plan' ? threadTs(a.annotation, sortedAnnotations) : a.ts;
|
|
186
|
+
const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
|
|
187
|
+
if (ta !== tb) return ta - tb;
|
|
188
|
+
return a.ts - b.ts;
|
|
189
|
+
});
|
|
124
190
|
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
|
125
191
|
|
|
126
192
|
// Scroll selected annotation card into view
|
|
@@ -138,47 +204,61 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
138
204
|
<aside
|
|
139
205
|
data-annotation-panel="true"
|
|
140
206
|
data-plan-sidebar="right"
|
|
141
|
-
className={`
|
|
142
|
-
|
|
207
|
+
className={`bg-card flex flex-col ${embedded ? 'h-full min-h-0 w-full flex-1' : 'flex-shrink-0 border-l border-border/50'} ${
|
|
208
|
+
mobilePanel ? 'fixed top-12 bottom-0 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : ''
|
|
143
209
|
}`}
|
|
144
|
-
style={
|
|
210
|
+
style={embedded || mobilePanel ? undefined : { width: width ?? 288 }}
|
|
145
211
|
>
|
|
146
212
|
{/* Header */}
|
|
147
|
-
|
|
148
|
-
<div className="
|
|
149
|
-
<div className="flex items-center
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
213
|
+
{!embedded && (
|
|
214
|
+
<div className="border-b border-border/50">
|
|
215
|
+
<div className="flex h-10 items-center justify-between px-3">
|
|
216
|
+
<div className="flex items-center gap-2">
|
|
217
|
+
<h2 className="text-xs font-medium text-foreground">
|
|
218
|
+
Annotations
|
|
219
|
+
</h2>
|
|
220
|
+
{totalCount > 0 && (
|
|
221
|
+
<span className="flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-primary/10 px-1 font-mono text-[10px] font-medium tabular-nums text-primary">
|
|
222
|
+
{totalCount}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
{mobilePanel && onClose && (
|
|
227
|
+
<button
|
|
228
|
+
onClick={onClose}
|
|
229
|
+
className="relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] hover:text-foreground md:hidden"
|
|
230
|
+
title="Close panel"
|
|
231
|
+
aria-label="Close panel"
|
|
232
|
+
>
|
|
233
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
234
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
235
|
+
</svg>
|
|
236
|
+
</button>
|
|
157
237
|
)}
|
|
158
238
|
</div>
|
|
159
|
-
{
|
|
239
|
+
{otherFileAnnotations && otherFileAnnotations.count > 0 && (
|
|
160
240
|
<button
|
|
161
|
-
onClick={
|
|
162
|
-
className="
|
|
163
|
-
title="
|
|
164
|
-
aria-label="Close panel"
|
|
241
|
+
onClick={onOtherFileAnnotationsClick}
|
|
242
|
+
className="px-3 pb-2 text-[10px] text-primary/70 hover:text-primary transition-colors cursor-pointer"
|
|
243
|
+
title="Show annotated files in sidebar"
|
|
165
244
|
>
|
|
166
|
-
|
|
167
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
168
|
-
</svg>
|
|
245
|
+
+{otherFileAnnotations.count} in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
|
|
169
246
|
</button>
|
|
170
247
|
)}
|
|
171
248
|
</div>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{embedded && otherFileAnnotations && otherFileAnnotations.count > 0 && (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
data-pn-touch-target="true"
|
|
255
|
+
onClick={onOtherFileAnnotationsClick}
|
|
256
|
+
className="min-h-11 flex-shrink-0 border-b border-border/50 px-3 text-left text-xs text-primary/80 active:bg-muted"
|
|
257
|
+
title="Show annotated files in navigator"
|
|
258
|
+
>
|
|
259
|
+
{otherFileAnnotations.count} more in {otherFileAnnotations.files} other file{otherFileAnnotations.files === 1 ? '' : 's'}
|
|
260
|
+
</button>
|
|
261
|
+
)}
|
|
182
262
|
|
|
183
263
|
{/* List */}
|
|
184
264
|
<OverlayScrollArea className="flex-1 min-h-0">
|
|
@@ -201,6 +281,25 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
201
281
|
<>
|
|
202
282
|
{timelineEntries.map(entry => (
|
|
203
283
|
entry.kind === 'plan' ? (
|
|
284
|
+
entry.isReply ? (
|
|
285
|
+
<div
|
|
286
|
+
key={entry.annotation.id}
|
|
287
|
+
data-annotation-reply="true"
|
|
288
|
+
className="ml-3 border-l-2 border-border/40 pl-1.5"
|
|
289
|
+
>
|
|
290
|
+
<AnnotationCard
|
|
291
|
+
annotation={entry.annotation}
|
|
292
|
+
isSelected={selectedId === entry.annotation.id}
|
|
293
|
+
isMe={isCurrentUser(entry.annotation.author)}
|
|
294
|
+
onSelect={() => onSelect(entry.annotation.id)}
|
|
295
|
+
onDelete={() => onDelete(entry.annotation.id)}
|
|
296
|
+
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
297
|
+
readOnly={readOnly}
|
|
298
|
+
footer={renderCardFooter?.(entry.annotation)}
|
|
299
|
+
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
) : (
|
|
204
303
|
<AnnotationCard
|
|
205
304
|
key={entry.annotation.id}
|
|
206
305
|
annotation={entry.annotation}
|
|
@@ -211,7 +310,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
211
310
|
onEdit={onEdit ? (updates: Partial<Annotation>) => onEdit(entry.annotation.id, updates) : undefined}
|
|
212
311
|
readOnly={readOnly}
|
|
213
312
|
footer={renderCardFooter?.(entry.annotation)}
|
|
313
|
+
unanchored={unanchoredIds?.has(entry.annotation.id) ?? false}
|
|
214
314
|
/>
|
|
315
|
+
)
|
|
215
316
|
) : (
|
|
216
317
|
<CodeAnnotationCard
|
|
217
318
|
key={entry.annotation.id}
|
|
@@ -297,7 +398,7 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
297
398
|
</aside>
|
|
298
399
|
);
|
|
299
400
|
|
|
300
|
-
if (
|
|
401
|
+
if (mobilePanel) {
|
|
301
402
|
return (
|
|
302
403
|
<>
|
|
303
404
|
<div
|
|
@@ -438,7 +539,9 @@ const AnnotationCard: React.FC<{
|
|
|
438
539
|
onEdit?: (updates: Partial<Annotation>) => void;
|
|
439
540
|
readOnly?: boolean;
|
|
440
541
|
footer?: React.ReactNode;
|
|
441
|
-
|
|
542
|
+
/** The annotation has no live location in the document (host-reported). */
|
|
543
|
+
unanchored?: boolean;
|
|
544
|
+
}> = ({ annotation, isSelected, isMe, onSelect, onDelete, onEdit, readOnly = false, footer, unanchored = false }) => {
|
|
442
545
|
const [isEditing, setIsEditing] = useState(false);
|
|
443
546
|
const [editText, setEditText] = useState(annotation.text || '');
|
|
444
547
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -493,6 +596,7 @@ const AnnotationCard: React.FC<{
|
|
|
493
596
|
const editComposer = (
|
|
494
597
|
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
495
598
|
<textarea
|
|
599
|
+
data-pn-mobile-editable="true"
|
|
496
600
|
ref={textareaRef}
|
|
497
601
|
value={editText}
|
|
498
602
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditText(e.target.value)}
|
|
@@ -534,6 +638,23 @@ const AnnotationCard: React.FC<{
|
|
|
534
638
|
diff
|
|
535
639
|
</span>
|
|
536
640
|
)}
|
|
641
|
+
{annotation.pageUrl && (
|
|
642
|
+
<span
|
|
643
|
+
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground truncate max-w-[10rem]"
|
|
644
|
+
title={annotation.pageUrl}
|
|
645
|
+
>
|
|
646
|
+
{annotation.pageUrl}
|
|
647
|
+
</span>
|
|
648
|
+
)}
|
|
649
|
+
{unanchored && (
|
|
650
|
+
<span
|
|
651
|
+
data-annotation-unanchored="true"
|
|
652
|
+
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground"
|
|
653
|
+
title="This comment no longer matches a location in the document"
|
|
654
|
+
>
|
|
655
|
+
Unanchored
|
|
656
|
+
</span>
|
|
657
|
+
)}
|
|
537
658
|
<span className="text-[10px] text-muted-foreground/50 truncate">
|
|
538
659
|
{annotation.author ? `${annotation.author}${isMe ? ' (me)' : ''} · ` : ''}{formatTimestamp(annotation.createdA)}
|
|
539
660
|
</span>
|
|
@@ -724,6 +845,7 @@ const CodeAnnotationCard: React.FC<{
|
|
|
724
845
|
{isEditing ? (
|
|
725
846
|
<div className="mt-1.5" onClick={(e) => e.stopPropagation()}>
|
|
726
847
|
<textarea
|
|
848
|
+
data-pn-mobile-editable="true"
|
|
727
849
|
ref={textareaRef}
|
|
728
850
|
value={editText}
|
|
729
851
|
onChange={(e) => setEditText(e.target.value)}
|
|
@@ -2,19 +2,13 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
|
2
2
|
import { AnnotationType } from "../types";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
|
|
5
|
-
import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
|
|
5
|
+
import { type QuickLabel, getQuickLabels, THUMBS_UP_LABEL } from "../utils/quickLabels";
|
|
6
6
|
import { copyTextToClipboard } from "../utils/clipboard";
|
|
7
|
+
import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
|
|
7
8
|
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
|
|
8
9
|
|
|
9
10
|
type PositionMode = 'center-above' | 'top-right';
|
|
10
11
|
|
|
11
|
-
const THUMBS_UP_LABEL: QuickLabel = {
|
|
12
|
-
id: 'thumbs-up',
|
|
13
|
-
emoji: '👍',
|
|
14
|
-
text: 'Looks good',
|
|
15
|
-
color: 'green',
|
|
16
|
-
};
|
|
17
|
-
|
|
18
12
|
const isEditableElement = (node: EventTarget | Element | null): boolean => {
|
|
19
13
|
if (!(node instanceof Element)) return false;
|
|
20
14
|
if (node.matches('input, textarea, select, [role="textbox"]')) return true;
|
|
@@ -33,6 +27,12 @@ interface AnnotationToolbarProps {
|
|
|
33
27
|
onQuickLabel?: (label: QuickLabel) => void;
|
|
34
28
|
/** Text to copy when the button is clicked */
|
|
35
29
|
copyText?: string;
|
|
30
|
+
/** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
|
|
31
|
+
* the quick-label picker, and the Alt+digit label shortcuts. A provided
|
|
32
|
+
* onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
|
|
33
|
+
* the one label affordance restored to these surfaces. Markdown surfaces
|
|
34
|
+
* keep the full toolbar. */
|
|
35
|
+
commentOnly?: boolean;
|
|
36
36
|
/** Hide the copy button (set when a keyboard copy handler exists) */
|
|
37
37
|
hideCopyButton?: boolean;
|
|
38
38
|
/** Close toolbar when element scrolls out of viewport */
|
|
@@ -52,6 +52,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
52
52
|
onRequestComment,
|
|
53
53
|
onQuickLabel,
|
|
54
54
|
copyText,
|
|
55
|
+
commentOnly = false,
|
|
55
56
|
hideCopyButton = false,
|
|
56
57
|
closeOnScrollOut = false,
|
|
57
58
|
isExiting = false,
|
|
@@ -126,14 +127,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
126
127
|
return;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
// Alt+N applies quick label (picker closed)
|
|
130
|
+
// Alt+N applies quick label (picker closed). Comment-only surfaces
|
|
131
|
+
// suppress this path: their only label affordance is the 👍 button.
|
|
130
132
|
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
|
|
131
133
|
if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
|
|
132
134
|
e.preventDefault();
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
if (!commentOnly) {
|
|
136
|
+
const digit = parseInt(e.code.slice(5), 10);
|
|
137
|
+
const index = digit === 0 ? 9 : digit - 1;
|
|
138
|
+
if (index < quickLabels.length) {
|
|
139
|
+
onQuickLabel?.(quickLabels[index]);
|
|
140
|
+
}
|
|
137
141
|
}
|
|
138
142
|
return;
|
|
139
143
|
}
|
|
@@ -146,8 +150,15 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
146
150
|
};
|
|
147
151
|
|
|
148
152
|
window.addEventListener("keydown", handleKeyDown);
|
|
149
|
-
|
|
150
|
-
|
|
153
|
+
// While this listener owns printable keys, the Shift+1..4 annotation-mode
|
|
154
|
+
// shortcuts must not fire: Shift+3 is "#", and a user typing "#" into a
|
|
155
|
+
// starting comment must not silently arm Redline (#1244 follow-up).
|
|
156
|
+
const releaseCapture = acquireTypeToCommentCapture();
|
|
157
|
+
return () => {
|
|
158
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
159
|
+
releaseCapture();
|
|
160
|
+
};
|
|
161
|
+
}, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels, commentOnly]);
|
|
151
162
|
|
|
152
163
|
useDismissOnOutsideAndEscape({
|
|
153
164
|
enabled: !showQuickLabels,
|
|
@@ -197,7 +208,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
197
208
|
to { opacity: 0; transform: translateY(8px)${translateX}; }
|
|
198
209
|
}
|
|
199
210
|
`}</style>
|
|
200
|
-
<div className="flex items-center p-1 gap-0.5">
|
|
211
|
+
<div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
|
|
201
212
|
{!hideCopyButton && (
|
|
202
213
|
<>
|
|
203
214
|
<ToolbarButton
|
|
@@ -209,12 +220,14 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
209
220
|
<div className="w-px h-5 bg-border mx-0.5" />
|
|
210
221
|
</>
|
|
211
222
|
)}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
{!commentOnly && (
|
|
224
|
+
<ToolbarButton
|
|
225
|
+
onClick={() => handleTypeSelect(AnnotationType.DELETION)}
|
|
226
|
+
icon={<TrashIcon />}
|
|
227
|
+
label="Delete"
|
|
228
|
+
className="text-destructive hover:bg-destructive/10"
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
218
231
|
<ToolbarButton
|
|
219
232
|
onClick={() => handleTypeSelect(AnnotationType.COMMENT)}
|
|
220
233
|
icon={<CommentIcon />}
|
|
@@ -223,20 +236,22 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
|
|
|
223
236
|
/>
|
|
224
237
|
{onQuickLabel && (
|
|
225
238
|
<>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
{!commentOnly && (
|
|
240
|
+
<ToolbarButton
|
|
241
|
+
ref={zapButtonRef}
|
|
242
|
+
onClick={() => setShowQuickLabels(prev => !prev)}
|
|
243
|
+
icon={<ZapIcon />}
|
|
244
|
+
label="Quick label"
|
|
245
|
+
className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
233
248
|
<ToolbarButton
|
|
234
249
|
onClick={() => onQuickLabel(THUMBS_UP_LABEL)}
|
|
235
250
|
icon={<span className="block w-4 h-4 text-sm leading-4 text-center">👍</span>}
|
|
236
251
|
label="Looks good"
|
|
237
252
|
className="hover:bg-green-500/10"
|
|
238
253
|
/>
|
|
239
|
-
{showQuickLabels && zapButtonRef.current && (
|
|
254
|
+
{!commentOnly && showQuickLabels && zapButtonRef.current && (
|
|
240
255
|
<FloatingQuickLabelPicker
|
|
241
256
|
anchorEl={zapButtonRef.current}
|
|
242
257
|
onSelect={(label) => {
|
|
@@ -306,6 +321,11 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
|
|
|
306
321
|
}>(({ onClick, icon, label, className }, ref) => (
|
|
307
322
|
<button
|
|
308
323
|
ref={ref}
|
|
324
|
+
// Icon-only controls: the markers are inert outside the compact touch
|
|
325
|
+
// scope, where theme.css grows them to var(--pn-touch-target). Desktop
|
|
326
|
+
// geometry is unchanged.
|
|
327
|
+
data-pn-touch-target="true"
|
|
328
|
+
data-pn-touch-target-icon="true"
|
|
309
329
|
onClick={onClick}
|
|
310
330
|
title={label}
|
|
311
331
|
className={`p-1.5 rounded-md transition-colors ${className}`}
|