@plannotator/ui 0.53.0 → 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.
- package/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -5,6 +5,8 @@ import { AttachmentsButton } from './AttachmentsButton';
|
|
|
5
5
|
import { useDraggable } from '../hooks/useDraggable';
|
|
6
6
|
import { SparklesIcon } from './SparklesIcon';
|
|
7
7
|
import { hasUnsavedCommentContent } from '../utils/commentContent';
|
|
8
|
+
import { isCommentSubmitKey } from '../utils/commentSubmitKey';
|
|
9
|
+
import { useConfigValue } from '../config/useConfig';
|
|
8
10
|
import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
|
|
9
11
|
import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
|
|
10
12
|
import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
|
|
@@ -119,6 +121,20 @@ interface CommentPopoverProps {
|
|
|
119
121
|
/** Opt-in composer yield while shift-selecting: 'near' fades the composer,
|
|
120
122
|
* 'over' makes it near-invisible and click-through. Undefined → no-op. */
|
|
121
123
|
yieldState?: CommentPopoverYieldState;
|
|
124
|
+
/** Opt-in: the header's text in place of the quoted `contextText` (an image
|
|
125
|
+
* region's "Region 3 · 356 × 486 px"). Absent → the quote, as always. */
|
|
126
|
+
headerText?: string;
|
|
127
|
+
/** Opt-in: `side` opens the popover beside the anchor (right, else left),
|
|
128
|
+
* falling back to below/above when neither side has room (an image region
|
|
129
|
+
* in the image viewer). Absent → below/above, as always. */
|
|
130
|
+
placement?: 'below' | 'side';
|
|
131
|
+
/** Opt-in: the stacking level, for a composer over a surface that is
|
|
132
|
+
* itself above the page (the image viewer is z-200). Absent → 100. */
|
|
133
|
+
zIndex?: number;
|
|
134
|
+
/** Opt-out: `false` mounts the composer without taking focus (an HTML
|
|
135
|
+
* pin's composer shown again when its hidden target returns, so focus
|
|
136
|
+
* stays where the person is). Absent → focuses on mount, as always. */
|
|
137
|
+
autoFocus?: boolean;
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
const MAX_POPOVER_WIDTH = 384;
|
|
@@ -151,7 +167,19 @@ interface CommentPopoverPosition {
|
|
|
151
167
|
export function computeCommentPopoverPosition(
|
|
152
168
|
anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
|
|
153
169
|
bounds: VisibleViewportBounds,
|
|
170
|
+
placement: 'below' | 'side' = 'below',
|
|
154
171
|
): CommentPopoverPosition {
|
|
172
|
+
if (placement === 'side') {
|
|
173
|
+
const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
|
|
174
|
+
const right = anchorRect.right + GAP * 1.75;
|
|
175
|
+
const left = anchorRect.left - GAP * 1.75 - width;
|
|
176
|
+
const sideLeft = right + width <= bounds.right ? right : left >= bounds.left ? left : null;
|
|
177
|
+
if (sideLeft !== null) {
|
|
178
|
+
const top = Math.max(bounds.top, Math.min(anchorRect.top, bounds.bottom - 280));
|
|
179
|
+
const maxHeight = Math.max(0, bounds.bottom - top);
|
|
180
|
+
if (maxHeight >= 280) return { top, left: sideLeft, flipAbove: false, width, maxHeight, requiresExpanded: false };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
155
183
|
const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
|
|
156
184
|
const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
|
|
157
185
|
const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
|
|
@@ -199,6 +227,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
199
227
|
refocusToken,
|
|
200
228
|
captureStrayKeys = false,
|
|
201
229
|
yieldState,
|
|
230
|
+
headerText,
|
|
231
|
+
placement = 'below',
|
|
232
|
+
zIndex,
|
|
233
|
+
autoFocus = true,
|
|
202
234
|
}) => {
|
|
203
235
|
const visibleBounds = useVisibleViewportBounds(16);
|
|
204
236
|
const coarsePointer = hasPrimaryCoarsePointer();
|
|
@@ -266,7 +298,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
266
298
|
const update = () => {
|
|
267
299
|
const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
|
|
268
300
|
if (!rect) return;
|
|
269
|
-
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
|
|
301
|
+
const nextPosition = computeCommentPopoverPosition(rect, visibleBounds, placement);
|
|
270
302
|
if (nextPosition.requiresExpanded) {
|
|
271
303
|
setDialogIsForced(true);
|
|
272
304
|
setMode('dialog');
|
|
@@ -280,7 +312,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
280
312
|
return () => {
|
|
281
313
|
window.removeEventListener('scroll', update, true);
|
|
282
314
|
};
|
|
283
|
-
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
|
|
315
|
+
}, [anchorEl, anchorRect, mode, visibleBounds, wasDragged, placement]);
|
|
284
316
|
|
|
285
317
|
// Surface a "jump back" arrow when an open popover scrolls out of view.
|
|
286
318
|
// Re-measures whenever the popover repositions (position updates every scroll
|
|
@@ -307,7 +339,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
307
339
|
// renders after `position` is measured, and WebKit fires 0ms timers ahead of
|
|
308
340
|
// that commit, so an effect keyed on mode alone can run before the textarea
|
|
309
341
|
// exists and never focus it (e.g. in WKWebView hosts like Glimpse).
|
|
310
|
-
const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
|
|
342
|
+
const shouldAutoFocus = autoFocus && (!coarsePointer || initialText.length > 0 || isGlobal);
|
|
311
343
|
const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
312
344
|
textareaRef.current = el;
|
|
313
345
|
if (!el || !shouldAutoFocus) return;
|
|
@@ -322,13 +354,13 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
322
354
|
// summon the software keyboard), but hardware-keyboard users still need a
|
|
323
355
|
// coherent focus target and Escape dismissal.
|
|
324
356
|
useEffect(() => {
|
|
325
|
-
if (mode !== 'dialog' || shouldAutoFocus) return;
|
|
357
|
+
if (mode !== 'dialog' || shouldAutoFocus || !autoFocus) return;
|
|
326
358
|
const timer = setTimeout(() => {
|
|
327
359
|
if (!popoverRef.current?.isConnected) return;
|
|
328
360
|
popoverRef.current.focus({ preventScroll: true });
|
|
329
361
|
}, 0);
|
|
330
362
|
return () => clearTimeout(timer);
|
|
331
|
-
}, [mode, shouldAutoFocus]);
|
|
363
|
+
}, [autoFocus, mode, shouldAutoFocus]);
|
|
332
364
|
|
|
333
365
|
const restoreOpeningFocus = useCallback(() => {
|
|
334
366
|
const target = openingFocusRef.current;
|
|
@@ -561,6 +593,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
561
593
|
const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
|
|
562
594
|
const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
|
|
563
595
|
|
|
596
|
+
const enterSavesComment = useConfigValue('enterSavesComment');
|
|
564
597
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
565
598
|
if (skillAc.onKeyDown(e)) return;
|
|
566
599
|
if (mentionAc.onKeyDown(e)) return;
|
|
@@ -577,17 +610,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
577
610
|
}
|
|
578
611
|
return;
|
|
579
612
|
}
|
|
580
|
-
if (
|
|
613
|
+
if (isCommentSubmitKey(e, enterSavesComment)) {
|
|
581
614
|
e.preventDefault();
|
|
582
615
|
handleSubmit();
|
|
583
616
|
}
|
|
584
617
|
};
|
|
585
618
|
|
|
586
|
-
const headerLabel = isGlobal
|
|
619
|
+
const headerLabel = headerText ?? (isGlobal
|
|
587
620
|
? 'Global Comment'
|
|
588
621
|
: contextText
|
|
589
622
|
? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"`
|
|
590
|
-
: 'Comment';
|
|
623
|
+
: 'Comment');
|
|
591
624
|
|
|
592
625
|
const canSubmit =
|
|
593
626
|
hasUnsavedContent ||
|
|
@@ -630,6 +663,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
630
663
|
<div
|
|
631
664
|
data-comment-popover="true"
|
|
632
665
|
className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
|
|
666
|
+
style={zIndex !== undefined ? { zIndex } : undefined}
|
|
633
667
|
>
|
|
634
668
|
{/* Backdrop */}
|
|
635
669
|
<button
|
|
@@ -726,6 +760,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
726
760
|
activeOptionId={activeComposerOptionId}
|
|
727
761
|
/>
|
|
728
762
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
763
|
+
{mentionSource?.renderNotice?.(mentionChipPeople)}
|
|
729
764
|
<MentionAutocompleteMenu
|
|
730
765
|
id={mentionListboxId}
|
|
731
766
|
menu={mentionAc.menu}
|
|
@@ -796,6 +831,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
796
831
|
className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
|
|
797
832
|
style={dragPosition
|
|
798
833
|
? {
|
|
834
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
799
835
|
top: dragPosition.top,
|
|
800
836
|
left: dragPosition.left,
|
|
801
837
|
width: position.width,
|
|
@@ -803,6 +839,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
803
839
|
overflowY: showsSkillMenu ? 'visible' : 'auto',
|
|
804
840
|
}
|
|
805
841
|
: {
|
|
842
|
+
...(zIndex !== undefined ? { zIndex } : {}),
|
|
806
843
|
top: position.top,
|
|
807
844
|
left: position.left,
|
|
808
845
|
width: position.width,
|
|
@@ -879,6 +916,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
|
|
|
879
916
|
activeOptionId={activeComposerOptionId}
|
|
880
917
|
/>
|
|
881
918
|
<HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
|
|
919
|
+
{mentionSource?.renderNotice?.(mentionChipPeople)}
|
|
882
920
|
<MentionAutocompleteMenu
|
|
883
921
|
id={mentionListboxId}
|
|
884
922
|
menu={mentionAc.menu}
|
|
@@ -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
|
-
//
|
|
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>(
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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 > Saving > 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
|
package/components/HostTitle.tsx
CHANGED
|
@@ -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;
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import React from 'react';
|
|
33
33
|
import { Tooltip } from './Tooltip';
|
|
34
|
+
import { modKey } from '../utils/platform';
|
|
34
35
|
import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
|
|
35
36
|
import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
|
|
36
37
|
|
|
@@ -135,7 +136,7 @@ export interface HtmlSurfaceControlLabels {
|
|
|
135
136
|
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
|
|
136
137
|
Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
|
|
137
138
|
> = {
|
|
138
|
-
annotateTitle:
|
|
139
|
+
annotateTitle: `Annotate mode: click an element or select text to comment; links open (${modKey}-click one to comment on it). Esc to interact`,
|
|
139
140
|
interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
|
|
140
141
|
hideTools: 'Hide tools',
|
|
141
142
|
showTools: 'Show tools',
|
|
@@ -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=
|
|
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 />
|