Skip to content

Commit a0a6edd

Browse files
YecatsCopilotbacknotprop
authored
feat: print support with export menu integration and keyboard shortcut (#420)
* feat: add print stylesheet for white paper output - Create packages/ui/styles/print.css with @media print rules - White background and black text for paper output - Monochrome code blocks for readability on white paper - Hide UI chrome (annotations, toolbars, sidebars) during print - Proper typography for headers, code, tables, lists, blockquotes - Page break rules to avoid orphaned content - Support for diagrams (Mermaid, Graphviz) in print - Import print.css in packages/ui/theme.css Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add Print button to toolbar - Add Print button next to Export button in plan editor toolbar - Triggers window.print() for native browser print dialog - Shows printer icon + 'Print' label (icon-only on mobile) - Tooltip: 'Print plan (Ctrl+P)' - Uses muted button styling consistent with other toolbar buttons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: rewrite print.css selectors to match real DOM structure Code review found the original selectors used class names that don't exist in the actual component tree. Fixed: - Target header.sticky / header.h-12 (not generic 'header') - Target aside elements directly (SidebarContainer + AnnotationPanel) - Target .annotation-toolbar (portalled floating toolbar) - Target .fixed overlays (modals, export dialog) - Target article element (Viewer renders <article>, not <main>) - Target .bg-grid for background pattern removal - Flatten .h-screen height for print flow - Simplified hljs monochrome rule to [class*='hljs'] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: print stylesheet — hide all toolbar UI, fix code block colors Issues fixed from user testing: - Header toolbar (Export/Print/Settings) now hidden via 'header' selector - AnnotationToolstrip (Select/Markup) hidden via '.flex-wrap' - Action buttons (Images/Comment/Copy) hidden via '.float-right' - Code block copy buttons hidden via '.group > button.absolute' - Fenced code blocks: override github-dark.css hljs theme to monochrome with light background (#f5f5f5) and dark text (#1a1a1a) - Inline code: solid light gray background with visible border - All text forced to solid black (override theme muted/foreground vars) - hljs span elements explicitly overridden for print Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: aggressive code block print overrides for dark theme - Override CSS custom properties (--muted, --foreground, etc.) in print - Use both background AND background-color on pre/code elements - Target pre[class], code[class], code.hljs with higher specificity - Explicitly list every hljs- class for monochrome override - Ensures github-dark.css hljs theme is fully overridden in print Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use JS beforeprint/afterprint class for code block print styling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add visibilitychange fallback for Firefox print cleanup Firefox may not fire afterprint when print preview is closed without printing, leaving the .plannotator-print class stuck on <html>. This adds a visibilitychange listener that removes the class when the user returns to the page, plus cleanup on unmount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: extract usePrintMode hook, replace brittle CSS selectors with data attributes - Extract print event listeners from App.tsx into packages/ui/hooks/usePrintMode.ts - Add data-print-region attributes to layout elements (root, content, document, article) - Add data-print-hide attribute to Viewer action buttons - Replace .h-screen, .flex-1.flex.overflow-hidden, .bg-card, .bg-grid, .float-right, .flex-wrap selectors with stable data-attribute and semantic element selectors - Follows existing hook patterns (useIsMobile, useDismissOnOutsideAndEscape, etc.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: hide annotation toolstrip and repo badges in print output Added data-print-hide to the AnnotationToolstrip wrapper and the repo/branch badges div inside the article. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: collapse top whitespace gap in print output Zero out article padding, add margin/padding reset to data-print-hide elements, and collapse first h1 top margin for a tight print layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: move print button into export menu and add Ctrl/Cmd+P shortcut * chore: remove accidentally introduced annotate command files Remove apps/hook/commands/annotate.md and apps/opencode-plugin/commands/annotate.md that were unintentionally added in the print-styling PR. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Yecats <Yecats@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Michael Ramos <mdramos8@gmail.com>
1 parent 08fcaaf commit a0a6edd

7 files changed

Lines changed: 517 additions & 6 deletions

File tree

packages/editor/App.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotat
3030
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
3131
import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod';
3232
import { useInputMethodSwitch } from '@plannotator/ui/hooks/useInputMethodSwitch';
33+
import { usePrintMode } from '@plannotator/ui/hooks/usePrintMode';
34+
import { modKey } from '@plannotator/ui/utils/platform';
3335
import { useResizablePanel } from '@plannotator/ui/hooks/useResizablePanel';
3436
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
3537
import { MobileMenu } from '@plannotator/ui/components/MobileMenu';
@@ -124,6 +126,8 @@ const App: React.FC = () => {
124126
const viewerRef = useRef<ViewerHandle>(null);
125127
const containerRef = useRef<HTMLElement>(null);
126128

129+
usePrintMode();
130+
127131
// Resizable panels
128132
const panelResize = useResizablePanel({ storageKey: 'plannotator-panel-width' });
129133
const tocResize = useResizablePanel({
@@ -995,6 +999,30 @@ const App: React.FC = () => {
995999
submitted, isApiMode, markdown, annotationsOutput,
9961000
]);
9971001

1002+
// Cmd/Ctrl+P keyboard shortcut — print plan
1003+
useEffect(() => {
1004+
const handlePrintShortcut = (e: KeyboardEvent) => {
1005+
if (e.key !== 'p' || !(e.metaKey || e.ctrlKey)) return;
1006+
1007+
const tag = (e.target as HTMLElement)?.tagName;
1008+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
1009+
1010+
if (showExport || showFeedbackPrompt || showClaudeCodeWarning ||
1011+
showAgentWarning || showPermissionModeSetup || pendingPasteImage) return;
1012+
1013+
if (submitted) return;
1014+
1015+
e.preventDefault();
1016+
window.print();
1017+
};
1018+
1019+
window.addEventListener('keydown', handlePrintShortcut);
1020+
return () => window.removeEventListener('keydown', handlePrintShortcut);
1021+
}, [
1022+
showExport, showFeedbackPrompt, showClaudeCodeWarning, showAgentWarning,
1023+
showPermissionModeSetup, pendingPasteImage, submitted,
1024+
]);
1025+
9981026
// Close export dropdown on click outside
9991027
useEffect(() => {
10001028
if (!showExportDropdown) return;
@@ -1018,7 +1046,7 @@ const App: React.FC = () => {
10181046

10191047
return (
10201048
<ThemeProvider defaultTheme="dark">
1021-
<div className="h-screen flex flex-col bg-background overflow-hidden">
1049+
<div data-print-region="root" className="h-screen flex flex-col bg-background overflow-hidden">
10221050
{/* Minimal Header */}
10231051
<header className="h-12 flex items-center justify-between px-2 md:px-4 border-b border-border/50 bg-card/50 backdrop-blur-xl sticky top-0 z-[50]">
10241052
<div className="flex items-center gap-2 md:gap-3">
@@ -1213,6 +1241,17 @@ const App: React.FC = () => {
12131241
</svg>
12141242
Download Annotations
12151243
</button>
1244+
<button
1245+
onClick={() => { setShowExportDropdown(false); window.print(); }}
1246+
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2"
1247+
>
1248+
<svg className="w-3.5 h-3.5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
1249+
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
1250+
</svg>
1251+
Print Plan
1252+
<span className="ml-auto text-[10px] text-muted-foreground/60">{modKey}+P</span>
1253+
</button>
1254+
<div className="my-1 border-t border-border" />
12161255
{isApiMode && isObsidianConfigured() && (
12171256
<button
12181257
onClick={() => handleQuickSaveToNotes('obsidian')}
@@ -1292,6 +1331,7 @@ const App: React.FC = () => {
12921331
setTimeout(() => setNoteSaveToast(null), 3000);
12931332
}}
12941333
onOpenImport={() => setShowImport(true)}
1334+
onPrint={() => window.print()}
12951335
sharingEnabled={sharingEnabled}
12961336
/>
12971337
</div>
@@ -1311,7 +1351,7 @@ const App: React.FC = () => {
13111351
)}
13121352

13131353
{/* Main Content */}
1314-
<div className={`flex-1 flex overflow-hidden relative z-0 ${isResizing ? 'select-none' : ''}`}>
1354+
<div data-print-region="content" className={`flex-1 flex overflow-hidden relative z-0 ${isResizing ? 'select-none' : ''}`}>
13151355
{/* Tater sprites — inside content wrapper so z-0 stacking context applies */}
13161356
{taterMode && <TaterSpriteRunning />}
13171357
{/* Left Sidebar: collapsed tab flags (when sidebar is closed) */}
@@ -1374,7 +1414,7 @@ const App: React.FC = () => {
13741414
)}
13751415

13761416
{/* Document Area */}
1377-
<main ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
1417+
<main data-print-region="document" ref={containerRef} className="flex-1 min-w-0 overflow-y-auto bg-grid">
13781418
<ConfirmDialog
13791419
isOpen={!!draftBanner}
13801420
onClose={dismissDraft}
@@ -1388,7 +1428,7 @@ const App: React.FC = () => {
13881428
<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">
13891429
{/* Annotation Toolstrip (hidden during plan diff and archive mode) */}
13901430
{!isPlanDiffActive && !archive.archiveMode && (
1391-
<div className="w-full mb-3 md:mb-4 flex items-center justify-start" style={{ maxWidth: planMaxWidth }}>
1431+
<div data-print-hide className="w-full mb-3 md:mb-4 flex items-center justify-start" style={{ maxWidth: planMaxWidth }}>
13921432
<AnnotationToolstrip
13931433
inputMethod={inputMethod}
13941434
onInputMethodChange={handleInputMethodChange}

packages/ui/components/KeyboardShortcuts.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const planShortcuts: ShortcutSection[] = [
7676
shortcuts: [
7777
{ keys: [modKey, enter], desc: 'Submit / Approve' },
7878
{ keys: [modKey, 'S'], desc: 'Save to notes app' },
79+
{ keys: [modKey, 'P'], desc: 'Print plan' },
7980
{ keys: ['Esc'], desc: 'Close dialog' },
8081
],
8182
},

packages/ui/components/MobileMenu.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface MobileMenuProps {
1010
onDownloadAnnotations: () => void;
1111
onCopyShareLink: () => void;
1212
onOpenImport: () => void;
13+
onPrint: () => void;
1314
shareUrl?: string;
1415
sharingEnabled: boolean;
1516
className?: string;
@@ -24,6 +25,7 @@ export const MobileMenu: React.FC<MobileMenuProps> = ({
2425
onDownloadAnnotations,
2526
onCopyShareLink,
2627
onOpenImport,
28+
onPrint,
2729
sharingEnabled,
2830
className,
2931
}) => {
@@ -120,6 +122,13 @@ export const MobileMenu: React.FC<MobileMenuProps> = ({
120122
label="Download Annotations"
121123
/>
122124

125+
{/* Print */}
126+
<MenuItem
127+
onClick={() => handleAction(onPrint)}
128+
icon={<PrintIcon />}
129+
label="Print Plan"
130+
/>
131+
123132
{/* Share link */}
124133
{sharingEnabled && (
125134
<MenuItem
@@ -203,3 +212,9 @@ const ImportIcon = () => (
203212
<path strokeLinecap="round" strokeLinejoin="round" d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3" />
204213
</svg>
205214
);
215+
216+
const PrintIcon = () => (
217+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
218+
<path strokeLinecap="round" strokeLinejoin="round" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
219+
</svg>
220+
);

packages/ui/components/Viewer.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,14 +433,15 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
433433
{taterMode && <TaterSpriteSitting />}
434434
<article
435435
ref={containerRef}
436+
data-print-region="article"
436437
className={`w-full bg-card rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative ${
437438
linkedDocInfo ? 'border border-primary/40' : 'border border-border/50'
438439
} ${inputMethod === 'pinpoint' ? 'cursor-crosshair' : ''}`}
439440
style={{ WebkitTouchCallout: 'none' } as React.CSSProperties}
440441
>
441442
{/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
442443
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo) && (
443-
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
444+
<div data-print-hide className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
444445
{repoInfo && !linkedDocInfo && (
445446
<div className="flex items-center gap-1.5">
446447
<span className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]" title={repoInfo.display}>
@@ -518,7 +519,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
518519
{stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
519520

520521
{/* Header buttons - top right */}
521-
<div className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} -mr-3 mt-6 md:-mr-5 md:-mt-5 lg:-mr-7 lg:-mt-7 xl:-mr-9 xl:-mt-9`}>
522+
<div data-print-hide className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} -mr-3 mt-6 md:-mr-5 md:-mt-5 lg:-mr-7 lg:-mt-7 xl:-mr-9 xl:-mt-9`}>
522523
{/* Attachments button */}
523524
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
524525
<AttachmentsButton

packages/ui/hooks/usePrintMode.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect } from 'react';
2+
3+
/**
4+
* Manages print mode by toggling 'plannotator-print' class on <html>.
5+
* Includes visibilitychange fallback for Firefox, which may not fire
6+
* afterprint when print preview is closed without printing.
7+
*/
8+
export function usePrintMode() {
9+
useEffect(() => {
10+
const onBeforePrint = () => document.documentElement.classList.add('plannotator-print');
11+
const onAfterPrint = () => document.documentElement.classList.remove('plannotator-print');
12+
const onVisibilityChange = () => {
13+
if (!document.hidden && document.documentElement.classList.contains('plannotator-print')) {
14+
document.documentElement.classList.remove('plannotator-print');
15+
}
16+
};
17+
window.addEventListener('beforeprint', onBeforePrint);
18+
window.addEventListener('afterprint', onAfterPrint);
19+
document.addEventListener('visibilitychange', onVisibilityChange);
20+
return () => {
21+
window.removeEventListener('beforeprint', onBeforePrint);
22+
window.removeEventListener('afterprint', onAfterPrint);
23+
document.removeEventListener('visibilitychange', onVisibilityChange);
24+
document.documentElement.classList.remove('plannotator-print');
25+
};
26+
}, []);
27+
}

0 commit comments

Comments
 (0)