@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  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 +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
@@ -8,6 +8,7 @@ import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
8
8
  import { useDismissablePopover } from '../hooks/useDismissablePopover';
9
9
  import { useCompactTouchLayout } from '../hooks/useIsMobile';
10
10
  import { cn } from '../lib/utils';
11
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
11
12
  import type {
12
13
  DecisionActionId,
13
14
  DecisionComposer,
@@ -57,6 +58,16 @@ export interface DecisionControlProps {
57
58
  dismissOnIframeFocus?: boolean;
58
59
  /** Confirm surface override; defaults to the ui ConfirmDialog. */
59
60
  confirmDialog?: React.ComponentType<ConfirmDialogProps>;
61
+ /**
62
+ * Rows rendered under the decisions, inside the same menu (#1057: plan
63
+ * review's "After approval, switch to" agent picker and Claude Code's
64
+ * permission-mode line). Not decisions: they never close the menu by
65
+ * themselves. Interactive rows use `role="menuitem"` or
66
+ * `role="menuitemradio"` so the arrow keys reach them. Absent: the menu is
67
+ * the decisions alone, exactly as before. A function receives
68
+ * `closeMenu`, for a row that leaves the menu (e.g. one opening Settings).
69
+ */
70
+ menuFooter?: React.ReactNode | ((api: { closeMenu: () => void }) => React.ReactNode);
60
71
  }
61
72
 
62
73
  const NOTE_MAX_HEIGHT_PX = 144;
@@ -98,6 +109,14 @@ export interface DecisionNoteFieldProps {
98
109
  placeholder?: string;
99
110
  disabled?: boolean;
100
111
  autoFocus?: boolean;
112
+ /**
113
+ * The field is a COMMENT composer (the review sidebar's "+ General
114
+ * comment"), so the "Enter saves a comment" setting applies: pass its value
115
+ * and a plain Enter also submits (Shift+Enter stays a new line). Default
116
+ * false: in the decision control's own composer a note rides a decision,
117
+ * and only Mod+Enter fires it.
118
+ */
119
+ enterSubmits?: boolean;
101
120
  }
102
121
 
103
122
  /**
@@ -114,6 +133,7 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
114
133
  placeholder = 'Add a note...',
115
134
  disabled = false,
116
135
  autoFocus = true,
136
+ enterSubmits = false,
117
137
  }) => {
118
138
  const ref = useAutoGrow(text);
119
139
 
@@ -132,15 +152,15 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
132
152
  onCancel();
133
153
  return;
134
154
  }
135
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
155
+ if (enterSubmits ? isCommentSubmitKey(event, true) : event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
136
156
  event.preventDefault();
137
157
  event.stopPropagation();
138
158
  if (!disabled) onSubmit();
139
159
  }
140
- // Plain Enter falls through: the field is multi-line and Enter's job is
141
- // a newline.
160
+ // Otherwise plain Enter falls through: the field is multi-line and
161
+ // Enter's job is a newline.
142
162
  },
143
- [disabled, onCancel, onSubmit],
163
+ [disabled, enterSubmits, onCancel, onSubmit],
144
164
  );
145
165
 
146
166
  return (
@@ -255,6 +275,7 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
255
275
  labelBreakpoint = 'md',
256
276
  dismissOnIframeFocus = false,
257
277
  confirmDialog,
278
+ menuFooter,
258
279
  }) => {
259
280
  const rootRef = useRef<HTMLDivElement>(null);
260
281
  const popoverRef = useRef<HTMLDivElement>(null);
@@ -402,7 +423,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
402
423
  const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
403
424
  if (!keys.includes(event.key)) return;
404
425
  const items = Array.from(
405
- popoverRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
426
+ popoverRef.current?.querySelectorAll<HTMLButtonElement>(
427
+ '[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)',
428
+ ) ?? [],
406
429
  );
407
430
  if (items.length === 0) return;
408
431
  event.preventDefault();
@@ -548,6 +571,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
548
571
  />
549
572
  </React.Fragment>
550
573
  ))}
574
+ {typeof menuFooter === 'function'
575
+ ? menuFooter({ closeMenu: () => closePopover() })
576
+ : menuFooter}
551
577
  </div>
552
578
  ) : activeItem?.composer ? (
553
579
  <div className="p-2">
@@ -16,6 +16,7 @@ import { svgContentSize } from './diagram/svgContentSize';
16
16
  import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
17
17
  import type { DiagramRenderState } from './diagram/useDiagramRender';
18
18
  import { useTheme } from './ThemeProvider';
19
+ import { LazyBlockBoundary } from './LazyBlockBoundary';
19
20
 
20
21
  /**
21
22
  * A diagram fence in the document: the fence's language picks the engine
@@ -345,7 +346,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
345
346
  appears in a node label) can never wrap a <mark> inside the svg. */}
346
347
  <div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
347
348
  {svgReady && !showSource && (
348
- <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
349
+ <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
349
350
  <button
350
351
  type="button"
351
352
  onClick={() => setShowSource(true)}
@@ -404,17 +405,21 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
404
405
  )}
405
406
  </div>
406
407
  {isExpanded && svgReady && typeof document !== 'undefined' && (
407
- <Suspense fallback={null}>
408
- <DiagramPopout
409
- {...viewerProps}
410
- open
411
- onClose={() => setIsExpanded(false)}
412
- title={`${label} diagram`}
413
- renderId={`${kind}-${block.id}-popout`}
414
- onAskAI={handlePopoutAskAI}
415
- dataAttributes={{ 'data-block-id': block.id }}
416
- />
417
- </Suspense>
408
+ // A popout chunk that cannot load leaves the inline diagram as it is
409
+ // and says so under it.
410
+ <LazyBlockBoundary fallback={(notice) => <div className="mb-5">{notice}</div>}>
411
+ <Suspense fallback={null}>
412
+ <DiagramPopout
413
+ {...viewerProps}
414
+ open
415
+ onClose={() => setIsExpanded(false)}
416
+ title={`${label} diagram`}
417
+ renderId={`${kind}-${block.id}-popout`}
418
+ onAskAI={handlePopoutAskAI}
419
+ dataAttributes={{ 'data-block-id': block.id }}
420
+ />
421
+ </Suspense>
422
+ </LazyBlockBoundary>
418
423
  )}
419
424
  </>
420
425
  );
@@ -42,7 +42,7 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
42
42
  {onDelete && (
43
43
  <button
44
44
  onClick={(e) => { e.stopPropagation(); onDelete(); }}
45
- className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
45
+ className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
46
46
  title="Delete annotation"
47
47
  >
48
48
  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -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