@plannotator/ui 0.53.1 → 0.55.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.
Files changed (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react';
10
10
  import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian';
11
11
  import { getBearSettings } from '../utils/bear';
12
12
  import { getOctarineSettings } from '../utils/octarine';
13
+ import { getNotionParentPageId, isNotionAvailable } from '../utils/notion';
13
14
  import { wrapFeedbackForAgent } from '../utils/parser';
14
15
  import { copyTextToClipboard } from '../utils/clipboard';
15
16
  import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession';
@@ -20,11 +21,12 @@ interface SaveToNotesPayload {
20
21
  obsidian?: object;
21
22
  bear?: object;
22
23
  octarine?: object;
24
+ notion?: object;
23
25
  }
24
26
 
25
27
  /** Parsed response from the notes endpoint. */
26
28
  interface SaveToNotesResult {
27
- results?: Record<string, { success?: boolean; error?: string }>;
29
+ results?: Record<string, { success?: boolean; error?: string; url?: string }>;
28
30
  }
29
31
 
30
32
  /** Default save-to-notes wire: today's literal POST to /api/save-notes. */
@@ -74,7 +76,7 @@ interface ExportModalProps {
74
76
 
75
77
  type Tab = 'share' | 'annotations' | 'notes';
76
78
 
77
- type SaveTarget = 'obsidian' | 'bear' | 'octarine';
79
+ type SaveTarget = 'obsidian' | 'bear' | 'octarine' | 'notion';
78
80
  type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
79
81
 
80
82
  export const ExportModal: React.FC<ExportModalProps> = ({
@@ -99,8 +101,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
99
101
  const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations');
100
102
  const [activeTab, setActiveTab] = useState<Tab>(defaultTab);
101
103
  const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false);
102
- const [saveStatus, setSaveStatus] = useState<Record<SaveTarget, SaveStatus>>({ obsidian: 'idle', bear: 'idle', octarine: 'idle' });
104
+ const [saveStatus, setSaveStatus] = useState<Record<SaveTarget, SaveStatus>>({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' });
103
105
  const [saveErrors, setSaveErrors] = useState<Record<string, string>>({});
106
+ const [savedUrls, setSavedUrls] = useState<Partial<Record<SaveTarget, string>>>({});
104
107
 
105
108
  // Reset tab when modal opens
106
109
  useEffect(() => {
@@ -112,8 +115,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
112
115
  // Reset save status when modal opens
113
116
  useEffect(() => {
114
117
  if (isOpen) {
115
- setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle' });
118
+ setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' });
116
119
  setSaveErrors({});
120
+ setSavedUrls({});
117
121
  }
118
122
  }, [isOpen]);
119
123
 
@@ -127,6 +131,10 @@ export const ExportModal: React.FC<ExportModalProps> = ({
127
131
  const isObsidianReady = obsidianSettings.enabled && effectiveVaultPath.trim().length > 0;
128
132
  const isBearReady = bearSettings.enabled;
129
133
  const isOctarineReady = octarineSettings.enabled && octarineSettings.workspace.trim().length > 0;
134
+ // Notion shows only when this server offers it (NOTION_TOKEN set, local session).
135
+ const notionAvailable = isNotionAvailable();
136
+ const notionParentPageId = getNotionParentPageId();
137
+ const isNotionReady = notionParentPageId !== null;
130
138
 
131
139
  const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => {
132
140
  if (await copyTextToClipboard(text)) {
@@ -162,7 +170,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
162
170
  setSaveStatus(prev => ({ ...prev, [target]: 'saving' }));
163
171
  setSaveErrors(prev => { const next = { ...prev }; delete next[target]; return next; });
164
172
 
165
- const body: { obsidian?: object; bear?: object; octarine?: object } = {};
173
+ const body: { obsidian?: object; bear?: object; octarine?: object; notion?: object } = {};
166
174
 
167
175
  if (target === 'obsidian') {
168
176
  body.obsidian = {
@@ -183,6 +191,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
183
191
  folder: octarineSettings.folder || 'plannotator',
184
192
  };
185
193
  }
194
+ if (target === 'notion' && notionParentPageId) {
195
+ body.notion = { plan: markdown, parentPageId: notionParentPageId };
196
+ }
186
197
 
187
198
  try {
188
199
  const data = await onSaveToNotes(body);
@@ -190,6 +201,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
190
201
 
191
202
  if (result?.success) {
192
203
  setSaveStatus(prev => ({ ...prev, [target]: 'success' }));
204
+ if (result.url) setSavedUrls(prev => ({ ...prev, [target]: result.url! }));
193
205
  } else {
194
206
  setSaveStatus(prev => ({ ...prev, [target]: 'error' }));
195
207
  setSaveErrors(prev => ({ ...prev, [target]: result?.error || 'Save failed' }));
@@ -205,10 +217,11 @@ export const ExportModal: React.FC<ExportModalProps> = ({
205
217
  if (isObsidianReady) targets.push('obsidian');
206
218
  if (isBearReady) targets.push('bear');
207
219
  if (isOctarineReady) targets.push('octarine');
220
+ if (isNotionReady) targets.push('notion');
208
221
  await Promise.all(targets.map(t => handleSaveToNotes(t)));
209
222
  };
210
223
 
211
- const readyCount = [isObsidianReady, isBearReady, isOctarineReady].filter(Boolean).length;
224
+ const readyCount = [isObsidianReady, isBearReady, isOctarineReady, isNotionReady].filter(Boolean).length;
212
225
 
213
226
  // Determine which tabs to show
214
227
  const showTabs = sharingEnabled || showNotesTab;
@@ -538,12 +551,69 @@ export const ExportModal: React.FC<ExportModalProps> = ({
538
551
  )}
539
552
  </div>
540
553
 
554
+ {/* Notion */}
555
+ {notionAvailable && (
556
+ <div className="border border-border rounded-lg p-3 space-y-2">
557
+ <div className="flex items-center justify-between">
558
+ <div className="flex items-center gap-2">
559
+ <span className={`w-2 h-2 rounded-full ${isNotionReady ? 'bg-success' : 'bg-muted-foreground/30'}`} />
560
+ <span className="text-sm font-medium">Notion</span>
561
+ </div>
562
+ {isNotionReady ? (
563
+ <button
564
+ onClick={() => handleSaveToNotes('notion')}
565
+ disabled={saveStatus.notion === 'saving'}
566
+ className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
567
+ saveStatus.notion === 'success'
568
+ ? 'bg-success/15 text-success'
569
+ : saveStatus.notion === 'error'
570
+ ? 'bg-destructive/15 text-destructive'
571
+ : saveStatus.notion === 'saving'
572
+ ? 'bg-muted text-muted-foreground opacity-50'
573
+ : 'bg-primary text-primary-foreground hover:opacity-90'
574
+ }`}
575
+ >
576
+ {saveStatus.notion === 'saving' ? 'Saving...'
577
+ : saveStatus.notion === 'success' ? 'Saved'
578
+ : saveStatus.notion === 'error' ? 'Failed'
579
+ : 'Save'}
580
+ </button>
581
+ ) : (
582
+ <span className="text-xs text-muted-foreground">Not configured</span>
583
+ )}
584
+ </div>
585
+ {isNotionReady && (
586
+ <div className="text-[10px] text-muted-foreground/70">
587
+ Parent page: {notionParentPageId}
588
+ </div>
589
+ )}
590
+ {savedUrls.notion?.startsWith('https://') && (
591
+ <a
592
+ href={savedUrls.notion}
593
+ target="_blank"
594
+ rel="noreferrer"
595
+ className="block text-[10px] text-primary hover:underline truncate"
596
+ >
597
+ Open saved page
598
+ </a>
599
+ )}
600
+ {!isNotionReady && (
601
+ <div className="text-[10px] text-muted-foreground/70">
602
+ Enable in Settings &gt; Saving &gt; Notion
603
+ </div>
604
+ )}
605
+ {saveErrors.notion && (
606
+ <div className="text-[10px] text-destructive">{saveErrors.notion}</div>
607
+ )}
608
+ </div>
609
+ )}
610
+
541
611
  {/* Save All button */}
542
612
  {readyCount >= 2 && (
543
613
  <div className="flex justify-end">
544
614
  <button
545
615
  onClick={handleSaveAll}
546
- disabled={saveStatus.obsidian === 'saving' || saveStatus.bear === 'saving' || saveStatus.octarine === 'saving'}
616
+ disabled={saveStatus.obsidian === 'saving' || saveStatus.bear === 'saving' || saveStatus.octarine === 'saving' || saveStatus.notion === 'saving'}
547
617
  className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
548
618
  >
549
619
  Save All
@@ -33,9 +33,6 @@ export function getComponentTooltips(): boolean {
33
33
  return componentTooltips;
34
34
  }
35
35
 
36
- /** How long the pointer rests before a component tooltip opens. */
37
- const HOST_TITLE_DELAY_MS = 150;
38
-
39
36
  /**
40
37
  * Where a tooltip for `trigger` is portaled: the open native `<dialog>` it
41
38
  * sits in (a popup in the body is drawn under the dialog's top layer), else
@@ -83,7 +80,6 @@ export function HostTitle({
83
80
  content={title}
84
81
  side={side}
85
82
  wide
86
- delayDuration={HOST_TITLE_DELAY_MS}
87
83
  escapePassesThrough
88
84
  shouldOpen={(trigger) => {
89
85
  if (noHover() || (cut && !isTextCut(trigger))) return false;
@@ -15,6 +15,8 @@ interface ToolbarProps {
15
15
  onRedo?: () => void;
16
16
  onClear: () => void;
17
17
  onSave: () => void;
18
+ /** The key named in the ✓'s title: Escape by default; Enter where Escape cancels (`escapeCancels`). */
19
+ saveKey?: 'Escape' | 'Enter';
18
20
  }
19
21
 
20
22
  const PenIcon = () => (
@@ -85,6 +87,15 @@ const TOOLS: { id: Tool; icon: React.FC; label: string }[] = [
85
87
 
86
88
  const STROKE_SIZES = [3, 6, 10, 16, 24];
87
89
 
90
+ /** The colours' names, for a screen reader (the swatch shows only the colour). */
91
+ const COLOR_NAMES: Record<string, string> = {
92
+ '#ef4444': 'Red',
93
+ '#eab308': 'Yellow',
94
+ '#22c55e': 'Green',
95
+ '#3b82f6': 'Blue',
96
+ '#ffffff': 'White',
97
+ };
98
+
88
99
  export const Toolbar: React.FC<ToolbarProps> = ({
89
100
  tool,
90
101
  color,
@@ -98,6 +109,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
98
109
  onRedo,
99
110
  onClear,
100
111
  onSave,
112
+ saveKey = 'Escape',
101
113
  }) => {
102
114
  const currentSizeIndex = STROKE_SIZES.indexOf(strokeSize);
103
115
  const canDecrease = currentSizeIndex > 0;
@@ -121,6 +133,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
121
133
  type="button"
122
134
  onClick={() => onToolChange(id)}
123
135
  title={label}
136
+ aria-label={label}
137
+ aria-pressed={tool === id}
124
138
  className={`p-1.5 rounded transition-colors ${
125
139
  tool === id
126
140
  ? 'bg-primary text-primary-foreground'
@@ -141,6 +155,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
141
155
  onClick={() => canDecrease && onStrokeSizeChange(STROKE_SIZES[currentSizeIndex - 1])}
142
156
  disabled={!canDecrease}
143
157
  title="Smaller stroke"
158
+ aria-label="Smaller stroke"
144
159
  className={`p-1 rounded transition-colors ${
145
160
  canDecrease
146
161
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -152,6 +167,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
152
167
  <div
153
168
  className="w-5 h-5 flex items-center justify-center"
154
169
  title={`Stroke size: ${strokeSize}`}
170
+ role="img"
171
+ aria-label={`Stroke size: ${strokeSize}`}
155
172
  >
156
173
  <div
157
174
  className="rounded-full"
@@ -167,6 +184,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
167
184
  onClick={() => canIncrease && onStrokeSizeChange(STROKE_SIZES[currentSizeIndex + 1])}
168
185
  disabled={!canIncrease}
169
186
  title="Larger stroke"
187
+ aria-label="Larger stroke"
170
188
  className={`p-1 rounded transition-colors ${
171
189
  canIncrease
172
190
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -186,6 +204,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
186
204
  key={c}
187
205
  type="button"
188
206
  onClick={() => onColorChange(c)}
207
+ aria-label={`Colour: ${COLOR_NAMES[c] ?? c}`}
208
+ aria-pressed={color === c}
189
209
  className={`w-5 h-5 rounded-full border-2 transition-transform ${
190
210
  color === c ? 'scale-110 border-foreground' : 'border-transparent hover:scale-105'
191
211
  }`}
@@ -202,6 +222,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
202
222
  onClick={onUndo}
203
223
  disabled={!canUndo}
204
224
  title="Undo (Cmd+Z)"
225
+ aria-label="Undo"
205
226
  className={`p-1.5 rounded transition-colors ${
206
227
  canUndo
207
228
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -217,6 +238,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
217
238
  onClick={onRedo}
218
239
  disabled={!canRedo}
219
240
  title="Redo (Cmd+Shift+Z)"
241
+ aria-label="Redo"
220
242
  className={`p-1.5 rounded transition-colors ${
221
243
  canRedo
222
244
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -232,6 +254,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
232
254
  type="button"
233
255
  onClick={onClear}
234
256
  title="Clear all"
257
+ aria-label="Clear all"
235
258
  className="p-1.5 rounded transition-colors hover:bg-muted text-muted-foreground hover:text-destructive"
236
259
  >
237
260
  <ClearIcon />
@@ -245,7 +268,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
245
268
  <button
246
269
  type="button"
247
270
  onClick={onSave}
248
- title="Save (Esc)"
271
+ title={saveKey === 'Enter' ? 'Save (Enter)' : 'Save (Esc)'}
272
+ aria-label="Save"
249
273
  className="shrink-0 p-1.5 rounded transition-colors bg-success text-success-foreground hover:opacity-90"
250
274
  >
251
275
  <CheckIcon />
@@ -20,6 +20,14 @@ interface ImageAnnotatorProps {
20
20
  onClose: () => void;
21
21
  /** Pre-populated image name (derived from filename) */
22
22
  initialName?: string;
23
+ /**
24
+ * Optional: Escape cancels (closes without accepting) and a click outside
25
+ * closes only an empty drawing; Enter and the ✓ accept. Absent: Escape,
26
+ * Enter and a click outside all accept, exactly as before.
27
+ */
28
+ escapeCancels?: boolean;
29
+ /** Optional: the line under the image in place of "Press Esc or Enter or click outside to accept". */
30
+ hint?: React.ReactNode;
23
31
  }
24
32
 
25
33
  export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
@@ -28,6 +36,8 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
28
36
  onAccept,
29
37
  onClose,
30
38
  initialName = '',
39
+ escapeCancels = false,
40
+ hint,
31
41
  }) => {
32
42
  const [state, setState] = useState<StrokeHistoryState>(DEFAULT_STROKE_HISTORY_STATE);
33
43
  const [saving, setSaving] = useState(false);
@@ -170,14 +180,19 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
170
180
  },
171
181
  save: {
172
182
  when: (event) => isOpen && !saving && !keyboardTargetIsInput(event),
173
- handle: () => { void handleAccept(); },
183
+ handle: (event) => {
184
+ if (escapeCancels && event.key === 'Escape') onClose();
185
+ else void handleAccept();
186
+ },
174
187
  },
175
188
  },
176
189
  });
177
190
 
178
191
  const handleBackdropClick = (e: React.MouseEvent) => {
179
192
  if (e.target === e.currentTarget) {
180
- handleAccept();
193
+ // With escapeCancels, a click outside never takes a drawing away: it closes an empty one only.
194
+ if (!escapeCancels) handleAccept();
195
+ else if (state.strokes.length === 0) onClose();
181
196
  }
182
197
  };
183
198
 
@@ -209,6 +224,7 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
209
224
  onRedo={handleRedo}
210
225
  onClear={handleClear}
211
226
  onSave={handleAccept}
227
+ saveKey={escapeCancels ? 'Enter' : 'Escape'}
212
228
  />
213
229
 
214
230
  <Canvas
@@ -250,8 +266,12 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
250
266
  )}
251
267
 
252
268
  {/* Accept hint */}
253
- <div className="text-xs text-muted-foreground">
254
- Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Esc</kbd> or <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Enter</kbd> or click outside to accept
269
+ <div className="text-xs text-muted-foreground" data-pn-image-annotator-hint>
270
+ {hint ?? (
271
+ <>
272
+ Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Esc</kbd> or <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Enter</kbd> or click outside to accept
273
+ </>
274
+ )}
255
275
  </div>
256
276
  </div>
257
277