@plannotator/ui 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +68 -35
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +136 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +215 -39
- package/components/html-viewer/bridge-script.ts +384 -66
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +179 -58
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
package/components/Settings.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
+
import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
|
|
3
4
|
import type { Origin } from '@plannotator/core/agents';
|
|
4
5
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
5
6
|
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
|
|
@@ -73,8 +74,9 @@ import {
|
|
|
73
74
|
type FileBrowserSettings,
|
|
74
75
|
} from '../utils/fileBrowser';
|
|
75
76
|
import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
|
|
77
|
+
import { AnalysisLayerToggle } from './AnalysisLayerToggle';
|
|
76
78
|
|
|
77
|
-
type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
|
|
79
|
+
type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
|
|
78
80
|
|
|
79
81
|
interface SettingsProps {
|
|
80
82
|
taterMode: boolean;
|
|
@@ -94,8 +96,17 @@ interface SettingsProps {
|
|
|
94
96
|
* (base ref unresolvable) — the Git tab shows a note that the Git-status
|
|
95
97
|
* preference can't take effect in THIS repo. */
|
|
96
98
|
sinceBaseUnavailable?: boolean;
|
|
99
|
+
/** The host is rendering its compact touch shell (review only). Display
|
|
100
|
+
* settings that the compact shell overrides for the session are hidden
|
|
101
|
+
* there instead of silently editing the desktop preference. */
|
|
102
|
+
isCompactTouchLayout?: boolean;
|
|
97
103
|
/** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */
|
|
98
104
|
onDetectObsidianVaults?: () => Promise<string[]>;
|
|
105
|
+
/** This annotate session actually offers the Agent TUI, so its Position
|
|
106
|
+
* setting is worth showing. Default false: the setting is the only way back
|
|
107
|
+
* from a Hidden position, but offering it where no terminal can ever run
|
|
108
|
+
* would just be a dead control. */
|
|
109
|
+
agentTerminalAvailable?: boolean;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
// --- Review-mode Display tab (diff display options) ---
|
|
@@ -148,6 +159,12 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
|
|
|
148
159
|
{ value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
|
|
149
160
|
];
|
|
150
161
|
|
|
162
|
+
const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [
|
|
163
|
+
{ value: 'left', label: 'Left' },
|
|
164
|
+
{ value: 'right', label: 'Right' },
|
|
165
|
+
{ value: 'hidden', label: 'Hidden' },
|
|
166
|
+
];
|
|
167
|
+
|
|
151
168
|
function SegmentedControl<T extends string>({ options, value, onChange }: {
|
|
152
169
|
options: { value: T; label: string }[];
|
|
153
170
|
value: T;
|
|
@@ -192,12 +209,17 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
|
|
|
192
209
|
aria-checked={checked}
|
|
193
210
|
disabled={disabled}
|
|
194
211
|
onClick={() => onChange(!checked)}
|
|
195
|
-
className=
|
|
196
|
-
checked ? 'bg-primary' : 'bg-muted'
|
|
197
|
-
}`}
|
|
212
|
+
className="relative inline-flex h-11 w-11 shrink-0 items-center disabled:cursor-not-allowed"
|
|
198
213
|
>
|
|
199
214
|
<span
|
|
200
|
-
|
|
215
|
+
aria-hidden="true"
|
|
216
|
+
className={`absolute inset-x-0 top-2.5 h-6 rounded-full transition-colors ${
|
|
217
|
+
checked ? 'bg-primary' : 'bg-muted'
|
|
218
|
+
}`}
|
|
219
|
+
/>
|
|
220
|
+
<span
|
|
221
|
+
aria-hidden="true"
|
|
222
|
+
className={`absolute top-3.5 inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
201
223
|
checked ? 'translate-x-6' : 'translate-x-1'
|
|
202
224
|
}`}
|
|
203
225
|
/>
|
|
@@ -288,6 +310,63 @@ function VimSettingsTab({
|
|
|
288
310
|
);
|
|
289
311
|
}
|
|
290
312
|
|
|
313
|
+
function ReviewAnalysisTab() {
|
|
314
|
+
const semanticDiffEnabled = useConfigValue('semanticDiffEnabled');
|
|
315
|
+
const callFlowEnabled = useConfigValue('callFlowEnabled');
|
|
316
|
+
return (
|
|
317
|
+
<div className="space-y-5">
|
|
318
|
+
<div>
|
|
319
|
+
<div className="text-sm font-semibold">Analysis layers</div>
|
|
320
|
+
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
321
|
+
Add structural context to the ordinary code diff. Each layer is independent and does no work while disabled.
|
|
322
|
+
</p>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
|
|
326
|
+
<AnalysisLayerToggle
|
|
327
|
+
checked={semanticDiffEnabled}
|
|
328
|
+
onChange={(enabled) => configStore.set('semanticDiffEnabled', enabled)}
|
|
329
|
+
label="Semantic changes"
|
|
330
|
+
description="Organize added, changed, moved, and removed functions, classes, and other named code. Enabled by default."
|
|
331
|
+
/>
|
|
332
|
+
<div className="border-t border-border" />
|
|
333
|
+
<AnalysisLayerToggle
|
|
334
|
+
checked={callFlowEnabled}
|
|
335
|
+
onChange={(enabled) => configStore.set('callFlowEnabled', enabled)}
|
|
336
|
+
label="Call flow"
|
|
337
|
+
description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)."
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
342
|
+
<div className="rounded-xl border border-border p-3.5">
|
|
343
|
+
<div className="flex items-center gap-2 text-xs font-semibold">
|
|
344
|
+
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">▣</span>
|
|
345
|
+
Dock
|
|
346
|
+
</div>
|
|
347
|
+
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
|
348
|
+
Open the complete analysis from the file panel. Rows jump to the relevant code location.
|
|
349
|
+
</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="rounded-xl border border-border p-3.5">
|
|
352
|
+
<div className="flex items-center gap-2 text-xs font-semibold">
|
|
353
|
+
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">⌁</span>
|
|
354
|
+
File lens
|
|
355
|
+
</div>
|
|
356
|
+
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
|
357
|
+
File headers show compact counts and a focused popover for that file.
|
|
358
|
+
</p>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<div className="rounded-xl border border-warning/25 bg-warning/[0.05] p-4 text-xs leading-relaxed text-muted-foreground">
|
|
363
|
+
<span className="font-semibold text-foreground">Call flow is syntactic.</span>{' '}
|
|
364
|
+
It does not use type resolution or runtime traces, so treat it as navigation context—not proof that a path executes.
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
291
370
|
const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
|
|
292
371
|
const defaultDiffType = useConfigValue('defaultDiffType');
|
|
293
372
|
const reviewPanelView = useConfigValue('reviewPanelView');
|
|
@@ -358,7 +437,7 @@ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavail
|
|
|
358
437
|
);
|
|
359
438
|
};
|
|
360
439
|
|
|
361
|
-
const ReviewDisplayTab: React.FC = () => {
|
|
440
|
+
const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => {
|
|
362
441
|
const diffStyle = useConfigValue('diffStyle');
|
|
363
442
|
const diffOverflow = useConfigValue('diffOverflow');
|
|
364
443
|
const diffIndicators = useConfigValue('diffIndicators');
|
|
@@ -456,13 +535,23 @@ const ReviewDisplayTab: React.FC = () => {
|
|
|
456
535
|
|
|
457
536
|
<div className="border-t border-border" />
|
|
458
537
|
|
|
459
|
-
{/* Diff Style
|
|
538
|
+
{/* Diff Style. The compact touch shell renders a session-only unified
|
|
539
|
+
diff, so the control there would look dead while quietly rewriting
|
|
540
|
+
the DESKTOP preference. Say what the phone is doing instead. */}
|
|
460
541
|
<div className="space-y-2">
|
|
461
542
|
<div>
|
|
462
543
|
<div className="text-sm font-medium">Diff Style</div>
|
|
463
544
|
<div className="text-xs text-muted-foreground">Side-by-side or inline diff view</div>
|
|
545
|
+
{isCompactTouchLayout && (
|
|
546
|
+
<div className="text-xs text-muted-foreground mt-1">
|
|
547
|
+
This layout shows unified diffs for the session; your desktop
|
|
548
|
+
preference is unchanged.
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
464
551
|
</div>
|
|
465
|
-
|
|
552
|
+
{!isCompactTouchLayout && (
|
|
553
|
+
<SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
|
|
554
|
+
)}
|
|
466
555
|
</div>
|
|
467
556
|
|
|
468
557
|
<div className="border-t border-border" />
|
|
@@ -759,7 +848,7 @@ const CommentsTab: React.FC = () => {
|
|
|
759
848
|
);
|
|
760
849
|
};
|
|
761
850
|
|
|
762
|
-
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => {
|
|
851
|
+
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false }) => {
|
|
763
852
|
const [showDialog, setShowDialog] = useState(false);
|
|
764
853
|
const settingsWasOpenRef = useRef(false);
|
|
765
854
|
const [themePreview, setThemePreview] = useState(false);
|
|
@@ -791,6 +880,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
791
880
|
const vimModeEnabled = useConfigValue('vimModeEnabled');
|
|
792
881
|
const vimHudEnabled = useConfigValue('vimHudEnabled');
|
|
793
882
|
const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
|
|
883
|
+
const agentTerminalSide = useConfigValue('agentTerminalSide');
|
|
794
884
|
const [identity, setIdentity] = useState('');
|
|
795
885
|
const [obsidian, setObsidian] = useState<ObsidianSettings>({
|
|
796
886
|
enabled: false,
|
|
@@ -835,6 +925,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
835
925
|
if (mode === 'review') {
|
|
836
926
|
t.push({ id: 'git', label: 'Git' });
|
|
837
927
|
t.push({ id: 'display', label: 'Editor' });
|
|
928
|
+
t.push({ id: 'analysis', label: 'Analysis' });
|
|
838
929
|
t.push({ id: 'comments', label: 'Comments' });
|
|
839
930
|
if (aiProviders.length > 0) {
|
|
840
931
|
t.push({ id: 'ai', label: 'AI' });
|
|
@@ -1306,6 +1397,37 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1306
1397
|
</>
|
|
1307
1398
|
)}
|
|
1308
1399
|
|
|
1400
|
+
{/* Agent TUI position. The same control lives in the
|
|
1401
|
+
terminal's own Display popover, but that one is
|
|
1402
|
+
unreachable once the position is Hidden — this is the
|
|
1403
|
+
way back. Both write the same config value. Gated on the
|
|
1404
|
+
terminal actually being available in this session, so a
|
|
1405
|
+
remote or runtime-less annotate never offers a dead
|
|
1406
|
+
control. */}
|
|
1407
|
+
{mode === 'annotate' && agentTerminalAvailable && (
|
|
1408
|
+
<>
|
|
1409
|
+
<div className="border-t border-border" />
|
|
1410
|
+
<div className="space-y-2">
|
|
1411
|
+
<div>
|
|
1412
|
+
<div className="text-sm font-medium">Agent TUI Position</div>
|
|
1413
|
+
<div className="text-xs text-muted-foreground">
|
|
1414
|
+
Which side the agent terminal docks on, or Hidden to keep it out of the layout
|
|
1415
|
+
</div>
|
|
1416
|
+
</div>
|
|
1417
|
+
<SegmentedControl
|
|
1418
|
+
options={AGENT_TERMINAL_SIDE_OPTIONS}
|
|
1419
|
+
value={agentTerminalSide}
|
|
1420
|
+
onChange={(v) => configStore.set('agentTerminalSide', v)}
|
|
1421
|
+
/>
|
|
1422
|
+
{agentTerminalSide === 'hidden' && (
|
|
1423
|
+
<div className="text-[10px] text-muted-foreground/70">
|
|
1424
|
+
You can still open it for this session from the sidebar rail.
|
|
1425
|
+
</div>
|
|
1426
|
+
)}
|
|
1427
|
+
</div>
|
|
1428
|
+
</>
|
|
1429
|
+
)}
|
|
1430
|
+
|
|
1309
1431
|
<div className="border-t border-border" />
|
|
1310
1432
|
|
|
1311
1433
|
{/* Auto-close Tab */}
|
|
@@ -1343,7 +1465,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
|
|
|
1343
1465
|
|
|
1344
1466
|
{/* === DISPLAY TAB === */}
|
|
1345
1467
|
{activeTab === 'display' && mode === 'review' && (
|
|
1346
|
-
<ReviewDisplayTab />
|
|
1468
|
+
<ReviewDisplayTab isCompactTouchLayout={isCompactTouchLayout} />
|
|
1469
|
+
)}
|
|
1470
|
+
|
|
1471
|
+
{activeTab === 'analysis' && mode === 'review' && (
|
|
1472
|
+
<ReviewAnalysisTab />
|
|
1347
1473
|
)}
|
|
1348
1474
|
|
|
1349
1475
|
{activeTab === 'display' && mode !== 'review' && (
|
|
@@ -9,6 +9,8 @@ const ROOT_LABELS: Record<SkillCatalogEntry['root'], string> = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
interface SkillReferenceMenuProps {
|
|
12
|
+
/** Id referenced by the focused textarea's aria-controls attribute. */
|
|
13
|
+
id: string;
|
|
12
14
|
items: SkillCatalogEntry[];
|
|
13
15
|
/** Explicitly activated row, or null — the menu opens with nothing active. */
|
|
14
16
|
activeIndex: number | null;
|
|
@@ -79,6 +81,7 @@ function computeMenuPlacement(
|
|
|
79
81
|
* a purely visual affordance; a pointer click inserts directly.
|
|
80
82
|
*/
|
|
81
83
|
export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
|
|
84
|
+
id,
|
|
82
85
|
items,
|
|
83
86
|
activeIndex,
|
|
84
87
|
onSelect,
|
|
@@ -140,6 +143,8 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
|
|
|
140
143
|
return (
|
|
141
144
|
<div
|
|
142
145
|
ref={menuRef}
|
|
146
|
+
id={id}
|
|
147
|
+
role="listbox"
|
|
143
148
|
data-skill-menu="true"
|
|
144
149
|
data-skill-menu-placement={placement.direction}
|
|
145
150
|
data-popover-layer="true"
|
|
@@ -157,6 +162,10 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
|
|
|
157
162
|
<button
|
|
158
163
|
key={item.name}
|
|
159
164
|
type="button"
|
|
165
|
+
id={`${id}-option-${index}`}
|
|
166
|
+
role="option"
|
|
167
|
+
aria-selected={index === activeIndex}
|
|
168
|
+
tabIndex={-1}
|
|
160
169
|
data-skill-item={item.name}
|
|
161
170
|
data-skill-item-active={index === activeIndex ? 'true' : undefined}
|
|
162
171
|
// Insert on pointerdown so the textarea never loses focus. A
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
import React, { useEffect, useRef, useState } from 'react';
|
|
28
28
|
import { AnnotationToolstrip } from './AnnotationToolstrip';
|
|
29
29
|
import { DocBadges } from './DocBadges';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
getScrollViewportIntersectionRoot,
|
|
32
|
+
useScrollViewport,
|
|
33
|
+
} from '../hooks/useScrollViewport';
|
|
31
34
|
import type { EditorMode, InputMethod } from '../types';
|
|
32
35
|
import type { PlanDiffStats } from '../utils/planDiffEngine';
|
|
33
36
|
|
|
@@ -172,7 +175,11 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
|
|
|
172
175
|
if (!sentinelRef.current || !scrollViewport) return;
|
|
173
176
|
const observer = new IntersectionObserver(
|
|
174
177
|
([entry]) => setIsStuck(!entry.isIntersecting),
|
|
175
|
-
{
|
|
178
|
+
{
|
|
179
|
+
root: getScrollViewportIntersectionRoot(scrollViewport),
|
|
180
|
+
rootMargin: '80px 0px 0px 0px',
|
|
181
|
+
threshold: 0,
|
|
182
|
+
}
|
|
176
183
|
);
|
|
177
184
|
observer.observe(sentinelRef.current);
|
|
178
185
|
return () => observer.disconnect();
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
getAnnotationCountBySection,
|
|
6
6
|
type TocItem,
|
|
7
7
|
} from '../utils/annotationHelpers';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getScrollViewportRect,
|
|
10
|
+
getScrollViewportTop,
|
|
11
|
+
scrollViewportTo,
|
|
12
|
+
useScrollViewport,
|
|
13
|
+
} from '../hooks/useScrollViewport';
|
|
9
14
|
|
|
10
15
|
interface TableOfContentsProps {
|
|
11
16
|
blocks: Block[];
|
|
@@ -82,11 +87,11 @@ export function TableOfContents({
|
|
|
82
87
|
if (target && scrollViewport) {
|
|
83
88
|
const scrollContainer = scrollViewport;
|
|
84
89
|
const headerOffset = 80; // sticky header (h-12) + breathing room
|
|
85
|
-
const containerRect = scrollContainer
|
|
90
|
+
const containerRect = getScrollViewportRect(scrollContainer);
|
|
86
91
|
const targetRect = target.getBoundingClientRect();
|
|
87
92
|
const offsetPosition =
|
|
88
|
-
scrollContainer
|
|
89
|
-
scrollContainer
|
|
93
|
+
getScrollViewportTop(scrollContainer) + (targetRect.top - containerRect.top) - headerOffset;
|
|
94
|
+
scrollViewportTo(scrollContainer, { top: offsetPosition, behavior: 'smooth' });
|
|
90
95
|
}
|
|
91
96
|
},
|
|
92
97
|
[onNavigate, scrollViewport]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useMemo, type JSX } from 'react';
|
|
2
|
-
import { motion } from 'motion/react';
|
|
2
|
+
import { motion, useReducedMotion } from 'motion/react';
|
|
3
3
|
|
|
4
4
|
export interface TextShimmerProps {
|
|
5
5
|
children: string;
|
|
@@ -16,6 +16,7 @@ function TextShimmerComponent({
|
|
|
16
16
|
duration = 2,
|
|
17
17
|
spread = 2,
|
|
18
18
|
}: TextShimmerProps) {
|
|
19
|
+
const shouldReduceMotion = useReducedMotion();
|
|
19
20
|
const MotionComponent = motion.create(
|
|
20
21
|
Component as keyof JSX.IntrinsicElements
|
|
21
22
|
);
|
|
@@ -35,9 +36,9 @@ function TextShimmerComponent({
|
|
|
35
36
|
]
|
|
36
37
|
.filter(Boolean)
|
|
37
38
|
.join(' ')}
|
|
38
|
-
initial={{ backgroundPosition: '100% center' }}
|
|
39
|
-
animate={{ backgroundPosition: '0% center' }}
|
|
40
|
-
transition={{
|
|
39
|
+
initial={shouldReduceMotion ? false : { backgroundPosition: '100% center' }}
|
|
40
|
+
animate={shouldReduceMotion ? undefined : { backgroundPosition: '0% center' }}
|
|
41
|
+
transition={shouldReduceMotion ? undefined : {
|
|
41
42
|
repeat: Infinity,
|
|
42
43
|
duration,
|
|
43
44
|
ease: 'linear',
|
|
@@ -45,7 +46,9 @@ function TextShimmerComponent({
|
|
|
45
46
|
style={
|
|
46
47
|
{
|
|
47
48
|
'--spread': `${dynamicSpread}px`,
|
|
48
|
-
backgroundImage:
|
|
49
|
+
backgroundImage: shouldReduceMotion
|
|
50
|
+
? 'linear-gradient(var(--base-color), var(--base-color))'
|
|
51
|
+
: 'var(--bg), linear-gradient(var(--base-color), var(--base-color))',
|
|
49
52
|
} as React.CSSProperties
|
|
50
53
|
}
|
|
51
54
|
>
|
|
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|
|
2
2
|
import { configStore } from '../config/configStore';
|
|
3
3
|
import { readThemePairCookies, writeThemePairCookies } from '../config/settings';
|
|
4
4
|
import { useConfigValue } from '../config/useConfig';
|
|
5
|
+
import { faviconDataUrl } from '@plannotator/core/favicon';
|
|
5
6
|
import { storage } from '../utils/storage';
|
|
6
7
|
import {
|
|
7
8
|
BUILT_IN_THEMES,
|
|
@@ -36,6 +37,12 @@ type ThemeProviderState = {
|
|
|
36
37
|
darkTheme: string;
|
|
37
38
|
setHalfTheme: (half: ThemeHalf, theme: string) => void;
|
|
38
39
|
availableThemes: ThemeInfo[];
|
|
40
|
+
/**
|
|
41
|
+
* Whether this provider owns the document's favicon. Published so the Settings
|
|
42
|
+
* UI can hide the favicon control on hosts that did not opt in: a knob that
|
|
43
|
+
* changes nothing is worse than no knob.
|
|
44
|
+
*/
|
|
45
|
+
manageFavicon: boolean;
|
|
39
46
|
};
|
|
40
47
|
|
|
41
48
|
const ThemeProviderContext = createContext<ThemeProviderState>({
|
|
@@ -51,6 +58,7 @@ const ThemeProviderContext = createContext<ThemeProviderState>({
|
|
|
51
58
|
darkTheme: 'plannotator',
|
|
52
59
|
setHalfTheme: () => null,
|
|
53
60
|
availableThemes: BUILT_IN_THEMES,
|
|
61
|
+
manageFavicon: false,
|
|
54
62
|
});
|
|
55
63
|
|
|
56
64
|
/** Sync theme classes on <html> without stripping non-theme classes (e.g. transitions-ready). */
|
|
@@ -94,6 +102,18 @@ interface ThemeProviderProps {
|
|
|
94
102
|
* `plannotator-dark-theme`; these props rename only the two legacy values.
|
|
95
103
|
*/
|
|
96
104
|
colorThemeStorageKey?: string;
|
|
105
|
+
/**
|
|
106
|
+
* Opt in to letting this provider own `<link rel="icon">` on the document.
|
|
107
|
+
*
|
|
108
|
+
* OFF by default, and deliberately so: `@plannotator/ui` is installed into
|
|
109
|
+
* host applications with their own branding, and a mounted provider must not
|
|
110
|
+
* silently replace a host page's favicon with Plannotator's. Plannotator's own
|
|
111
|
+
* apps pass `manageFavicon`; hosts opt in only if they want the same feature.
|
|
112
|
+
*
|
|
113
|
+
* The value also gates the Settings favicon control (see ThemeTab), so a host
|
|
114
|
+
* that leaves it off never renders a switch that would do nothing.
|
|
115
|
+
*/
|
|
116
|
+
manageFavicon?: boolean;
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
export function ThemeProvider({
|
|
@@ -102,6 +122,7 @@ export function ThemeProvider({
|
|
|
102
122
|
defaultColorTheme = 'plannotator',
|
|
103
123
|
storageKey = 'plannotator-theme',
|
|
104
124
|
colorThemeStorageKey = 'plannotator-color-theme',
|
|
125
|
+
manageFavicon = false,
|
|
105
126
|
}: ThemeProviderProps) {
|
|
106
127
|
const legacyKeys = useMemo(
|
|
107
128
|
() => ({ mode: storageKey, colorTheme: colorThemeStorageKey }),
|
|
@@ -124,6 +145,26 @@ export function ThemeProvider({
|
|
|
124
145
|
const storePair = useConfigValue('themePair');
|
|
125
146
|
const pair = pendingSeed.current ?? storePair;
|
|
126
147
|
const mode = pair.mode;
|
|
148
|
+
const faviconStyle = useConfigValue('faviconStyle');
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!manageFavicon) return;
|
|
152
|
+
if (typeof document === 'undefined') return;
|
|
153
|
+
let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
154
|
+
if (!link) {
|
|
155
|
+
link = document.createElement('link');
|
|
156
|
+
link.rel = 'icon';
|
|
157
|
+
document.head.appendChild(link);
|
|
158
|
+
}
|
|
159
|
+
if (faviconStyle === 'classic') {
|
|
160
|
+
link.type = 'image/svg+xml';
|
|
161
|
+
link.removeAttribute('sizes');
|
|
162
|
+
} else {
|
|
163
|
+
link.type = 'image/png';
|
|
164
|
+
link.setAttribute('sizes', '64x64');
|
|
165
|
+
}
|
|
166
|
+
link.href = faviconDataUrl(faviconStyle);
|
|
167
|
+
}, [faviconStyle, manageFavicon]);
|
|
127
168
|
|
|
128
169
|
// Hand the resolved pair to the store as a SEED, not a user choice: seeding
|
|
129
170
|
// writes memory + cookies only. Routing it through set() would queue a
|
|
@@ -243,7 +284,8 @@ export function ThemeProvider({
|
|
|
243
284
|
darkTheme: pair.dark,
|
|
244
285
|
setHalfTheme,
|
|
245
286
|
availableThemes: BUILT_IN_THEMES,
|
|
246
|
-
|
|
287
|
+
manageFavicon,
|
|
288
|
+
}), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme, manageFavicon]);
|
|
247
289
|
|
|
248
290
|
return (
|
|
249
291
|
<ThemeProviderContext.Provider value={value}>
|
package/components/ThemeTab.tsx
CHANGED
|
@@ -2,6 +2,9 @@ import React, { useEffect, useState } from 'react';
|
|
|
2
2
|
import { useTheme } from './ThemeProvider';
|
|
3
3
|
import { THEME_MODES } from './themeModes';
|
|
4
4
|
import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry';
|
|
5
|
+
import { configStore } from '../config/configStore';
|
|
6
|
+
import { useConfigValue } from '../config/useConfig';
|
|
7
|
+
import { faviconDataUrl, type FaviconStyle } from '@plannotator/core/favicon';
|
|
5
8
|
|
|
6
9
|
interface ThemeTabProps {
|
|
7
10
|
onPreview?: () => void;
|
|
@@ -13,12 +16,46 @@ const HALVES: { id: ThemeHalf; label: string }[] = [
|
|
|
13
16
|
{ id: 'dark', label: 'Dark' },
|
|
14
17
|
];
|
|
15
18
|
|
|
19
|
+
const FAVICON_STYLES: { id: FaviconStyle; label: string }[] = [
|
|
20
|
+
{ id: 'totman', label: 'Totman' },
|
|
21
|
+
{ id: 'classic', label: 'Classic P' },
|
|
22
|
+
];
|
|
23
|
+
|
|
16
24
|
const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|
17
25
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
18
26
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
|
|
19
27
|
</svg>
|
|
20
28
|
);
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The favicon style choices. Rendered in both layouts, so it lives in one place.
|
|
32
|
+
* The compact layout drops the visible "Favicon" heading the full layout has, so
|
|
33
|
+
* the group carries its own accessible name and two unlabelled image buttons are
|
|
34
|
+
* never all a screen reader gets.
|
|
35
|
+
*/
|
|
36
|
+
const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected }) => (
|
|
37
|
+
<div className="flex gap-1" role="group" aria-label="Favicon style">
|
|
38
|
+
{FAVICON_STYLES.map(({ id, label }) => (
|
|
39
|
+
<button
|
|
40
|
+
key={id}
|
|
41
|
+
type="button"
|
|
42
|
+
aria-pressed={selected === id}
|
|
43
|
+
onClick={() => configStore.set('faviconStyle', id)}
|
|
44
|
+
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
45
|
+
selected === id
|
|
46
|
+
? 'bg-primary text-primary-foreground'
|
|
47
|
+
: 'bg-muted text-muted-foreground hover:text-foreground'
|
|
48
|
+
}`}
|
|
49
|
+
>
|
|
50
|
+
<span className="flex items-center gap-1.5">
|
|
51
|
+
<img src={faviconDataUrl(id)} alt="" className="w-5 h-5 rounded-sm shrink-0" />
|
|
52
|
+
{label}
|
|
53
|
+
</span>
|
|
54
|
+
</button>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
|
|
22
59
|
export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
|
|
23
60
|
const {
|
|
24
61
|
mode,
|
|
@@ -28,7 +65,9 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
|
|
|
28
65
|
setHalfTheme,
|
|
29
66
|
availableThemes,
|
|
30
67
|
preferredMode,
|
|
68
|
+
manageFavicon,
|
|
31
69
|
} = useTheme();
|
|
70
|
+
const faviconStyle = useConfigValue('faviconStyle');
|
|
32
71
|
|
|
33
72
|
// Which half the grid assigns to. Follows the mode you are actually seeing,
|
|
34
73
|
// so opening Settings in dark mode edits the dark half first.
|
|
@@ -68,6 +107,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
|
|
|
68
107
|
{THEME_MODES.map(({ id, label, Icon }) => (
|
|
69
108
|
<button
|
|
70
109
|
key={id}
|
|
110
|
+
type="button"
|
|
71
111
|
onClick={() => setMode(id)}
|
|
72
112
|
className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
|
73
113
|
mode === id
|
|
@@ -82,12 +122,23 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
|
|
|
82
122
|
</button>
|
|
83
123
|
))}
|
|
84
124
|
</div>
|
|
125
|
+
{!compact && manageFavicon && (
|
|
126
|
+
<div className="space-y-1.5 pt-1">
|
|
127
|
+
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Favicon</label>
|
|
128
|
+
<FaviconStyleControl selected={faviconStyle} />
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
85
131
|
{!compact && (
|
|
86
132
|
<p className="text-[11px] text-muted-foreground/70">
|
|
87
133
|
System follows your OS and switches between the two themes below.
|
|
88
134
|
</p>
|
|
89
135
|
)}
|
|
90
|
-
{compact &&
|
|
136
|
+
{compact && (
|
|
137
|
+
<>
|
|
138
|
+
{manageFavicon && <FaviconStyleControl selected={faviconStyle} />}
|
|
139
|
+
<div className="ml-auto">{summary}</div>
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
91
142
|
</div>
|
|
92
143
|
|
|
93
144
|
{/* Theme pair */}
|
package/components/Tooltip.tsx
CHANGED
|
@@ -63,7 +63,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|
|
63
63
|
<BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-50">
|
|
64
64
|
<BaseTooltip.Popup
|
|
65
65
|
className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
|
|
66
|
-
wide
|
|
66
|
+
wide
|
|
67
|
+
? 'max-w-[min(260px,calc(100vw-1rem))] leading-snug whitespace-normal [overflow-wrap:anywhere]'
|
|
68
|
+
: 'whitespace-nowrap'
|
|
67
69
|
}`}
|
|
68
70
|
>
|
|
69
71
|
{content}
|
package/components/Viewer.tsx
CHANGED
|
@@ -49,7 +49,13 @@ import { PinpointOverlay } from './PinpointOverlay';
|
|
|
49
49
|
import { usePinpoint } from '../hooks/usePinpoint';
|
|
50
50
|
import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
|
|
51
51
|
import { useVimSelection } from '../hooks/useVimSelection';
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
getScrollViewportIntersectionRoot,
|
|
54
|
+
getScrollViewportRect,
|
|
55
|
+
getScrollViewportTop,
|
|
56
|
+
scrollViewportTo,
|
|
57
|
+
useScrollViewport,
|
|
58
|
+
} from '../hooks/useScrollViewport';
|
|
53
59
|
import { decodeAnchorHash } from '../utils/anchors';
|
|
54
60
|
import { VimModeOverlay } from './VimModeOverlay';
|
|
55
61
|
|
|
@@ -602,7 +608,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
602
608
|
if (!stickyActions || !stickySentinelRef.current || !scrollViewport) return;
|
|
603
609
|
const observer = new IntersectionObserver(
|
|
604
610
|
([entry]) => setIsStuck(!entry.isIntersecting),
|
|
605
|
-
{ root: scrollViewport, threshold: 0 }
|
|
611
|
+
{ root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
|
|
606
612
|
);
|
|
607
613
|
observer.observe(stickySentinelRef.current);
|
|
608
614
|
return () => observer.disconnect();
|
|
@@ -635,12 +641,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
635
641
|
const headerOffset = stickyActionsEl
|
|
636
642
|
? stickyActionsEl.getBoundingClientRect().height + stickyTop
|
|
637
643
|
: 0;
|
|
638
|
-
const containerRect = scrollViewport
|
|
644
|
+
const containerRect = getScrollViewportRect(scrollViewport);
|
|
639
645
|
const targetRect = target.getBoundingClientRect();
|
|
640
646
|
const relativeTop = targetRect.top - containerRect.top;
|
|
641
|
-
const offsetPosition = scrollViewport
|
|
647
|
+
const offsetPosition = getScrollViewportTop(scrollViewport) + relativeTop - headerOffset;
|
|
642
648
|
|
|
643
|
-
scrollViewport
|
|
649
|
+
scrollViewportTo(scrollViewport, {
|
|
644
650
|
top: Math.max(0, offsetPosition),
|
|
645
651
|
behavior: 'smooth',
|
|
646
652
|
});
|
|
@@ -778,6 +784,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
778
784
|
setViewerCommentPopover(null);
|
|
779
785
|
}, []);
|
|
780
786
|
|
|
787
|
+
const commentDraftScope = linkedDocInfo?.filepath ?? sourceInfo ?? markdown.slice(0, 120);
|
|
788
|
+
|
|
781
789
|
const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
|
|
782
790
|
|
|
783
791
|
return (
|
|
@@ -1154,6 +1162,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1154
1162
|
contextText={hookCommentPopover.contextText}
|
|
1155
1163
|
isGlobal={false}
|
|
1156
1164
|
initialText={hookCommentPopover.initialText}
|
|
1165
|
+
draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
|
|
1157
1166
|
onSubmit={hookCommentSubmit}
|
|
1158
1167
|
onClose={hookCommentClose}
|
|
1159
1168
|
allowImages={allowImages}
|
|
@@ -1173,6 +1182,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
|
|
1173
1182
|
contextText={viewerCommentPopover.contextText}
|
|
1174
1183
|
isGlobal={viewerCommentPopover.isGlobal}
|
|
1175
1184
|
initialText={viewerCommentPopover.initialText}
|
|
1185
|
+
draftKey={`plan:${commentDraftScope}:${
|
|
1186
|
+
viewerCommentPopover.isGlobal
|
|
1187
|
+
? 'global'
|
|
1188
|
+
: `code-block:${viewerCommentPopover.codeBlock?.block.id ?? viewerCommentPopover.contextText}`
|
|
1189
|
+
}`}
|
|
1176
1190
|
onSubmit={handleViewerCommentSubmit}
|
|
1177
1191
|
onClose={handleViewerCommentClose}
|
|
1178
1192
|
allowImages={allowImages}
|