Skip to content

Commit c342ffb

Browse files
authored
feat(plan): simplify share/copy + sticky header polish (#519)
* refactor(ui): simplify Quick Share/Copy to Share/Copy Rename "Quick Copy"/"Quick Share" buttons in AnnotationPanel to plain "Copy"/"Share" and route Share through the existing ExportModal so it benefits from the short-link path for large payloads. The naive clipboard.writeText fallback is gone. For provenance purposes, this commit was AI assisted. * feat(plan): coordinated sticky header label shrinking Replace fixed pixel reserves in the sticky ghost header with measured geometry: a ResizeObserver on the wrapper plus one on the Viewer's action button cluster (tagged with data-sticky-actions) computes the exact space available for the toolstrip + badges. Both sides shrink in coordination so they stay horizontally aligned as long as possible: plan area | right side | ghost bar | layout >= 800 | full labels | active labels | shared lane 680..800 | short labels | active labels | shared lane 500..680 | icons only | active labels | shared lane 340..500 | icons only | icon-only | shared lane < 340 | icons only | icon-only | stacked The label-mode bucket lives in App.tsx state so the tree re-renders at most twice per drag instead of every pixel. Sticky lane measurements snap to a 16px grid for the same reason. Switches the IntersectionObserver scroll root from <main> to the OverlayScrollArea viewport (via useScrollViewport) so the ghost bar actually appears at the right scroll position. AttachmentsButton's "Images" label now also collapses with the cluster. For provenance purposes, this commit was AI assisted. * fix(plan): float collapsed sidebar rail over the grid background Absolutely position the SidebarTabs rail and reclaim its 30px gutter with left padding on the OverlayScrollArea, so the bg-grid pattern paints edge-to-edge under the flags instead of starting to their right. The plan card lands in exactly the same place as before. For provenance purposes, this commit was AI assisted. * fix(plan): address PR review — mobile share, a11y, snap hoist - AnnotationPanel mobile Share was opening ExportModal (z-50) underneath the panel (z-[60]) and its backdrop (z-[59]), creating a dead-end on mobile. Close the panel before opening the share modal. - AttachmentsButton trigger had no accessible name when hideLabel was active and there were no images yet. Add aria-label + title. - Hoist StickyHeaderLane's snap() to module scope so the effects (which use [] deps) can't close over a stale per-render instance. Currently harmless since snap is pure, but removes a footgun. For provenance purposes, this commit was AI assisted. * fix(plan): address PR review round 2 - StickyHeaderLane's [data-sticky-actions] ResizeObserver had [] deps and was attached at mount, but Viewer is keyed by linkedDocHook state and remounts on linked-doc toggle. The observer would stay bound to the now-detached old node and freeze actionsWidth, leaving the bar's geometry permanently wrong. Add a remountToken prop threaded from App.tsx so the effect re-queries against the fresh DOM. - Restore the shareUrl gate on the AnnotationPanel Share action — only pass onShare when a URL has been generated, matching prior behavior. - Hoist StickyHeaderLane's layout constants (LEFT_OFFSET, GAP, WIDE_BAR_WIDTH, MIN_BAR_WIDTH) to module scope alongside snap, for consistency. For provenance purposes, this commit was AI assisted. * fix(plan): address PR review round 3 — polish glitches - snap() uses Math.floor instead of Math.round so the wrapper width undershoots and the actions width overshoots, both pushing toward a more cautious layout. Avoids a one-bucket overlap flash right at the 300/460 thresholds during a slow drag. - Reset actionsWidth to 0 at the top of the [remountToken] effect so the bar falls back to the safe unmeasured path for the one frame between Viewer remounting and the new observer's first callback. - App.tsx switches to useLayoutEffect + a synchronous getBoundingClientRect to set the initial actionsLabelMode bucket before paint. Eliminates the one-frame flash of full labels on narrow viewports during first render. For provenance purposes, this commit was AI assisted.
1 parent ed254cf commit c342ffb

7 files changed

Lines changed: 222 additions & 107 deletions

File tree

packages/editor/App.tsx

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import React, { useState, useEffect, useMemo, useRef } from 'react';
1+
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
22
import { type Origin, getAgentName } from '@plannotator/shared/agents';
33
import { parseMarkdownToBlocks, exportAnnotations, exportLinkedDocAnnotations, exportEditorAnnotations, extractFrontmatter, wrapFeedbackForAgent, Frontmatter } from '@plannotator/ui/utils/parser';
44
import { Viewer, ViewerHandle } from '@plannotator/ui/components/Viewer';
55
import { AnnotationPanel } from '@plannotator/ui/components/AnnotationPanel';
66
import { ExportModal } from '@plannotator/ui/components/ExportModal';
77
import { ImportModal } from '@plannotator/ui/components/ImportModal';
88
import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog';
9-
import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment } from '@plannotator/ui/types';
9+
import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '@plannotator/ui/types';
1010
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
1111
import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip';
1212
import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane';
@@ -97,6 +97,36 @@ const App: React.FC = () => {
9797
return stored === 'true';
9898
});
9999
const [uiPrefs, setUiPrefs] = useState(() => getUIPreferences());
100+
101+
// Plan-area width (inside the OverlayScrollArea, after sidebar/panel
102+
// shrinkage) drives the action button label compactness. ResizeObserver
103+
// fires every frame during a resize drag, so we store only the BUCKET
104+
// ('full' | 'short' | 'icon') in state — App.tsx then re-renders at
105+
// most twice across an entire drag (once per threshold crossing) instead
106+
// of on every pixel, which would chug the whole tree.
107+
//
108+
// full → "Global comment" / "Copy plan" — fits when planArea >= 800
109+
// short → "Comment" / "Copy" — fits when planArea >= 680
110+
// icon → labels hidden — fallback below that
111+
const planAreaRef = useRef<HTMLDivElement>(null);
112+
const [actionsLabelMode, setActionsLabelMode] = useState<ActionsLabelMode>('full');
113+
// useLayoutEffect + synchronous getBoundingClientRect so the initial
114+
// bucket is set before the browser paints. Otherwise narrow viewports
115+
// get a one-frame flash of "Global comment"/"Copy plan" labels before
116+
// the ResizeObserver callback collapses them.
117+
useLayoutEffect(() => {
118+
const el = planAreaRef.current;
119+
if (!el) return;
120+
const bucket = (w: number): ActionsLabelMode =>
121+
w >= 800 ? 'full' : w >= 680 ? 'short' : 'icon';
122+
setActionsLabelMode(bucket(el.getBoundingClientRect().width));
123+
const ro = new ResizeObserver(([entry]) => {
124+
const next = bucket(entry.contentRect.width);
125+
setActionsLabelMode((prev) => (prev === next ? prev : next));
126+
});
127+
ro.observe(el);
128+
return () => ro.disconnect();
129+
}, []);
100130
const [isApiMode, setIsApiMode] = useState(false);
101131
const [origin, setOrigin] = useState<Origin | null>(null);
102132
const [gitUser, setGitUser] = useState<string | undefined>();
@@ -1431,7 +1461,7 @@ const App: React.FC = () => {
14311461
showVaultTab={showVaultTab}
14321462
hasFileAnnotations={hasFileAnnotations}
14331463
hasVaultAnnotations={hasVaultAnnotations}
1434-
className="hidden lg:flex"
1464+
className="hidden lg:flex absolute left-0 top-0 z-10"
14351465
/>
14361466
)}
14371467

@@ -1492,7 +1522,7 @@ const App: React.FC = () => {
14921522
{/* Document Area */}
14931523
<OverlayScrollArea
14941524
element="main"
1495-
className="flex-1 min-w-0 bg-grid"
1525+
className={`flex-1 min-w-0 bg-grid ${!sidebar.isOpen ? 'lg:pl-[30px]' : ''}`}
14961526
data-print-region="document"
14971527
onViewportReady={handleViewportReady}
14981528
>
@@ -1506,7 +1536,7 @@ const App: React.FC = () => {
15061536
cancelText="Dismiss"
15071537
showCancel
15081538
/>
1509-
<div className="min-h-full flex flex-col items-center px-2 py-3 md:px-10 md:py-8 xl:px-16 relative z-10">
1539+
<div ref={planAreaRef} className="min-h-full flex flex-col items-center px-2 py-3 md:px-10 md:py-8 xl:px-16 relative z-10">
15101540
{/* Sticky header lane — ghost bar that pins the toolstrip +
15111541
badges at top: 12px once the user scrolls. Invisible at top
15121542
of doc; original toolstrip/badges remain the source of
@@ -1526,6 +1556,7 @@ const App: React.FC = () => {
15261556
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
15271557
archiveInfo={archive.currentInfo}
15281558
maxWidth={planMaxWidth}
1559+
remountToken={linkedDocHook.isActive ? `doc:${linkedDocHook.filepath}` : 'plan'}
15291560
/>
15301561
)}
15311562

@@ -1605,6 +1636,7 @@ const App: React.FC = () => {
16051636
archiveInfo={archive.currentInfo}
16061637
onToggleCheckbox={checkbox.toggle}
16071638
checkboxOverrides={checkbox.overrides}
1639+
actionsLabelMode={actionsLabelMode}
16081640
/>
16091641
</div>
16101642
</div>
@@ -1622,7 +1654,6 @@ const App: React.FC = () => {
16221654
onSelect={setSelectedAnnotationId}
16231655
onDelete={handleDeleteAnnotation}
16241656
onEdit={handleEditAnnotation}
1625-
shareUrl={shareUrl}
16261657
sharingEnabled={sharingEnabled}
16271658
width={panelResize.width}
16281659
editorAnnotations={editorAnnotations}
@@ -1631,6 +1662,7 @@ const App: React.FC = () => {
16311662
onQuickCopy={async () => {
16321663
await navigator.clipboard.writeText(wrapFeedbackForAgent(annotationsOutput));
16331664
}}
1665+
onShare={shareUrl ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined}
16341666
otherFileAnnotations={otherFileAnnotations}
16351667
onOtherFileAnnotationsClick={handleFlashAnnotatedFiles}
16361668
/>

packages/ui/components/AgentsTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,9 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
264264
<button
265265
onClick={handleLaunch}
266266
disabled={!selectedProvider}
267-
className="px-3 py-1.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
267+
className="shrink-0 whitespace-nowrap px-3 py-1.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
268268
>
269-
Run Agent
269+
Run
270270
</button>
271271
</div>
272272
</div>

packages/ui/components/AnnotationPanel.tsx

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ interface PanelProps {
1414
onDelete: (id: string) => void;
1515
onEdit?: (id: string, updates: Partial<Annotation>) => void;
1616
selectedId: string | null;
17-
shareUrl?: string;
1817
sharingEnabled?: boolean;
1918
width?: number;
2019
editorAnnotations?: EditorAnnotation[];
2120
onDeleteEditorAnnotation?: (id: string) => void;
2221
onClose?: () => void;
2322
onQuickCopy?: () => Promise<void>;
23+
onShare?: () => void;
2424
otherFileAnnotations?: { count: number; files: number };
2525
onOtherFileAnnotationsClick?: () => void;
2626
}
@@ -33,18 +33,17 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
3333
onDelete,
3434
onEdit,
3535
selectedId,
36-
shareUrl,
3736
sharingEnabled = true,
3837
width,
3938
editorAnnotations,
4039
onDeleteEditorAnnotation,
4140
onClose,
4241
onQuickCopy,
42+
onShare,
4343
otherFileAnnotations,
4444
onOtherFileAnnotationsClick,
4545
}) => {
4646
const isMobile = useIsMobile();
47-
const [copied, setCopied] = useState(false);
4847
const [copiedText, setCopiedText] = useState(false);
4948
const listRef = useRef<HTMLDivElement>(null);
5049
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
@@ -59,17 +58,6 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
5958
}
6059
}, [selectedId]);
6160

62-
const handleQuickShare = async () => {
63-
if (!shareUrl) return;
64-
try {
65-
await navigator.clipboard.writeText(shareUrl);
66-
setCopied(true);
67-
setTimeout(() => setCopied(false), 2000);
68-
} catch (e) {
69-
console.error('Failed to copy:', e);
70-
}
71-
};
72-
7361
if (!isOpen) return null;
7462

7563
const panel = (
@@ -187,31 +175,20 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
187175
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
188176
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
189177
</svg>
190-
Quick Copy
178+
Copy
191179
</>
192180
)}
193181
</button>
194182
)}
195-
{sharingEnabled && shareUrl && (
183+
{sharingEnabled && onShare && (
196184
<button
197-
onClick={handleQuickShare}
185+
onClick={onShare}
198186
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium transition-all text-muted-foreground hover:text-foreground hover:bg-muted/50"
199187
>
200-
{copied ? (
201-
<>
202-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
203-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
204-
</svg>
205-
Copied
206-
</>
207-
) : (
208-
<>
209-
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
210-
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
211-
</svg>
212-
Quick Share
213-
</>
214-
)}
188+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
189+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
190+
</svg>
191+
Share
215192
</button>
216193
)}
217194
</div>

packages/ui/components/AttachmentsButton.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ interface AttachmentsButtonProps {
4646
onAdd: (image: ImageAttachment) => void;
4747
onRemove: (path: string) => void;
4848
variant?: 'toolbar' | 'inline';
49+
/** Hide the "Images" label (icon-only). When images.length > 0 the
50+
* numeric badge still shows so the user can see the count. */
51+
hideLabel?: boolean;
4952
}
5053

5154
export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
5255
images,
5356
onAdd,
5457
onRemove,
5558
variant = 'toolbar',
59+
hideLabel = false,
5660
}) => {
5761
const [isOpen, setIsOpen] = useState(false);
5862
const [manualPath, setManualPath] = useState('');
@@ -210,6 +214,8 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
210214
ref={buttonRef}
211215
type="button"
212216
onClick={() => setIsOpen(!isOpen)}
217+
aria-label="Attachments"
218+
title="Attachments"
213219
className="group relative flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
214220
>
215221
{/* Show stacked thumbnails if we have images */}
@@ -254,9 +260,11 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
254260
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
255261
</svg>
256262
)}
257-
<span className={variant === 'inline' ? 'sr-only' : ''}>
258-
{images.length > 0 ? `${images.length}` : 'Images'}
259-
</span>
263+
{(!hideLabel || images.length > 0) && (
264+
<span className={variant === 'inline' ? 'sr-only' : ''}>
265+
{images.length > 0 ? `${images.length}` : 'Images'}
266+
</span>
267+
)}
260268
</button>
261269

262270
{/* Popover */}

0 commit comments

Comments
 (0)