From 2a6e4346a6c4dc2713910b59343d8ef6926ec999 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Tue, 19 Aug 2025 15:55:46 -0700 Subject: [PATCH 01/12] pdf download path changed --- apps/desktop/src-tauri/capabilities/default.json | 4 +++- apps/desktop/src/components/toolbar/buttons/share-button.tsx | 3 +++ apps/desktop/src/components/toolbar/utils/pdf-export.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 2b6aaefc3e..ef8407bd91 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -78,7 +78,9 @@ "identifier": "fs:allow-write-file", "allow": [ { "path": "$APPDATA/*" }, - { "path": "$APPDATA/**" } + { "path": "$APPDATA/**" }, + { "path": "$DOWNLOAD/*" }, + { "path": "$DOWNLOAD/**" } ] }, { diff --git a/apps/desktop/src/components/toolbar/buttons/share-button.tsx b/apps/desktop/src/components/toolbar/buttons/share-button.tsx index 2d4be60077..8b1edcd448 100644 --- a/apps/desktop/src/components/toolbar/buttons/share-button.tsx +++ b/apps/desktop/src/components/toolbar/buttons/share-button.tsx @@ -442,6 +442,9 @@ const exportHandlers = { pdf: async (session: Session): Promise => { const path = await exportToPDF(session); + if (path) { + await message(`Meeting summary saved to your 'Downloads' folder ("${path}")`); + } return { type: "pdf", path }; }, diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index 3c8cf3fdb3..9cf3eed6bf 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -1,4 +1,4 @@ -import { appDataDir } from "@tauri-apps/api/path"; +import { downloadDir } from "@tauri-apps/api/path"; import { writeFile } from "@tauri-apps/plugin-fs"; import { jsPDF } from "jspdf"; @@ -332,7 +332,7 @@ export const exportToPDF = async (session: SessionData): Promise => { const pdfArrayBuffer = pdf.output("arraybuffer"); const uint8Array = new Uint8Array(pdfArrayBuffer); - const downloadsPath = await appDataDir(); + const downloadsPath = await downloadDir(); const filePath = downloadsPath.endsWith("/") ? `${downloadsPath}${filename}` : `${downloadsPath}/${filename}`; From 42908cdeb294579f3571f340a89d16cfa4c0f284 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 01:21:40 -0700 Subject: [PATCH 02/12] kind of a working version --- .../components/toolbar/utils/pdf-export.ts | 243 +++++++++++++++--- 1 file changed, 203 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index 9cf3eed6bf..d53fb9074b 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -9,12 +9,22 @@ export type SessionData = Session & { event?: Event | null; }; +// Enhanced interface to support vector bullets interface TextSegment { text: string; - bold?: boolean; - italic?: boolean; isHeader?: number; // 1, 2, 3 for h1, h2, h3 isListItem?: boolean; + listType?: 'ordered' | 'unordered'; + listLevel?: number; + listItemNumber?: number; + bulletType?: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle'; // New: for vector bullets +} + +// New: List context to track state during parsing +interface ListContext { + type: 'ordered' | 'unordered'; + level: number; + counters: number[]; // Track numbering for each level } // TODO: @@ -27,10 +37,18 @@ const htmlToStructuredText = (html: string): TextSegment[] => { return []; } + // Strip out bold and italic tags while preserving content + const cleanedHtml = html + .replace(/<\/?strong>/gi, '') // Remove and + .replace(/<\/?b>/gi, '') // Remove and + .replace(/<\/?em>/gi, '') // Remove and + .replace(/<\/?i>/gi, ''); // Remove and + const tempDiv = document.createElement("div"); - tempDiv.innerHTML = html; + tempDiv.innerHTML = cleanedHtml; // Use cleaned HTML instead of original const segments: TextSegment[] = []; + const listStack: ListContext[] = []; // Track nested lists const processNode = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -52,35 +70,110 @@ const htmlToStructuredText = (html: string): TextSegment[] => { case "h3": segments.push({ text: element.textContent || "", isHeader: 3 }); break; - case "strong": - case "b": - segments.push({ text: element.textContent || "", bold: true }); + + // Enhanced list handling + case "ul": + processListContainer(element, 'unordered'); break; - case "em": - case "i": - segments.push({ text: element.textContent || "", italic: true }); + case "ol": + processListContainer(element, 'ordered'); break; case "li": - segments.push({ text: `• ${element.textContent || ""}`, isListItem: true }); + processListItem(element); break; + case "p": if (element.textContent?.trim()) { - // Process inline formatting within paragraphs processInlineFormatting(element, segments); - segments.push({ text: "\n" }); // Add paragraph break + segments.push({ text: "\n" }); } break; case "br": segments.push({ text: "\n" }); break; default: - // For other elements, process children Array.from(node.childNodes).forEach(processNode); break; } } }; + const processListContainer = (listElement: Element, type: 'ordered' | 'unordered') => { + const level = listStack.length; + + // Initialize counters array if needed + const counters = [...(listStack[listStack.length - 1]?.counters || [])]; + if (counters.length <= level) { + counters[level] = 0; + } + + // Push new list context + listStack.push({ type, level, counters }); + + // Process list items + Array.from(listElement.children).forEach((child, index) => { + if (child.tagName.toLowerCase() === 'li') { + if (type === 'ordered') { + counters[level] = index + 1; + } + processNode(child); + } + }); + + // Pop list context + listStack.pop(); + }; + + const processListItem = (liElement: Element) => { + const currentList = listStack[listStack.length - 1]; + if (!currentList) return; + + const { type, level, counters } = currentList; + + // Extract text content, handling nested formatting + const textContent = getListItemText(liElement); + + // For unordered lists, determine bullet type based on level + // After level 2 (third level), always use square + const bulletTypes = ['filled-circle', 'hollow-circle', 'square'] as const; + + segments.push({ + text: type === 'ordered' + ? `${counters[level]}. ${textContent}` // Keep numbers as text + : textContent, // Remove bullet prefix for unordered - we'll draw it as vector + isListItem: true, + listType: type, + listLevel: level, + listItemNumber: type === 'ordered' ? counters[level] : undefined, + bulletType: type === 'unordered' + ? (level <= 2 ? bulletTypes[level] : 'square') // Cap at square for level 3+ + : undefined + }); + + // Process nested lists within this list item + Array.from(liElement.children).forEach(child => { + if (child.tagName.toLowerCase() === 'ul' || child.tagName.toLowerCase() === 'ol') { + processNode(child); + } + }); + }; + + const getListItemText = (liElement: Element): string => { + // Get only direct text content, excluding nested lists + let text = ''; + for (const child of liElement.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + text += child.textContent || ''; + } else if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as Element; + if (!['ul', 'ol'].includes(element.tagName.toLowerCase())) { + text += element.textContent || ''; + } + } + } + return text.trim(); + }; + const processInlineFormatting = (element: Element, segments: TextSegment[]) => { Array.from(element.childNodes).forEach(child => { if (child.nodeType === Node.TEXT_NODE) { @@ -90,23 +183,11 @@ const htmlToStructuredText = (html: string): TextSegment[] => { } } else if (child.nodeType === Node.ELEMENT_NODE) { const childElement = child as Element; - const tagName = childElement.tagName.toLowerCase(); const text = childElement.textContent || ""; if (text.trim()) { - switch (tagName) { - case "strong": - case "b": - segments.push({ text, bold: true }); - break; - case "em": - case "i": - segments.push({ text, italic: true }); - break; - default: - segments.push({ text }); - break; - } + // Remove bold/italic detection - treat everything as normal text + segments.push({ text }); } } }); @@ -155,6 +236,63 @@ const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: } }; +// Update the drawVectorBullet function signature to use a smaller default size +const drawVectorBullet = ( + pdf: jsPDF, + bulletType: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle', + x: number, + y: number, + size: number = 1.0 // Reduced from 1.5 to 1.0 +) => { + // Save current state + const currentFillColor = pdf.getFillColor(); + const currentDrawColor = pdf.getDrawColor(); + + // Set bullet color (dark gray to match text) + pdf.setFillColor(50, 50, 50); + pdf.setDrawColor(50, 50, 50); + pdf.setLineWidth(0.2); // Also made line width thinner + + // Adjust y position to center bullet with text baseline + const bulletY = y - (size / 2); + + switch (bulletType) { + case 'filled-circle': + pdf.circle(x, bulletY, size * 0.85, 'F'); // Made circle smaller (0.85x instead of 1x) + break; + + case 'hollow-circle': + pdf.circle(x, bulletY, size * 0.85, 'S'); // Made circle smaller (0.85x instead of 1x) + break; + + case 'square': + const squareSize = size * 1.4; // Made square bigger (1.4x instead of 1.1x) + pdf.rect( + x - squareSize/2, + bulletY - squareSize/2, + squareSize, + squareSize, + 'F' + ); + break; + + case 'triangle': + const triangleSize = size * 1.15; // Keep triangle the same + // Create triangle path + pdf.triangle( + x, bulletY - triangleSize/2, // top point + x - triangleSize/2, bulletY + triangleSize/2, // bottom left + x + triangleSize/2, bulletY + triangleSize/2, // bottom right + 'F' + ); + break; + } + + // Restore previous state + pdf.setFillColor(currentFillColor); + pdf.setDrawColor(currentDrawColor); +}; + export const exportToPDF = async (session: SessionData): Promise => { const { participants, event } = await fetchSessionMetadata(session.id); @@ -296,22 +434,28 @@ export const exportToPDF = async (session: SessionData): Promise => { yPosition += lineHeight; // Extra space before headers } else { pdf.setFontSize(12); - const fontStyle = segment.bold && segment.italic - ? "bolditalic" - : segment.bold - ? "bold" - : segment.italic - ? "italic" - : "normal"; - pdf.setFont("helvetica", fontStyle); + // Remove font style logic - always use normal + pdf.setFont("helvetica", "normal"); pdf.setTextColor(50, 50, 50); // Dark gray for content } - // Handle list items with indentation - const xPosition = segment.isListItem ? margin + 5 : margin; + // Enhanced list item handling with vector bullets + let xPosition = margin; + let bulletSpace = 0; + + if (segment.isListItem && segment.listLevel !== undefined) { + // Base indentation + additional for each level + const baseIndent = 5; + const levelIndent = 8; + xPosition = margin + baseIndent + (segment.listLevel * levelIndent); + + // Reserve space for bullet/number + bulletSpace = segment.listType === 'ordered' ? 0 : 6; // Space for vector bullet + } - // Split long text into multiple lines - const lines = splitTextToLines(segment.text, pdf, maxWidth - (segment.isListItem ? 5 : 0)); + // Adjust max width for indented content and bullet space + const effectiveMaxWidth = maxWidth - (xPosition - margin) - bulletSpace; + const lines = splitTextToLines(segment.text, pdf, effectiveMaxWidth); for (let i = 0; i < lines.length; i++) { if (yPosition > pageHeight - margin) { @@ -319,7 +463,26 @@ export const exportToPDF = async (session: SessionData): Promise => { yPosition = margin; } - pdf.text(lines[i], xPosition, yPosition); + // Draw vector bullet for first line of unordered list items + if (segment.isListItem && + segment.listType === 'unordered' && + segment.bulletType && + i === 0) { + drawVectorBullet( + pdf, + segment.bulletType, + xPosition + 2, // Position bullet slightly right of indent + yPosition - 1, // Adjust for text baseline + 1.0 // Reduced bullet size from 1.5 to 1.0 + ); + } + + // Position text after bullet space + const textXPosition = segment.isListItem && i > 0 + ? xPosition + bulletSpace + 4 // Continuation lines with extra indent + : xPosition + bulletSpace; + + pdf.text(lines[i], textXPosition, yPosition); yPosition += lineHeight; } From e7569d2eee00a07fb88c5d660a907e920d466fd5 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 01:51:24 -0700 Subject: [PATCH 03/12] applied pdf themes --- .../toolbar/buttons/share-button.tsx | 29 ++- .../components/toolbar/utils/pdf-export.ts | 86 +++++-- .../components/toolbar/utils/pdf-themes.ts | 239 ++++++++++++++++++ 3 files changed, 323 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/components/toolbar/utils/pdf-themes.ts diff --git a/apps/desktop/src/components/toolbar/buttons/share-button.tsx b/apps/desktop/src/components/toolbar/buttons/share-button.tsx index 8b1edcd448..afad07f634 100644 --- a/apps/desktop/src/components/toolbar/buttons/share-button.tsx +++ b/apps/desktop/src/components/toolbar/buttons/share-button.tsx @@ -23,7 +23,7 @@ import { Button } from "@hypr/ui/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; import { useSession } from "@hypr/utils/contexts"; -import { exportToPDF } from "../utils/pdf-export"; +import { exportToPDF, getAvailableThemes, type ThemeName } from "../utils/pdf-export"; export function ShareButton() { const param = useParams({ from: "/app/note/$id", shouldThrow: false }); @@ -38,6 +38,7 @@ function ShareButtonInNote() { const [open, setOpen] = useState(false); const [expandedId, setExpandedId] = useState(null); const [selectedObsidianFolder, setSelectedObsidianFolder] = useState("default"); + const [selectedPdfTheme, setSelectedPdfTheme] = useState("default"); const [includeTranscript, setIncludeTranscript] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const hasEnhancedNote = !!session?.enhanced_memo_html; @@ -161,7 +162,7 @@ function ShareButtonInNote() { if (optionId === "copy") { result = await exportHandlers.copy(session); } else if (optionId === "pdf") { - result = await exportHandlers.pdf(session); + result = await exportHandlers.pdf(session, selectedPdfTheme); } else if (optionId === "email") { try { // fetch participants directly, bypassing cache @@ -331,6 +332,26 @@ function ShareButtonInNote() { + {option.id === "pdf" && ( +
+ + +
+ )} + {option.id === "obsidian" && ( <>
@@ -440,8 +461,8 @@ const exportHandlers = { } }, - pdf: async (session: Session): Promise => { - const path = await exportToPDF(session); + pdf: async (session: Session, theme: ThemeName = "default"): Promise => { + const path = await exportToPDF(session, theme); if (path) { await message(`Meeting summary saved to your 'Downloads' folder ("${path}")`); } diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index d53fb9074b..f0b98fae4d 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -3,6 +3,10 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import { jsPDF } from "jspdf"; import { commands as dbCommands, type Event, type Human, type Session } from "@hypr/plugin-db"; +import { getPDFTheme, type ThemeName } from "./pdf-themes"; + +// Re-export theme types and function for external use +export { getPDFTheme, getAvailableThemes, type ThemeName, type PDFTheme } from "./pdf-themes"; export type SessionData = Session & { participants?: Human[]; @@ -242,15 +246,16 @@ const drawVectorBullet = ( bulletType: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle', x: number, y: number, - size: number = 1.0 // Reduced from 1.5 to 1.0 + size: number = 1.0, + color: readonly [number, number, number] = [50, 50, 50] // Accept color parameter ) => { // Save current state const currentFillColor = pdf.getFillColor(); const currentDrawColor = pdf.getDrawColor(); - // Set bullet color (dark gray to match text) - pdf.setFillColor(50, 50, 50); - pdf.setDrawColor(50, 50, 50); + // Set bullet color from parameter + pdf.setFillColor(...color); + pdf.setDrawColor(...color); pdf.setLineWidth(0.2); // Also made line width thinner // Adjust y position to center bullet with text baseline @@ -293,9 +298,17 @@ const drawVectorBullet = ( pdf.setDrawColor(currentDrawColor); }; -export const exportToPDF = async (session: SessionData): Promise => { + + +export const exportToPDF = async ( + session: SessionData, + themeName: ThemeName = 'default' +): Promise => { const { participants, event } = await fetchSessionMetadata(session.id); + // 🎨 Get PDF styling from theme + const PDF_STYLES = getPDFTheme(themeName); + // Generate filename const filename = session?.title ? `${session.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.pdf` @@ -309,13 +322,32 @@ export const exportToPDF = async (session: SessionData): Promise => { const maxWidth = pageWidth - (margin * 2); const lineHeight = 6; + // 🎨 Helper function to apply background color to current page + const applyBackgroundColor = () => { + if (PDF_STYLES.colors.background[0] !== 255 || + PDF_STYLES.colors.background[1] !== 255 || + PDF_STYLES.colors.background[2] !== 255) { + pdf.setFillColor(...PDF_STYLES.colors.background); + pdf.rect(0, 0, pageWidth, pageHeight, 'F'); + } + }; + + // 🎨 Helper function to add new page with background + const addNewPage = () => { + pdf.addPage(); + applyBackgroundColor(); + }; + let yPosition = margin; + // 🎨 Apply background color to first page + applyBackgroundColor(); + // Add title with text wrapping const title = session?.title || "Untitled Note"; pdf.setFontSize(16); - pdf.setFont("helvetica", "bold"); - pdf.setTextColor(0, 0, 0); // Black + pdf.setFont(PDF_STYLES.font, "bold"); + pdf.setTextColor(...PDF_STYLES.colors.headers); // Use headers color for title // Split title into multiple lines if it's too long const titleLines = splitTextToLines(title, pdf, maxWidth); @@ -329,8 +361,8 @@ export const exportToPDF = async (session: SessionData): Promise => { // Add creation date ONLY if there's no event info if (!event && session?.created_at) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color const createdAt = `Created: ${new Date(session.created_at).toLocaleDateString()}`; pdf.text(createdAt, margin, yPosition); yPosition += lineHeight; @@ -339,8 +371,8 @@ export const exportToPDF = async (session: SessionData): Promise => { // Add event info if available if (event) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color // Event name if (event.name) { @@ -375,8 +407,8 @@ export const exportToPDF = async (session: SessionData): Promise => { // Add participants if available if (participants && participants.length > 0) { pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color const participantNames = participants .filter(p => p.full_name) @@ -396,13 +428,13 @@ export const exportToPDF = async (session: SessionData): Promise => { // Add attribution with clickable "Hyprnote" pdf.setFontSize(10); - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(100, 100, 100); // Gray + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color pdf.text("Summarized by ", margin, yPosition); // Calculate width of "Summarized by " to position "Hyprnote" const madeByWidth = pdf.getTextWidth("Summarized by "); - pdf.setTextColor(37, 99, 235); // Blue color for Hyprnote + pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); // Use hyprnote link color // Create clickable link for Hyprnote const hyprnoteText = "Hyprnote"; @@ -411,7 +443,7 @@ export const exportToPDF = async (session: SessionData): Promise => { yPosition += lineHeight * 2; // Add separator line - pdf.setDrawColor(200, 200, 200); // Light gray line + pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); // Use separator line color pdf.line(margin, yPosition, pageWidth - margin, yPosition); yPosition += lineHeight; @@ -421,7 +453,7 @@ export const exportToPDF = async (session: SessionData): Promise => { for (const segment of segments) { // Check if we need a new page if (yPosition > pageHeight - margin) { - pdf.addPage(); + addNewPage(); // ✅ Use helper function instead of pdf.addPage() yPosition = margin; } @@ -429,14 +461,13 @@ export const exportToPDF = async (session: SessionData): Promise => { if (segment.isHeader) { const headerSizes = { 1: 14, 2: 13, 3: 12 }; pdf.setFontSize(headerSizes[segment.isHeader as keyof typeof headerSizes]); - pdf.setFont("helvetica", "bold"); - pdf.setTextColor(0, 0, 0); // Black for headers + pdf.setFont(PDF_STYLES.font, "bold"); + pdf.setTextColor(...PDF_STYLES.colors.headers); // Use headers color yPosition += lineHeight; // Extra space before headers } else { pdf.setFontSize(12); - // Remove font style logic - always use normal - pdf.setFont("helvetica", "normal"); - pdf.setTextColor(50, 50, 50); // Dark gray for content + pdf.setFont(PDF_STYLES.font, "normal"); + pdf.setTextColor(...PDF_STYLES.colors.mainContent); // Use main content color } // Enhanced list item handling with vector bullets @@ -459,7 +490,7 @@ export const exportToPDF = async (session: SessionData): Promise => { for (let i = 0; i < lines.length; i++) { if (yPosition > pageHeight - margin) { - pdf.addPage(); + addNewPage(); // ✅ Use helper function instead of pdf.addPage() yPosition = margin; } @@ -471,9 +502,10 @@ export const exportToPDF = async (session: SessionData): Promise => { drawVectorBullet( pdf, segment.bulletType, - xPosition + 2, // Position bullet slightly right of indent - yPosition - 1, // Adjust for text baseline - 1.0 // Reduced bullet size from 1.5 to 1.0 + xPosition + 2, + yPosition - 1, + 1.0, + PDF_STYLES.colors.bullets // This now comes from the selected theme ); } diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts new file mode 100644 index 0000000000..d4e1794eb6 --- /dev/null +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -0,0 +1,239 @@ +// 🎨 PDF THEME SYSTEM + +export type ThemeName = + | 'default' + | 'light' + | 'dark' + | 'corporate' + | 'ocean' + | 'sunset' + | 'forest' + | 'sci-fi' + | 'retro' + | 'spring' + | 'summer' + | 'winter'; + +export interface PDFTheme { + font: string; + colors: { + background: readonly [number, number, number]; + mainContent: readonly [number, number, number]; + headers: readonly [number, number, number]; + metadata: readonly [number, number, number]; + hyprnoteLink: readonly [number, number, number]; + separatorLine: readonly [number, number, number]; + bullets: readonly [number, number, number]; + }; +} + +export const getPDFTheme = (themeName: ThemeName): PDFTheme => { + const themes: Record = { + default: { + font: "helvetica", + colors: { + background: [255, 255, 255], // Pure white + mainContent: [33, 33, 33], // Dark charcoal + headers: [0, 0, 0], // Black + metadata: [102, 102, 102], // Medium gray + hyprnoteLink: [59, 130, 246], // Blue + separatorLine: [229, 229, 229], // Light gray + bullets: [75, 85, 99], // Slate gray + } + }, + + light: { + font: "helvetica", + colors: { + background: [250, 250, 250], // Off-white + mainContent: [55, 65, 81], // Gray 700 + headers: [17, 24, 39], // Gray 900 + metadata: [107, 114, 128], // Gray 500 + hyprnoteLink: [99, 102, 241], // Indigo + separatorLine: [209, 213, 219], // Gray 300 + bullets: [75, 85, 99], // Gray 600 + } + }, + + dark: { + font: "verdana", + colors: { + background: [17, 24, 39], // Gray 900 + mainContent: [229, 231, 235], // Gray 200 + headers: [255, 255, 255], // White + metadata: [156, 163, 175], // Gray 400 + hyprnoteLink: [147, 197, 253], // Light blue + separatorLine: [55, 65, 81], // Gray 700 + bullets: [209, 213, 219], // Gray 300 + } + }, + + corporate: { + font: "times new roman", + colors: { + background: [255, 255, 255], // Pure white + mainContent: [15, 23, 42], // Slate 900 + headers: [30, 41, 59], // Slate 800 + metadata: [100, 116, 139], // Slate 500 + hyprnoteLink: [30, 64, 175], // Professional blue + separatorLine: [203, 213, 225], // Slate 300 + bullets: [51, 65, 85], // Slate 700 + } + }, + + ocean: { + font: "helvetica", + colors: { + background: [236, 254, 255], // Cyan 50 + mainContent: [22, 78, 99], // Cyan 800 + headers: [5, 39, 103], // Blue 900 + metadata: [14, 116, 144], // Cyan 700 + hyprnoteLink: [8, 145, 178], // Cyan 600 + separatorLine: [165, 243, 252], // Cyan 200 + bullets: [34, 211, 238], // Cyan 400 + } + }, + + sunset: { + font: "helvetica", + colors: { + background: [255, 251, 235], // Orange 50 + mainContent: [124, 45, 18], // Orange 900 + headers: [154, 52, 18], // Orange 800 + metadata: [194, 65, 12], // Orange 700 + hyprnoteLink: [234, 88, 12], // Orange 600 + separatorLine: [254, 215, 170], // Orange 200 + bullets: [251, 146, 60], // Orange 400 + } + }, + + forest: { + font: "helvetica", + colors: { + background: [240, 253, 244], // Green 50 + mainContent: [20, 83, 45], // Green 800 + headers: [22, 101, 52], // Green 700 + metadata: [21, 128, 61], // Green 600 + hyprnoteLink: [34, 197, 94], // Green 500 + separatorLine: [187, 247, 208], // Green 200 + bullets: [74, 222, 128], // Green 400 + } + }, + + 'sci-fi': { + font: "helvetica", + colors: { + background: [6, 15, 25], // Dark blue-black + mainContent: [0, 255, 204], // Cyan green + headers: [0, 255, 255], // Pure cyan + metadata: [102, 204, 255], // Light blue + hyprnoteLink: [255, 0, 255], // Magenta + separatorLine: [0, 102, 153], // Dark blue + bullets: [0, 255, 128], // Bright green + } + }, + + retro: { + font: "helvetica", + colors: { + background: [254, 249, 195], // Yellow 100 + mainContent: [120, 53, 15], // Orange 900 + headers: [146, 64, 14], // Orange 800 + metadata: [180, 83, 9], // Orange 700 + hyprnoteLink: [202, 138, 4], // Yellow 600 + separatorLine: [254, 240, 138], // Yellow 200 + bullets: [245, 158, 11], // Amber 500 + } + }, + + spring: { + font: "helvetica", + colors: { + background: [247, 254, 231], // Lime 50 + mainContent: [54, 83, 20], // Lime 800 + headers: [77, 124, 15], // Lime 700 + metadata: [101, 163, 13], // Lime 600 + hyprnoteLink: [132, 204, 22], // Lime 500 + separatorLine: [217, 249, 157], // Lime 200 + bullets: [163, 230, 53], // Lime 400 + } + }, + + summer: { + font: "helvetica", + colors: { + background: [255, 247, 237], // Orange 25 + mainContent: [154, 52, 18], // Orange 800 + headers: [194, 65, 12], // Orange 700 + metadata: [234, 88, 12], // Orange 600 + hyprnoteLink: [251, 146, 60], // Orange 400 + separatorLine: [254, 215, 170], // Orange 200 + bullets: [255, 154, 0], // Bright orange + } + }, + + winter: { + font: "helvetica", + colors: { + background: [241, 245, 249], // Slate 100 + mainContent: [30, 41, 59], // Slate 800 + headers: [15, 23, 42], // Slate 900 + metadata: [71, 85, 105], // Slate 600 + hyprnoteLink: [59, 130, 246], // Blue 500 + separatorLine: [203, 213, 225], // Slate 300 + bullets: [100, 116, 139], // Slate 500 + } + } + }; + + return themes[themeName] || themes.default; +}; + +// Helper function to get all available theme names +export const getAvailableThemes = (): ThemeName[] => { + return [ + 'default', + 'light', + 'dark', + 'corporate', + 'ocean', + 'sunset', + 'forest', + 'sci-fi', + 'retro', + 'spring', + 'summer', + 'winter' + ]; +}; + +// Helper function to get theme preview info +export const getThemePreview = (themeName: ThemeName) => { + const theme = getPDFTheme(themeName); + return { + name: themeName, + font: theme.font, + primaryColor: theme.colors.headers, + backgroundColor: theme.colors.background, + description: getThemeDescription(themeName) + }; +}; + +const getThemeDescription = (themeName: ThemeName): string => { + const descriptions: Record = { + default: "Clean charcoal text on white with Helvetica", + light: "Subtle grays on off-white with Helvetica", + dark: "Light text on dark slate with Verdana", + corporate: "Professional navy on white with Times New Roman", + ocean: "Deep blues on cyan background with Optima", + sunset: "Warm oranges on cream with Palatino", + forest: "Natural greens on light green with Verdana", + 'sci-fi': "Neon cyan on dark blue-black with Helvetica", + retro: "Vintage browns on yellow with Times New Roman", + spring: "Fresh lime greens on light background with Optima", + summer: "Bright oranges on warm cream with Noteworthy", + winter: "Cool slate tones on light blue with Palatino" + }; + + return descriptions[themeName] || descriptions.default; +}; From 932e2d5ae4422468764e60aedd770ca23b2e1c85 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 10:45:44 -0700 Subject: [PATCH 04/12] stablized a bit --- .../src-tauri/capabilities/default.json | 4 +- .../components/toolbar/utils/pdf-export.ts | 150 ++++++--------- .../components/toolbar/utils/pdf-themes.ts | 180 +++++++++--------- 3 files changed, 154 insertions(+), 180 deletions(-) diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index ef8407bd91..687487f5f2 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -60,7 +60,9 @@ "identifier": "opener:allow-open-path", "allow": [ { "path": "$APPDATA/*" }, - { "path": "$APPDATA/**" } + { "path": "$APPDATA/**" }, + { "path": "$DOWNLOAD/*" }, + { "path": "$DOWNLOAD/**" } ] }, { diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index f0b98fae4d..3ed01750f5 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -5,7 +5,6 @@ import { jsPDF } from "jspdf"; import { commands as dbCommands, type Event, type Human, type Session } from "@hypr/plugin-db"; import { getPDFTheme, type ThemeName } from "./pdf-themes"; -// Re-export theme types and function for external use export { getPDFTheme, getAvailableThemes, type ThemeName, type PDFTheme } from "./pdf-themes"; export type SessionData = Session & { @@ -13,46 +12,65 @@ export type SessionData = Session & { event?: Event | null; }; -// Enhanced interface to support vector bullets interface TextSegment { text: string; - isHeader?: number; // 1, 2, 3 for h1, h2, h3 + isHeader?: number; isListItem?: boolean; listType?: 'ordered' | 'unordered'; listLevel?: number; listItemNumber?: number; - bulletType?: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle'; // New: for vector bullets + bulletType?: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle'; } -// New: List context to track state during parsing interface ListContext { type: 'ordered' | 'unordered'; level: number; - counters: number[]; // Track numbering for each level + counters: number[]; } -// TODO: -// 1. Tiptap already has structured output - toJSON(). Should be cleaner than htmlToStructuredText. -// 2. Fetch should happen outside. This file should be only do the rendering. (Ideally writeFile should be happened outside too) -// 3. exportToPDF should be composed with multiple steps. + + +const getOrderedListMarker = (counter: number, level: number): string => { + switch (level) { + case 0: + return `${counter}.`; + case 1: + return `${String.fromCharCode(96 + counter)}.`; + default: + return `${toRomanNumeral(counter)}.`; + } +}; + +const toRomanNumeral = (num: number): string => { + const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; + const numerals = ['m', 'cm', 'd', 'cd', 'c', 'xc', 'l', 'xl', 'x', 'ix', 'v', 'iv', 'i']; + + let result = ''; + for (let i = 0; i < values.length; i++) { + while (num >= values[i]) { + result += numerals[i]; + num -= values[i]; + } + } + return result; +}; const htmlToStructuredText = (html: string): TextSegment[] => { if (!html) { return []; } - // Strip out bold and italic tags while preserving content const cleanedHtml = html - .replace(/<\/?strong>/gi, '') // Remove and - .replace(/<\/?b>/gi, '') // Remove and - .replace(/<\/?em>/gi, '') // Remove and - .replace(/<\/?i>/gi, ''); // Remove and + .replace(/<\/?strong>/gi, '') + .replace(/<\/?b>/gi, '') + .replace(/<\/?em>/gi, '') + .replace(/<\/?i>/gi, ''); const tempDiv = document.createElement("div"); - tempDiv.innerHTML = cleanedHtml; // Use cleaned HTML instead of original + tempDiv.innerHTML = cleanedHtml; const segments: TextSegment[] = []; - const listStack: ListContext[] = []; // Track nested lists + const listStack: ListContext[] = []; const processNode = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -75,7 +93,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { segments.push({ text: element.textContent || "", isHeader: 3 }); break; - // Enhanced list handling case "ul": processListContainer(element, 'unordered'); break; @@ -105,16 +122,13 @@ const htmlToStructuredText = (html: string): TextSegment[] => { const processListContainer = (listElement: Element, type: 'ordered' | 'unordered') => { const level = listStack.length; - // Initialize counters array if needed const counters = [...(listStack[listStack.length - 1]?.counters || [])]; if (counters.length <= level) { counters[level] = 0; } - // Push new list context listStack.push({ type, level, counters }); - // Process list items Array.from(listElement.children).forEach((child, index) => { if (child.tagName.toLowerCase() === 'li') { if (type === 'ordered') { @@ -124,7 +138,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { } }); - // Pop list context listStack.pop(); }; @@ -134,27 +147,23 @@ const htmlToStructuredText = (html: string): TextSegment[] => { const { type, level, counters } = currentList; - // Extract text content, handling nested formatting const textContent = getListItemText(liElement); - // For unordered lists, determine bullet type based on level - // After level 2 (third level), always use square const bulletTypes = ['filled-circle', 'hollow-circle', 'square'] as const; segments.push({ text: type === 'ordered' - ? `${counters[level]}. ${textContent}` // Keep numbers as text - : textContent, // Remove bullet prefix for unordered - we'll draw it as vector + ? `${getOrderedListMarker(counters[level], level)} ${textContent}` + : textContent, isListItem: true, listType: type, listLevel: level, listItemNumber: type === 'ordered' ? counters[level] : undefined, bulletType: type === 'unordered' - ? (level <= 2 ? bulletTypes[level] : 'square') // Cap at square for level 3+ + ? (level <= 2 ? bulletTypes[level] : 'square') : undefined }); - // Process nested lists within this list item Array.from(liElement.children).forEach(child => { if (child.tagName.toLowerCase() === 'ul' || child.tagName.toLowerCase() === 'ol') { processNode(child); @@ -163,7 +172,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { }; const getListItemText = (liElement: Element): string => { - // Get only direct text content, excluding nested lists let text = ''; for (const child of liElement.childNodes) { if (child.nodeType === Node.TEXT_NODE) { @@ -190,7 +198,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { const text = childElement.textContent || ""; if (text.trim()) { - // Remove bold/italic detection - treat everything as normal text segments.push({ text }); } } @@ -201,7 +208,7 @@ const htmlToStructuredText = (html: string): TextSegment[] => { return segments; }; -// Split text into lines that fit within the PDF width + const splitTextToLines = (text: string, pdf: jsPDF, maxWidth: number): string[] => { const words = text.split(" "); const lines: string[] = []; @@ -226,7 +233,6 @@ const splitTextToLines = (text: string, pdf: jsPDF, maxWidth: number): string[] return lines; }; -// Fetch additional session data (participants and event info) const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: Human[]; event: Event | null }> => { try { const [participants, event] = await Promise.all([ @@ -240,7 +246,6 @@ const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: } }; -// Update the drawVectorBullet function signature to use a smaller default size const drawVectorBullet = ( pdf: jsPDF, bulletType: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle', @@ -253,25 +258,23 @@ const drawVectorBullet = ( const currentFillColor = pdf.getFillColor(); const currentDrawColor = pdf.getDrawColor(); - // Set bullet color from parameter pdf.setFillColor(...color); pdf.setDrawColor(...color); - pdf.setLineWidth(0.2); // Also made line width thinner + pdf.setLineWidth(0.2); - // Adjust y position to center bullet with text baseline const bulletY = y - (size / 2); switch (bulletType) { case 'filled-circle': - pdf.circle(x, bulletY, size * 0.85, 'F'); // Made circle smaller (0.85x instead of 1x) + pdf.circle(x, bulletY, size * 0.85, 'F'); break; case 'hollow-circle': - pdf.circle(x, bulletY, size * 0.85, 'S'); // Made circle smaller (0.85x instead of 1x) + pdf.circle(x, bulletY, size * 0.85, 'S'); break; case 'square': - const squareSize = size * 1.4; // Made square bigger (1.4x instead of 1.1x) + const squareSize = size * 1.4; pdf.rect( x - squareSize/2, bulletY - squareSize/2, @@ -282,8 +285,7 @@ const drawVectorBullet = ( break; case 'triangle': - const triangleSize = size * 1.15; // Keep triangle the same - // Create triangle path + const triangleSize = size * 1.15; pdf.triangle( x, bulletY - triangleSize/2, // top point x - triangleSize/2, bulletY + triangleSize/2, // bottom left @@ -293,7 +295,6 @@ const drawVectorBullet = ( break; } - // Restore previous state pdf.setFillColor(currentFillColor); pdf.setDrawColor(currentDrawColor); }; @@ -306,10 +307,8 @@ export const exportToPDF = async ( ): Promise => { const { participants, event } = await fetchSessionMetadata(session.id); - // 🎨 Get PDF styling from theme const PDF_STYLES = getPDFTheme(themeName); - // Generate filename const filename = session?.title ? `${session.title.replace(/[^a-z0-9]/gi, "_").toLowerCase()}.pdf` : `note_${new Date().toISOString().split("T")[0]}.pdf`; @@ -322,7 +321,6 @@ export const exportToPDF = async ( const maxWidth = pageWidth - (margin * 2); const lineHeight = 6; - // 🎨 Helper function to apply background color to current page const applyBackgroundColor = () => { if (PDF_STYLES.colors.background[0] !== 255 || PDF_STYLES.colors.background[1] !== 255 || @@ -332,7 +330,7 @@ export const exportToPDF = async ( } }; - // 🎨 Helper function to add new page with background + const addNewPage = () => { pdf.addPage(); applyBackgroundColor(); @@ -340,47 +338,40 @@ export const exportToPDF = async ( let yPosition = margin; - // 🎨 Apply background color to first page applyBackgroundColor(); - // Add title with text wrapping const title = session?.title || "Untitled Note"; pdf.setFontSize(16); pdf.setFont(PDF_STYLES.font, "bold"); - pdf.setTextColor(...PDF_STYLES.colors.headers); // Use headers color for title + pdf.setTextColor(...PDF_STYLES.colors.headers); - // Split title into multiple lines if it's too long const titleLines = splitTextToLines(title, pdf, maxWidth); for (const titleLine of titleLines) { pdf.text(titleLine, margin, yPosition); yPosition += lineHeight; } - yPosition += lineHeight; // Extra space after title + yPosition += lineHeight; - // Add creation date ONLY if there's no event info if (!event && session?.created_at) { pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color + pdf.setTextColor(...PDF_STYLES.colors.metadata); const createdAt = `Created: ${new Date(session.created_at).toLocaleDateString()}`; pdf.text(createdAt, margin, yPosition); yPosition += lineHeight; } - // Add event info if available if (event) { pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color - // Event name if (event.name) { pdf.text(`Event: ${event.name}`, margin, yPosition); yPosition += lineHeight; } - // Event date/time if (event.start_date) { const startDate = new Date(event.start_date); const endDate = event.end_date ? new Date(event.end_date) : null; @@ -393,7 +384,6 @@ export const exportToPDF = async ( pdf.text(dateText, margin, yPosition); yPosition += lineHeight; - // Time const timeText = endDate ? `Time: ${startDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} - ${ endDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) @@ -404,11 +394,10 @@ export const exportToPDF = async ( } } - // Add participants if available if (participants && participants.length > 0) { pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color + pdf.setTextColor(...PDF_STYLES.colors.metadata); const participantNames = participants .filter(p => p.full_name) @@ -426,75 +415,64 @@ export const exportToPDF = async ( } } - // Add attribution with clickable "Hyprnote" pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); // Use metadata color + pdf.setTextColor(...PDF_STYLES.colors.metadata); pdf.text("Summarized by ", margin, yPosition); - // Calculate width of "Summarized by " to position "Hyprnote" const madeByWidth = pdf.getTextWidth("Summarized by "); - pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); // Use hyprnote link color + pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); - // Create clickable link for Hyprnote const hyprnoteText = "Hyprnote"; pdf.textWithLink(hyprnoteText, margin + madeByWidth, yPosition, { url: "https://www.hyprnote.com" }); yPosition += lineHeight * 2; - // Add separator line - pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); // Use separator line color + pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); pdf.line(margin, yPosition, pageWidth - margin, yPosition); yPosition += lineHeight; - // Convert HTML to structured text and add content + const segments = htmlToStructuredText(session?.enhanced_memo_html || "No content available"); for (const segment of segments) { - // Check if we need a new page if (yPosition > pageHeight - margin) { - addNewPage(); // ✅ Use helper function instead of pdf.addPage() + addNewPage(); yPosition = margin; } - // Set font style based on segment properties if (segment.isHeader) { const headerSizes = { 1: 14, 2: 13, 3: 12 }; pdf.setFontSize(headerSizes[segment.isHeader as keyof typeof headerSizes]); pdf.setFont(PDF_STYLES.font, "bold"); - pdf.setTextColor(...PDF_STYLES.colors.headers); // Use headers color - yPosition += lineHeight; // Extra space before headers + pdf.setTextColor(...PDF_STYLES.colors.headers); + yPosition += lineHeight; } else { pdf.setFontSize(12); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.mainContent); // Use main content color + pdf.setTextColor(...PDF_STYLES.colors.mainContent); } - // Enhanced list item handling with vector bullets let xPosition = margin; let bulletSpace = 0; if (segment.isListItem && segment.listLevel !== undefined) { - // Base indentation + additional for each level const baseIndent = 5; const levelIndent = 8; xPosition = margin + baseIndent + (segment.listLevel * levelIndent); - // Reserve space for bullet/number - bulletSpace = segment.listType === 'ordered' ? 0 : 6; // Space for vector bullet + bulletSpace = segment.listType === 'ordered' ? 0 : 6; } - // Adjust max width for indented content and bullet space const effectiveMaxWidth = maxWidth - (xPosition - margin) - bulletSpace; const lines = splitTextToLines(segment.text, pdf, effectiveMaxWidth); for (let i = 0; i < lines.length; i++) { if (yPosition > pageHeight - margin) { - addNewPage(); // ✅ Use helper function instead of pdf.addPage() + addNewPage(); yPosition = margin; } - // Draw vector bullet for first line of unordered list items if (segment.isListItem && segment.listType === 'unordered' && segment.bulletType && @@ -505,22 +483,18 @@ export const exportToPDF = async ( xPosition + 2, yPosition - 1, 1.0, - PDF_STYLES.colors.bullets // This now comes from the selected theme + PDF_STYLES.colors.bullets ); } - // Position text after bullet space - const textXPosition = segment.isListItem && i > 0 - ? xPosition + bulletSpace + 4 // Continuation lines with extra indent - : xPosition + bulletSpace; + const textXPosition = xPosition + bulletSpace; pdf.text(lines[i], textXPosition, yPosition); yPosition += lineHeight; } - // Add extra space after headers and paragraphs if (segment.isHeader || segment.text === "\n") { - yPosition += lineHeight * 0.5; + yPosition += lineHeight * 0.25; } } diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts index d4e1794eb6..d56949474e 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -32,7 +32,7 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { default: { font: "helvetica", colors: { - background: [255, 255, 255], // Pure white + background: [255, 255, 255], // Pure white (kept as requested) mainContent: [33, 33, 33], // Dark charcoal headers: [0, 0, 0], // Black metadata: [102, 102, 102], // Medium gray @@ -45,33 +45,33 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { light: { font: "helvetica", colors: { - background: [250, 250, 250], // Off-white - mainContent: [55, 65, 81], // Gray 700 - headers: [17, 24, 39], // Gray 900 - metadata: [107, 114, 128], // Gray 500 - hyprnoteLink: [99, 102, 241], // Indigo - separatorLine: [209, 213, 219], // Gray 300 - bullets: [75, 85, 99], // Gray 600 + background: [248, 250, 252], // Very light blue + mainContent: [30, 58, 138], // Deep blue + headers: [15, 23, 42], // Navy + metadata: [100, 116, 139], // Steel blue + hyprnoteLink: [37, 99, 235], // Bright blue + separatorLine: [186, 230, 253], // Light sky blue + bullets: [59, 130, 246], // Blue } }, dark: { - font: "verdana", + font: "helvetica", colors: { - background: [17, 24, 39], // Gray 900 - mainContent: [229, 231, 235], // Gray 200 + background: [15, 15, 15], // Almost black + mainContent: [220, 220, 220], // Light gray headers: [255, 255, 255], // White - metadata: [156, 163, 175], // Gray 400 - hyprnoteLink: [147, 197, 253], // Light blue - separatorLine: [55, 65, 81], // Gray 700 - bullets: [209, 213, 219], // Gray 300 + metadata: [140, 140, 140], // Medium gray + hyprnoteLink: [96, 165, 250], // Light blue + separatorLine: [40, 40, 40], // Dark gray + bullets: [180, 180, 180], // Light gray } }, corporate: { - font: "times new roman", + font: "times", colors: { - background: [255, 255, 255], // Pure white + background: [255, 255, 255], // Pure white (kept unchanged) mainContent: [15, 23, 42], // Slate 900 headers: [30, 41, 59], // Slate 800 metadata: [100, 116, 139], // Slate 500 @@ -84,104 +84,104 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { ocean: { font: "helvetica", colors: { - background: [236, 254, 255], // Cyan 50 - mainContent: [22, 78, 99], // Cyan 800 - headers: [5, 39, 103], // Blue 900 - metadata: [14, 116, 144], // Cyan 700 - hyprnoteLink: [8, 145, 178], // Cyan 600 - separatorLine: [165, 243, 252], // Cyan 200 - bullets: [34, 211, 238], // Cyan 400 + background: [240, 249, 255], // Light ocean blue + mainContent: [7, 89, 133], // Deep ocean blue + headers: [12, 74, 110], // Ocean blue + metadata: [14, 116, 144], // Teal + hyprnoteLink: [6, 182, 212], // Cyan + separatorLine: [165, 243, 252], // Light cyan + bullets: [34, 211, 238], // Bright cyan } }, sunset: { - font: "helvetica", + font: "times", colors: { - background: [255, 251, 235], // Orange 50 - mainContent: [124, 45, 18], // Orange 900 - headers: [154, 52, 18], // Orange 800 - metadata: [194, 65, 12], // Orange 700 - hyprnoteLink: [234, 88, 12], // Orange 600 - separatorLine: [254, 215, 170], // Orange 200 - bullets: [251, 146, 60], // Orange 400 + background: [255, 247, 237], // Warm cream + mainContent: [120, 53, 15], // Dark brown + headers: [194, 65, 12], // Orange red + metadata: [156, 105, 23], // Amber + hyprnoteLink: [234, 88, 12], // Bright orange + separatorLine: [254, 215, 170], // Peach + bullets: [251, 146, 60], // Orange } }, forest: { - font: "helvetica", + font: "courier", colors: { - background: [240, 253, 244], // Green 50 - mainContent: [20, 83, 45], // Green 800 - headers: [22, 101, 52], // Green 700 - metadata: [21, 128, 61], // Green 600 - hyprnoteLink: [34, 197, 94], // Green 500 - separatorLine: [187, 247, 208], // Green 200 - bullets: [74, 222, 128], // Green 400 + background: [236, 253, 245], // Mint green + mainContent: [20, 83, 45], // Forest green + headers: [5, 46, 22], // Dark forest green + metadata: [21, 128, 61], // Green + hyprnoteLink: [34, 197, 94], // Bright green + separatorLine: [167, 243, 208], // Light green + bullets: [74, 222, 128], // Lime green } }, 'sci-fi': { - font: "helvetica", + font: "courier", colors: { - background: [6, 15, 25], // Dark blue-black - mainContent: [0, 255, 204], // Cyan green - headers: [0, 255, 255], // Pure cyan - metadata: [102, 204, 255], // Light blue - hyprnoteLink: [255, 0, 255], // Magenta + background: [3, 7, 18], // Deep space black + mainContent: [0, 255, 204], // Matrix green + headers: [0, 255, 255], // Electric cyan + metadata: [102, 204, 255], // Neon blue + hyprnoteLink: [255, 0, 255], // Electric magenta separatorLine: [0, 102, 153], // Dark blue bullets: [0, 255, 128], // Bright green } }, retro: { - font: "helvetica", + font: "courier", colors: { - background: [254, 249, 195], // Yellow 100 - mainContent: [120, 53, 15], // Orange 900 - headers: [146, 64, 14], // Orange 800 - metadata: [180, 83, 9], // Orange 700 - hyprnoteLink: [202, 138, 4], // Yellow 600 - separatorLine: [254, 240, 138], // Yellow 200 - bullets: [245, 158, 11], // Amber 500 + background: [139, 69, 19], // Dark brown (much darker!) + mainContent: [255, 248, 220], // Cream text + headers: [255, 215, 0], // Gold + metadata: [222, 184, 135], // Burlywood + hyprnoteLink: [255, 140, 0], // Dark orange + separatorLine: [160, 82, 45], // Saddle brown + bullets: [255, 165, 0], // Orange } }, spring: { - font: "helvetica", + font: "courier", colors: { - background: [247, 254, 231], // Lime 50 - mainContent: [54, 83, 20], // Lime 800 - headers: [77, 124, 15], // Lime 700 - metadata: [101, 163, 13], // Lime 600 - hyprnoteLink: [132, 204, 22], // Lime 500 - separatorLine: [217, 249, 157], // Lime 200 - bullets: [163, 230, 53], // Lime 400 + background: [254, 249, 195], // Light yellow green + mainContent: [56, 142, 60], // Green + headers: [27, 94, 32], // Dark green + metadata: [76, 175, 80], // Light green + hyprnoteLink: [139, 195, 74], // Lime + separatorLine: [200, 230, 201], // Very light green + bullets: [104, 159, 56], // Olive green } }, summer: { - font: "helvetica", + font: "courier", colors: { - background: [255, 247, 237], // Orange 25 - mainContent: [154, 52, 18], // Orange 800 - headers: [194, 65, 12], // Orange 700 - metadata: [234, 88, 12], // Orange 600 - hyprnoteLink: [251, 146, 60], // Orange 400 - separatorLine: [254, 215, 170], // Orange 200 - bullets: [255, 154, 0], // Bright orange + background: [255, 235, 59], // Bright yellow + mainContent: [191, 54, 12], // Deep red orange + headers: [213, 0, 0], // Red + metadata: [255, 87, 34], // Orange red + hyprnoteLink: [255, 152, 0], // Orange + separatorLine: [255, 193, 7], // Amber + bullets: [244, 67, 54], // Red } }, winter: { - font: "helvetica", + font: "times", colors: { - background: [241, 245, 249], // Slate 100 - mainContent: [30, 41, 59], // Slate 800 - headers: [15, 23, 42], // Slate 900 - metadata: [71, 85, 105], // Slate 600 - hyprnoteLink: [59, 130, 246], // Blue 500 - separatorLine: [203, 213, 225], // Slate 300 - bullets: [100, 116, 139], // Slate 500 + background: [233, 242, 251], // Icy blue + mainContent: [13, 71, 161], // Deep blue + headers: [25, 118, 210], // Blue + metadata: [66, 165, 245], // Light blue + hyprnoteLink: [33, 150, 243], // Sky blue + separatorLine: [187, 222, 251], // Very light blue + bullets: [100, 181, 246], // Light blue } } }; @@ -189,7 +189,6 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { return themes[themeName] || themes.default; }; -// Helper function to get all available theme names export const getAvailableThemes = (): ThemeName[] => { return [ 'default', @@ -207,7 +206,6 @@ export const getAvailableThemes = (): ThemeName[] => { ]; }; -// Helper function to get theme preview info export const getThemePreview = (themeName: ThemeName) => { const theme = getPDFTheme(themeName); return { @@ -222,17 +220,17 @@ export const getThemePreview = (themeName: ThemeName) => { const getThemeDescription = (themeName: ThemeName): string => { const descriptions: Record = { default: "Clean charcoal text on white with Helvetica", - light: "Subtle grays on off-white with Helvetica", - dark: "Light text on dark slate with Verdana", - corporate: "Professional navy on white with Times New Roman", - ocean: "Deep blues on cyan background with Optima", - sunset: "Warm oranges on cream with Palatino", - forest: "Natural greens on light green with Verdana", - 'sci-fi': "Neon cyan on dark blue-black with Helvetica", - retro: "Vintage browns on yellow with Times New Roman", - spring: "Fresh lime greens on light background with Optima", - summer: "Bright oranges on warm cream with Noteworthy", - winter: "Cool slate tones on light blue with Palatino" + light: "Deep blues on light blue with Helvetica", + dark: "Light text on deep black with Helvetica", + corporate: "Professional navy on white with Times", + ocean: "Ocean blues on light cyan with Helvetica", + sunset: "Warm browns and oranges on cream with Times", + forest: "Forest greens on mint background with Courier", + 'sci-fi': "Matrix green on space black with Courier", + retro: "Gold text on dark brown with Courier", + spring: "Fresh greens on yellow-green with Courier", + summer: "Bright reds on yellow with Courier", + winter: "Deep blues on icy background with Times" }; return descriptions[themeName] || descriptions.default; From 3257042a32cef35b4e9b5d01e341681814e1003b Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 10:47:29 -0700 Subject: [PATCH 05/12] ran tests --- .../toolbar/buttons/share-button.tsx | 5 +- .../components/toolbar/utils/pdf-export.ts | 214 +++++++-------- .../components/toolbar/utils/pdf-themes.ts | 254 +++++++++--------- 3 files changed, 239 insertions(+), 234 deletions(-) diff --git a/apps/desktop/src/components/toolbar/buttons/share-button.tsx b/apps/desktop/src/components/toolbar/buttons/share-button.tsx index afad07f634..63bc0374d4 100644 --- a/apps/desktop/src/components/toolbar/buttons/share-button.tsx +++ b/apps/desktop/src/components/toolbar/buttons/share-button.tsx @@ -337,7 +337,10 @@ function ShareButtonInNote() { - setSelectedPdfTheme(value as ThemeName)} + > diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index 3ed01750f5..c00c68ac25 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -5,7 +5,7 @@ import { jsPDF } from "jspdf"; import { commands as dbCommands, type Event, type Human, type Session } from "@hypr/plugin-db"; import { getPDFTheme, type ThemeName } from "./pdf-themes"; -export { getPDFTheme, getAvailableThemes, type ThemeName, type PDFTheme } from "./pdf-themes"; +export { getAvailableThemes, getPDFTheme, type PDFTheme, type ThemeName } from "./pdf-themes"; export type SessionData = Session & { participants?: Human[]; @@ -14,38 +14,36 @@ export type SessionData = Session & { interface TextSegment { text: string; - isHeader?: number; + isHeader?: number; isListItem?: boolean; - listType?: 'ordered' | 'unordered'; + listType?: "ordered" | "unordered"; listLevel?: number; listItemNumber?: number; - bulletType?: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle'; + bulletType?: "filled-circle" | "hollow-circle" | "square" | "triangle"; } interface ListContext { - type: 'ordered' | 'unordered'; + type: "ordered" | "unordered"; level: number; - counters: number[]; + counters: number[]; } - - const getOrderedListMarker = (counter: number, level: number): string => { switch (level) { - case 0: + case 0: return `${counter}.`; - case 1: + case 1: return `${String.fromCharCode(96 + counter)}.`; - default: + default: return `${toRomanNumeral(counter)}.`; } }; const toRomanNumeral = (num: number): string => { const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; - const numerals = ['m', 'cm', 'd', 'cd', 'c', 'xc', 'l', 'xl', 'x', 'ix', 'v', 'iv', 'i']; - - let result = ''; + const numerals = ["m", "cm", "d", "cd", "c", "xc", "l", "xl", "x", "ix", "v", "iv", "i"]; + + let result = ""; for (let i = 0; i < values.length; i++) { while (num >= values[i]) { result += numerals[i]; @@ -61,16 +59,16 @@ const htmlToStructuredText = (html: string): TextSegment[] => { } const cleanedHtml = html - .replace(/<\/?strong>/gi, '') - .replace(/<\/?b>/gi, '') - .replace(/<\/?em>/gi, '') - .replace(/<\/?i>/gi, ''); + .replace(/<\/?strong>/gi, "") + .replace(/<\/?b>/gi, "") + .replace(/<\/?em>/gi, "") + .replace(/<\/?i>/gi, ""); const tempDiv = document.createElement("div"); - tempDiv.innerHTML = cleanedHtml; + tempDiv.innerHTML = cleanedHtml; const segments: TextSegment[] = []; - const listStack: ListContext[] = []; + const listStack: ListContext[] = []; const processNode = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { @@ -92,17 +90,17 @@ const htmlToStructuredText = (html: string): TextSegment[] => { case "h3": segments.push({ text: element.textContent || "", isHeader: 3 }); break; - + case "ul": - processListContainer(element, 'unordered'); + processListContainer(element, "unordered"); break; case "ol": - processListContainer(element, 'ordered'); + processListContainer(element, "ordered"); break; case "li": processListItem(element); break; - + case "p": if (element.textContent?.trim()) { processInlineFormatting(element, segments); @@ -119,9 +117,9 @@ const htmlToStructuredText = (html: string): TextSegment[] => { } }; - const processListContainer = (listElement: Element, type: 'ordered' | 'unordered') => { + const processListContainer = (listElement: Element, type: "ordered" | "unordered") => { const level = listStack.length; - + const counters = [...(listStack[listStack.length - 1]?.counters || [])]; if (counters.length <= level) { counters[level] = 0; @@ -130,8 +128,8 @@ const htmlToStructuredText = (html: string): TextSegment[] => { listStack.push({ type, level, counters }); Array.from(listElement.children).forEach((child, index) => { - if (child.tagName.toLowerCase() === 'li') { - if (type === 'ordered') { + if (child.tagName.toLowerCase() === "li") { + if (type === "ordered") { counters[level] = index + 1; } processNode(child); @@ -143,43 +141,45 @@ const htmlToStructuredText = (html: string): TextSegment[] => { const processListItem = (liElement: Element) => { const currentList = listStack[listStack.length - 1]; - if (!currentList) return; + if (!currentList) { + return; + } const { type, level, counters } = currentList; - + const textContent = getListItemText(liElement); - - const bulletTypes = ['filled-circle', 'hollow-circle', 'square'] as const; - + + const bulletTypes = ["filled-circle", "hollow-circle", "square"] as const; + segments.push({ - text: type === 'ordered' - ? `${getOrderedListMarker(counters[level], level)} ${textContent}` - : textContent, + text: type === "ordered" + ? `${getOrderedListMarker(counters[level], level)} ${textContent}` + : textContent, isListItem: true, listType: type, listLevel: level, - listItemNumber: type === 'ordered' ? counters[level] : undefined, - bulletType: type === 'unordered' - ? (level <= 2 ? bulletTypes[level] : 'square') - : undefined + listItemNumber: type === "ordered" ? counters[level] : undefined, + bulletType: type === "unordered" + ? (level <= 2 ? bulletTypes[level] : "square") + : undefined, }); Array.from(liElement.children).forEach(child => { - if (child.tagName.toLowerCase() === 'ul' || child.tagName.toLowerCase() === 'ol') { + if (child.tagName.toLowerCase() === "ul" || child.tagName.toLowerCase() === "ol") { processNode(child); } }); }; const getListItemText = (liElement: Element): string => { - let text = ''; + let text = ""; for (const child of liElement.childNodes) { if (child.nodeType === Node.TEXT_NODE) { - text += child.textContent || ''; + text += child.textContent || ""; } else if (child.nodeType === Node.ELEMENT_NODE) { const element = child as Element; - if (!['ul', 'ol'].includes(element.tagName.toLowerCase())) { - text += element.textContent || ''; + if (!["ul", "ol"].includes(element.tagName.toLowerCase())) { + text += element.textContent || ""; } } } @@ -208,7 +208,6 @@ const htmlToStructuredText = (html: string): TextSegment[] => { return segments; }; - const splitTextToLines = (text: string, pdf: jsPDF, maxWidth: number): string[] => { const words = text.split(" "); const lines: string[] = []; @@ -247,50 +246,53 @@ const fetchSessionMetadata = async (sessionId: string): Promise<{ participants: }; const drawVectorBullet = ( - pdf: jsPDF, - bulletType: 'filled-circle' | 'hollow-circle' | 'square' | 'triangle', - x: number, - y: number, + pdf: jsPDF, + bulletType: "filled-circle" | "hollow-circle" | "square" | "triangle", + x: number, + y: number, size: number = 1.0, - color: readonly [number, number, number] = [50, 50, 50] // Accept color parameter + color: readonly [number, number, number] = [50, 50, 50], // Accept color parameter ) => { // Save current state const currentFillColor = pdf.getFillColor(); const currentDrawColor = pdf.getDrawColor(); - + pdf.setFillColor(...color); pdf.setDrawColor(...color); - pdf.setLineWidth(0.2); + pdf.setLineWidth(0.2); const bulletY = y - (size / 2); switch (bulletType) { - case 'filled-circle': - pdf.circle(x, bulletY, size * 0.85, 'F'); + case "filled-circle": + pdf.circle(x, bulletY, size * 0.85, "F"); break; - - case 'hollow-circle': - pdf.circle(x, bulletY, size * 0.85, 'S'); + + case "hollow-circle": + pdf.circle(x, bulletY, size * 0.85, "S"); break; - - case 'square': - const squareSize = size * 1.4; + + case "square": + const squareSize = size * 1.4; pdf.rect( - x - squareSize/2, - bulletY - squareSize/2, - squareSize, - squareSize, - 'F' + x - squareSize / 2, + bulletY - squareSize / 2, + squareSize, + squareSize, + "F", ); break; - - case 'triangle': - const triangleSize = size * 1.15; + + case "triangle": + const triangleSize = size * 1.15; pdf.triangle( - x, bulletY - triangleSize/2, // top point - x - triangleSize/2, bulletY + triangleSize/2, // bottom left - x + triangleSize/2, bulletY + triangleSize/2, // bottom right - 'F' + x, + bulletY - triangleSize / 2, // top point + x - triangleSize / 2, + bulletY + triangleSize / 2, // bottom left + x + triangleSize / 2, + bulletY + triangleSize / 2, // bottom right + "F", ); break; } @@ -299,11 +301,9 @@ const drawVectorBullet = ( pdf.setDrawColor(currentDrawColor); }; - - export const exportToPDF = async ( - session: SessionData, - themeName: ThemeName = 'default' + session: SessionData, + themeName: ThemeName = "default", ): Promise => { const { participants, event } = await fetchSessionMetadata(session.id); @@ -322,15 +322,16 @@ export const exportToPDF = async ( const lineHeight = 6; const applyBackgroundColor = () => { - if (PDF_STYLES.colors.background[0] !== 255 || - PDF_STYLES.colors.background[1] !== 255 || - PDF_STYLES.colors.background[2] !== 255) { + if ( + PDF_STYLES.colors.background[0] !== 255 + || PDF_STYLES.colors.background[1] !== 255 + || PDF_STYLES.colors.background[2] !== 255 + ) { pdf.setFillColor(...PDF_STYLES.colors.background); - pdf.rect(0, 0, pageWidth, pageHeight, 'F'); + pdf.rect(0, 0, pageWidth, pageHeight, "F"); } }; - const addNewPage = () => { pdf.addPage(); applyBackgroundColor(); @@ -343,7 +344,7 @@ export const exportToPDF = async ( const title = session?.title || "Untitled Note"; pdf.setFontSize(16); pdf.setFont(PDF_STYLES.font, "bold"); - pdf.setTextColor(...PDF_STYLES.colors.headers); + pdf.setTextColor(...PDF_STYLES.colors.headers); const titleLines = splitTextToLines(title, pdf, maxWidth); @@ -351,12 +352,12 @@ export const exportToPDF = async ( pdf.text(titleLine, margin, yPosition); yPosition += lineHeight; } - yPosition += lineHeight; + yPosition += lineHeight; if (!event && session?.created_at) { pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); + pdf.setTextColor(...PDF_STYLES.colors.metadata); const createdAt = `Created: ${new Date(session.created_at).toLocaleDateString()}`; pdf.text(createdAt, margin, yPosition); yPosition += lineHeight; @@ -397,7 +398,7 @@ export const exportToPDF = async ( if (participants && participants.length > 0) { pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); + pdf.setTextColor(...PDF_STYLES.colors.metadata); const participantNames = participants .filter(p => p.full_name) @@ -417,27 +418,26 @@ export const exportToPDF = async ( pdf.setFontSize(10); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.metadata); + pdf.setTextColor(...PDF_STYLES.colors.metadata); pdf.text("Summarized by ", margin, yPosition); const madeByWidth = pdf.getTextWidth("Summarized by "); - pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); + pdf.setTextColor(...PDF_STYLES.colors.hyprnoteLink); const hyprnoteText = "Hyprnote"; pdf.textWithLink(hyprnoteText, margin + madeByWidth, yPosition, { url: "https://www.hyprnote.com" }); yPosition += lineHeight * 2; - pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); + pdf.setDrawColor(...PDF_STYLES.colors.separatorLine); pdf.line(margin, yPosition, pageWidth - margin, yPosition); yPosition += lineHeight; - const segments = htmlToStructuredText(session?.enhanced_memo_html || "No content available"); for (const segment of segments) { if (yPosition > pageHeight - margin) { - addNewPage(); + addNewPage(); yPosition = margin; } @@ -445,23 +445,23 @@ export const exportToPDF = async ( const headerSizes = { 1: 14, 2: 13, 3: 12 }; pdf.setFontSize(headerSizes[segment.isHeader as keyof typeof headerSizes]); pdf.setFont(PDF_STYLES.font, "bold"); - pdf.setTextColor(...PDF_STYLES.colors.headers); - yPosition += lineHeight; + pdf.setTextColor(...PDF_STYLES.colors.headers); + yPosition += lineHeight; } else { pdf.setFontSize(12); pdf.setFont(PDF_STYLES.font, "normal"); - pdf.setTextColor(...PDF_STYLES.colors.mainContent); + pdf.setTextColor(...PDF_STYLES.colors.mainContent); } let xPosition = margin; let bulletSpace = 0; - + if (segment.isListItem && segment.listLevel !== undefined) { const baseIndent = 5; const levelIndent = 8; xPosition = margin + baseIndent + (segment.listLevel * levelIndent); - - bulletSpace = segment.listType === 'ordered' ? 0 : 6; + + bulletSpace = segment.listType === "ordered" ? 0 : 6; } const effectiveMaxWidth = maxWidth - (xPosition - margin) - bulletSpace; @@ -469,25 +469,27 @@ export const exportToPDF = async ( for (let i = 0; i < lines.length; i++) { if (yPosition > pageHeight - margin) { - addNewPage(); + addNewPage(); yPosition = margin; } - if (segment.isListItem && - segment.listType === 'unordered' && - segment.bulletType && - i === 0) { + if ( + segment.isListItem + && segment.listType === "unordered" + && segment.bulletType + && i === 0 + ) { drawVectorBullet( - pdf, - segment.bulletType, + pdf, + segment.bulletType, xPosition + 2, yPosition - 1, 1.0, - PDF_STYLES.colors.bullets + PDF_STYLES.colors.bullets, ); } - const textXPosition = xPosition + bulletSpace; + const textXPosition = xPosition + bulletSpace; pdf.text(lines[i], textXPosition, yPosition); yPosition += lineHeight; diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts index d56949474e..ebe95f19aa 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -1,18 +1,18 @@ // 🎨 PDF THEME SYSTEM -export type ThemeName = - | 'default' - | 'light' - | 'dark' - | 'corporate' - | 'ocean' - | 'sunset' - | 'forest' - | 'sci-fi' - | 'retro' - | 'spring' - | 'summer' - | 'winter'; +export type ThemeName = + | "default" + | "light" + | "dark" + | "corporate" + | "ocean" + | "sunset" + | "forest" + | "sci-fi" + | "retro" + | "spring" + | "summer" + | "winter"; export interface PDFTheme { font: string; @@ -32,158 +32,158 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { default: { font: "helvetica", colors: { - background: [255, 255, 255], // Pure white (kept as requested) - mainContent: [33, 33, 33], // Dark charcoal - headers: [0, 0, 0], // Black - metadata: [102, 102, 102], // Medium gray - hyprnoteLink: [59, 130, 246], // Blue - separatorLine: [229, 229, 229], // Light gray - bullets: [75, 85, 99], // Slate gray - } + background: [255, 255, 255], // Pure white (kept as requested) + mainContent: [33, 33, 33], // Dark charcoal + headers: [0, 0, 0], // Black + metadata: [102, 102, 102], // Medium gray + hyprnoteLink: [59, 130, 246], // Blue + separatorLine: [229, 229, 229], // Light gray + bullets: [75, 85, 99], // Slate gray + }, }, light: { font: "helvetica", colors: { - background: [248, 250, 252], // Very light blue - mainContent: [30, 58, 138], // Deep blue - headers: [15, 23, 42], // Navy - metadata: [100, 116, 139], // Steel blue - hyprnoteLink: [37, 99, 235], // Bright blue - separatorLine: [186, 230, 253], // Light sky blue - bullets: [59, 130, 246], // Blue - } + background: [248, 250, 252], // Very light blue + mainContent: [30, 58, 138], // Deep blue + headers: [15, 23, 42], // Navy + metadata: [100, 116, 139], // Steel blue + hyprnoteLink: [37, 99, 235], // Bright blue + separatorLine: [186, 230, 253], // Light sky blue + bullets: [59, 130, 246], // Blue + }, }, dark: { font: "helvetica", colors: { - background: [15, 15, 15], // Almost black - mainContent: [220, 220, 220], // Light gray - headers: [255, 255, 255], // White - metadata: [140, 140, 140], // Medium gray - hyprnoteLink: [96, 165, 250], // Light blue - separatorLine: [40, 40, 40], // Dark gray - bullets: [180, 180, 180], // Light gray - } + background: [15, 15, 15], // Almost black + mainContent: [220, 220, 220], // Light gray + headers: [255, 255, 255], // White + metadata: [140, 140, 140], // Medium gray + hyprnoteLink: [96, 165, 250], // Light blue + separatorLine: [40, 40, 40], // Dark gray + bullets: [180, 180, 180], // Light gray + }, }, corporate: { font: "times", colors: { - background: [255, 255, 255], // Pure white (kept unchanged) - mainContent: [15, 23, 42], // Slate 900 - headers: [30, 41, 59], // Slate 800 - metadata: [100, 116, 139], // Slate 500 - hyprnoteLink: [30, 64, 175], // Professional blue - separatorLine: [203, 213, 225], // Slate 300 - bullets: [51, 65, 85], // Slate 700 - } + background: [255, 255, 255], // Pure white (kept unchanged) + mainContent: [15, 23, 42], // Slate 900 + headers: [30, 41, 59], // Slate 800 + metadata: [100, 116, 139], // Slate 500 + hyprnoteLink: [30, 64, 175], // Professional blue + separatorLine: [203, 213, 225], // Slate 300 + bullets: [51, 65, 85], // Slate 700 + }, }, ocean: { font: "helvetica", colors: { - background: [240, 249, 255], // Light ocean blue - mainContent: [7, 89, 133], // Deep ocean blue - headers: [12, 74, 110], // Ocean blue - metadata: [14, 116, 144], // Teal - hyprnoteLink: [6, 182, 212], // Cyan - separatorLine: [165, 243, 252], // Light cyan - bullets: [34, 211, 238], // Bright cyan - } + background: [240, 249, 255], // Light ocean blue + mainContent: [7, 89, 133], // Deep ocean blue + headers: [12, 74, 110], // Ocean blue + metadata: [14, 116, 144], // Teal + hyprnoteLink: [6, 182, 212], // Cyan + separatorLine: [165, 243, 252], // Light cyan + bullets: [34, 211, 238], // Bright cyan + }, }, sunset: { font: "times", colors: { - background: [255, 247, 237], // Warm cream - mainContent: [120, 53, 15], // Dark brown - headers: [194, 65, 12], // Orange red - metadata: [156, 105, 23], // Amber - hyprnoteLink: [234, 88, 12], // Bright orange - separatorLine: [254, 215, 170], // Peach - bullets: [251, 146, 60], // Orange - } + background: [255, 247, 237], // Warm cream + mainContent: [120, 53, 15], // Dark brown + headers: [194, 65, 12], // Orange red + metadata: [156, 105, 23], // Amber + hyprnoteLink: [234, 88, 12], // Bright orange + separatorLine: [254, 215, 170], // Peach + bullets: [251, 146, 60], // Orange + }, }, forest: { font: "courier", colors: { - background: [236, 253, 245], // Mint green - mainContent: [20, 83, 45], // Forest green - headers: [5, 46, 22], // Dark forest green - metadata: [21, 128, 61], // Green - hyprnoteLink: [34, 197, 94], // Bright green - separatorLine: [167, 243, 208], // Light green - bullets: [74, 222, 128], // Lime green - } + background: [236, 253, 245], // Mint green + mainContent: [20, 83, 45], // Forest green + headers: [5, 46, 22], // Dark forest green + metadata: [21, 128, 61], // Green + hyprnoteLink: [34, 197, 94], // Bright green + separatorLine: [167, 243, 208], // Light green + bullets: [74, 222, 128], // Lime green + }, }, - 'sci-fi': { + "sci-fi": { font: "courier", colors: { - background: [3, 7, 18], // Deep space black - mainContent: [0, 255, 204], // Matrix green - headers: [0, 255, 255], // Electric cyan - metadata: [102, 204, 255], // Neon blue - hyprnoteLink: [255, 0, 255], // Electric magenta - separatorLine: [0, 102, 153], // Dark blue - bullets: [0, 255, 128], // Bright green - } + background: [3, 7, 18], // Deep space black + mainContent: [0, 255, 204], // Matrix green + headers: [0, 255, 255], // Electric cyan + metadata: [102, 204, 255], // Neon blue + hyprnoteLink: [255, 0, 255], // Electric magenta + separatorLine: [0, 102, 153], // Dark blue + bullets: [0, 255, 128], // Bright green + }, }, retro: { font: "courier", colors: { - background: [139, 69, 19], // Dark brown (much darker!) - mainContent: [255, 248, 220], // Cream text - headers: [255, 215, 0], // Gold - metadata: [222, 184, 135], // Burlywood - hyprnoteLink: [255, 140, 0], // Dark orange - separatorLine: [160, 82, 45], // Saddle brown - bullets: [255, 165, 0], // Orange - } + background: [139, 69, 19], // Dark brown (much darker!) + mainContent: [255, 248, 220], // Cream text + headers: [255, 215, 0], // Gold + metadata: [222, 184, 135], // Burlywood + hyprnoteLink: [255, 140, 0], // Dark orange + separatorLine: [160, 82, 45], // Saddle brown + bullets: [255, 165, 0], // Orange + }, }, spring: { font: "courier", colors: { - background: [254, 249, 195], // Light yellow green - mainContent: [56, 142, 60], // Green - headers: [27, 94, 32], // Dark green - metadata: [76, 175, 80], // Light green - hyprnoteLink: [139, 195, 74], // Lime - separatorLine: [200, 230, 201], // Very light green - bullets: [104, 159, 56], // Olive green - } + background: [254, 249, 195], // Light yellow green + mainContent: [56, 142, 60], // Green + headers: [27, 94, 32], // Dark green + metadata: [76, 175, 80], // Light green + hyprnoteLink: [139, 195, 74], // Lime + separatorLine: [200, 230, 201], // Very light green + bullets: [104, 159, 56], // Olive green + }, }, summer: { font: "courier", colors: { - background: [255, 235, 59], // Bright yellow - mainContent: [191, 54, 12], // Deep red orange - headers: [213, 0, 0], // Red - metadata: [255, 87, 34], // Orange red - hyprnoteLink: [255, 152, 0], // Orange - separatorLine: [255, 193, 7], // Amber - bullets: [244, 67, 54], // Red - } + background: [255, 235, 59], // Bright yellow + mainContent: [191, 54, 12], // Deep red orange + headers: [213, 0, 0], // Red + metadata: [255, 87, 34], // Orange red + hyprnoteLink: [255, 152, 0], // Orange + separatorLine: [255, 193, 7], // Amber + bullets: [244, 67, 54], // Red + }, }, winter: { font: "times", colors: { - background: [233, 242, 251], // Icy blue - mainContent: [13, 71, 161], // Deep blue - headers: [25, 118, 210], // Blue - metadata: [66, 165, 245], // Light blue - hyprnoteLink: [33, 150, 243], // Sky blue - separatorLine: [187, 222, 251], // Very light blue - bullets: [100, 181, 246], // Light blue - } - } + background: [233, 242, 251], // Icy blue + mainContent: [13, 71, 161], // Deep blue + headers: [25, 118, 210], // Blue + metadata: [66, 165, 245], // Light blue + hyprnoteLink: [33, 150, 243], // Sky blue + separatorLine: [187, 222, 251], // Very light blue + bullets: [100, 181, 246], // Light blue + }, + }, }; return themes[themeName] || themes.default; @@ -191,18 +191,18 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { export const getAvailableThemes = (): ThemeName[] => { return [ - 'default', - 'light', - 'dark', - 'corporate', - 'ocean', - 'sunset', - 'forest', - 'sci-fi', - 'retro', - 'spring', - 'summer', - 'winter' + "default", + "light", + "dark", + "corporate", + "ocean", + "sunset", + "forest", + "sci-fi", + "retro", + "spring", + "summer", + "winter", ]; }; @@ -213,7 +213,7 @@ export const getThemePreview = (themeName: ThemeName) => { font: theme.font, primaryColor: theme.colors.headers, backgroundColor: theme.colors.background, - description: getThemeDescription(themeName) + description: getThemeDescription(themeName), }; }; @@ -226,12 +226,12 @@ const getThemeDescription = (themeName: ThemeName): string => { ocean: "Ocean blues on light cyan with Helvetica", sunset: "Warm browns and oranges on cream with Times", forest: "Forest greens on mint background with Courier", - 'sci-fi': "Matrix green on space black with Courier", + "sci-fi": "Matrix green on space black with Courier", retro: "Gold text on dark brown with Courier", spring: "Fresh greens on yellow-green with Courier", summer: "Bright reds on yellow with Courier", - winter: "Deep blues on icy background with Times" + winter: "Deep blues on icy background with Times", }; - + return descriptions[themeName] || descriptions.default; }; From 2b4b90c38256955356a8bfb4bce250fa3bb55662 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 11:01:57 -0700 Subject: [PATCH 06/12] ran tests --- .../src/components/toolbar/utils/pdf-themes.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts index ebe95f19aa..b467ff161c 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -8,7 +8,7 @@ export type ThemeName = | "ocean" | "sunset" | "forest" - | "sci-fi" + | "cyberpunk" | "retro" | "spring" | "summer" @@ -108,7 +108,7 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { }, forest: { - font: "courier", + font: "helvetica", colors: { background: [236, 253, 245], // Mint green mainContent: [20, 83, 45], // Forest green @@ -120,8 +120,8 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { }, }, - "sci-fi": { - font: "courier", + cyberpunk: { + font: "helvetica", colors: { background: [3, 7, 18], // Deep space black mainContent: [0, 255, 204], // Matrix green @@ -160,7 +160,7 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { }, summer: { - font: "courier", + font: "helvetica", colors: { background: [255, 235, 59], // Bright yellow mainContent: [191, 54, 12], // Deep red orange @@ -198,7 +198,7 @@ export const getAvailableThemes = (): ThemeName[] => { "ocean", "sunset", "forest", - "sci-fi", + "cyberpunk", "retro", "spring", "summer", @@ -226,7 +226,7 @@ const getThemeDescription = (themeName: ThemeName): string => { ocean: "Ocean blues on light cyan with Helvetica", sunset: "Warm browns and oranges on cream with Times", forest: "Forest greens on mint background with Courier", - "sci-fi": "Matrix green on space black with Courier", + cyberpunk: "Matrix green on space black with Courier", retro: "Gold text on dark brown with Courier", spring: "Fresh greens on yellow-green with Courier", summer: "Bright reds on yellow with Courier", From d3bf8f23db26b2f181d4601be70051ad054decb7 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 11:03:18 -0700 Subject: [PATCH 07/12] commented out --- apps/desktop/src/components/toolbar/utils/pdf-themes.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts index b467ff161c..dfcbb362bc 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -1,5 +1,3 @@ -// 🎨 PDF THEME SYSTEM - export type ThemeName = | "default" | "light" From b1469c7e7b910aeaedc9c8a9acf733c9f39769e0 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 11:16:28 -0700 Subject: [PATCH 08/12] ran tests --- .../src/components/organization-profile/recent-notes.tsx | 2 +- apps/desktop/src/locales/en/messages.po | 8 ++++++-- apps/desktop/src/locales/ko/messages.po | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/organization-profile/recent-notes.tsx b/apps/desktop/src/components/organization-profile/recent-notes.tsx index 90dcd7d784..c72165e18c 100644 --- a/apps/desktop/src/components/organization-profile/recent-notes.tsx +++ b/apps/desktop/src/components/organization-profile/recent-notes.tsx @@ -67,7 +67,7 @@ export function RecentNotes({ organizationId, members }: RecentNotesProps) { ) : (

- No recent notes for this organization + No recent notes with this organization

)}
diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index 70122f0450..45c98b5fff 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -1124,8 +1124,12 @@ msgid "No past notes with this contact" msgstr "No past notes with this contact" #: src/components/organization-profile/recent-notes.tsx:70 -msgid "No recent notes for this organization" -msgstr "No recent notes for this organization" +#~ msgid "No recent notes for this organization" +#~ msgstr "No recent notes for this organization" + +#: src/components/organization-profile/recent-notes.tsx:70 +msgid "No recent notes with this organization" +msgstr "No recent notes with this organization" #: src/components/settings/components/ai/stt-view.tsx:248 #~ msgid "No speech-to-text models available or failed to load." diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 51b7401b73..a27d5768d6 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -1124,7 +1124,11 @@ msgid "No past notes with this contact" msgstr "" #: src/components/organization-profile/recent-notes.tsx:70 -msgid "No recent notes for this organization" +#~ msgid "No recent notes for this organization" +#~ msgstr "" + +#: src/components/organization-profile/recent-notes.tsx:70 +msgid "No recent notes with this organization" msgstr "" #: src/components/settings/components/ai/stt-view.tsx:248 From ac1e79c4712840fc83d76f042928b2e9cfdc01d2 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 13:26:39 -0700 Subject: [PATCH 09/12] moved share button --- .../editor-area/note-header/chips/index.tsx | 41 +- .../note-header/chips/share-chip.tsx | 63 ++ .../note-header/share-button-header.tsx | 721 ++++++++++++++++++ .../components/toolbar/bars/main-toolbar.tsx | 4 +- 4 files changed, 809 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx create mode 100644 apps/desktop/src/components/editor-area/note-header/share-button-header.tsx diff --git a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx index 7f0a368541..0075bde4ec 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx @@ -1,27 +1,30 @@ -import { useRightPanel } from "@/contexts"; -import { MessageCircleMore } from "lucide-react"; +// Temporarily commented out chat functionality +// import { useRightPanel } from "@/contexts"; +// import { MessageCircleMore } from "lucide-react"; import { EventChip } from "./event-chip"; import { ParticipantsChip } from "./participants-chip"; import { PastNotesChip } from "./past-notes-chip"; +import { ShareChip } from "./share-chip"; import { TagChip } from "./tag-chip"; -function StartChatButton({ isVeryNarrow }: { isVeryNarrow: boolean }) { - const { togglePanel } = useRightPanel(); +// Temporarily commented out StartChatButton +// function StartChatButton({ isVeryNarrow }: { isVeryNarrow: boolean }) { +// const { togglePanel } = useRightPanel(); - const handleChatClick = () => { - togglePanel("chat"); - }; +// const handleChatClick = () => { +// togglePanel("chat"); +// }; - return ( - - ); -} +// return ( +// +// ); +// } export default function NoteHeaderChips({ sessionId, @@ -45,7 +48,9 @@ export default function NoteHeaderChips({ - + + {/* Temporarily commented out chat button */} + {/* */} ); diff --git a/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx new file mode 100644 index 0000000000..c3c50b2fac --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx @@ -0,0 +1,63 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover"; +import { Share2, TextSelect } from "lucide-react"; +import { useState } from "react"; +import { SharePopoverContent, useShareLogic } from "../share-button-header"; + +interface ShareChipProps { + sessionId: string; + isVeryNarrow?: boolean; + isNarrow?: boolean; +} + +export function ShareChip({ sessionId, isVeryNarrow = false, isNarrow = false }: ShareChipProps) { + const [open, setOpen] = useState(false); + const { hasEnhancedNote, handleOpenStateChange } = useShareLogic(); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (hasEnhancedNote) { + handleOpenStateChange(newOpen); + } + }; + + return ( + + + + + + {hasEnhancedNote ? : } + + + ); +} + +function SharePlaceholderContent() { + return ( +
+
Share Enhanced Note
+
+
+ +
+

+ Enhanced Note Required +

+

+ Complete your meeting to generate an enhanced note, then share it via PDF, email, Obsidian, and more. +

+
+
+ ); +} diff --git a/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx b/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx new file mode 100644 index 0000000000..aa1467c326 --- /dev/null +++ b/apps/desktop/src/components/editor-area/note-header/share-button-header.tsx @@ -0,0 +1,721 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; +import { join } from "@tauri-apps/api/path"; +import { message } from "@tauri-apps/plugin-dialog"; +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { openPath, openUrl } from "@tauri-apps/plugin-opener"; +import { BookText, Check, ChevronDown, ChevronUp, Copy, FileText, HelpCircle, Mail } from "lucide-react"; +import { useState } from "react"; + +import { useHypr } from "@/contexts"; +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { Session, Tag } from "@hypr/plugin-db"; +import { commands as dbCommands } from "@hypr/plugin-db"; +import { + client, + commands as obsidianCommands, + getVault, + patchVaultByFilename, + putVaultByFilename, +} from "@hypr/plugin-obsidian"; +import { html2md } from "@hypr/tiptap/shared"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hypr/ui/components/ui/select"; +import { useSession } from "@hypr/utils/contexts"; +import { exportToPDF, getAvailableThemes, type ThemeName } from "../../toolbar/utils/pdf-export"; + +interface DirectAction { + id: "copy"; + title: string; + icon: React.ReactNode; + description: string; +} + +interface ExportCard { + id: "pdf" | "email" | "obsidian"; + title: string; + icon: React.ReactNode; + description: string; + docsUrl: string; +} + +interface ExportResult { + type: "copy" | "pdf" | "email" | "obsidian"; + path?: string; + url?: string; + success?: boolean; +} + +interface ObsidianFolder { + value: string; + label: string; +} + +const exportHandlers = { + copy: async (session: Session): Promise => { + try { + let textToCopy = ""; + + if (session.enhanced_memo_html) { + textToCopy = html2md(session.enhanced_memo_html); + } else if (session.raw_memo_html) { + textToCopy = html2md(session.raw_memo_html); + } else { + textToCopy = session.title || "No content available"; + } + + await navigator.clipboard.writeText(textToCopy); + return { type: "copy", success: true }; + } catch (error) { + console.error("Failed to copy to clipboard:", error); + throw new Error("Failed to copy note to clipboard"); + } + }, + + pdf: async (session: Session, theme: ThemeName = "default"): Promise => { + const path = await exportToPDF(session, theme); + if (path) { + await message(`Meeting summary saved to your 'Downloads' folder ("${path}")`); + } + return { type: "pdf", path }; + }, + + email: async ( + session: Session, + sessionParticipants?: Array<{ full_name: string | null; email: string | null }>, + ): Promise => { + let bodyContent = "Here is the meeting summary: \n\n"; + + if (session.enhanced_memo_html) { + bodyContent += html2md(session.enhanced_memo_html); + } else if (session.raw_memo_html) { + bodyContent += html2md(session.raw_memo_html); + } else { + bodyContent += "No content available"; + } + + if (sessionParticipants && sessionParticipants.length > 0) { + const participantNames = sessionParticipants + .filter(p => p.full_name) + .map(p => p.full_name) + .join(", "); + + if (participantNames) { + bodyContent += `\n\nMeeting Participants: ${participantNames}`; + } + } + + bodyContent += "\n\nSent with Hyprnote (www.hyprnote.com)\n\n"; + + const participantEmails = sessionParticipants + ?.filter(participant => participant.email && participant.email.trim()) + ?.map(participant => participant.email!) + ?.join(",") || ""; + + const subject = encodeURIComponent(session.title); + const body = encodeURIComponent(bodyContent); + + const to = participantEmails ? `&to=${encodeURIComponent(participantEmails)}` : ""; + + const url = `mailto:?subject=${subject}&body=${body}${to}`; + return { type: "email", url }; + }, + + obsidian: async ( + session: Session, + selectedFolder: string, + sessionTags: Tag[] | undefined, + sessionParticipants: Array<{ full_name: string | null }> | undefined, + includeTranscript: boolean = false, + ): Promise => { + const [baseFolder, apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getBaseFolder(), + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + + client.setConfig({ + fetch: tauriFetch, + auth: apiKey!, + baseUrl: baseUrl!, + }); + + const filename = `${session.title.replace(/[^a-zA-Z0-9 ]/g, "").replace(/\s+/g, "-")}.md`; + + let finalPath: string; + if (selectedFolder === "default") { + finalPath = baseFolder ? await join(baseFolder!, filename) : filename; + } else { + finalPath = await join(selectedFolder, filename); + } + + let convertedMarkdown = session.enhanced_memo_html ? html2md(session.enhanced_memo_html) : ""; + + // Add transcript if requested + if (includeTranscript && session.words && session.words.length > 0) { + const transcriptText = convertWordsToTranscript(session.words); + if (transcriptText) { + convertedMarkdown += "\n\n---\n\n## Full Transcript\n\n" + transcriptText; + } + } + + await putVaultByFilename({ + client, + path: { filename: finalPath }, + body: convertedMarkdown, + bodySerializer: null, + headers: { + "Content-Type": "text/markdown", + }, + }); + + // Update frontmatter + const targets = [ + { target: "date", value: session?.created_at ?? new Date().toISOString() }, + ...(sessionTags && sessionTags.length > 0 + ? [{ + target: "tags", + value: sessionTags.map(tag => tag.name), + }] + : []), + ...(sessionParticipants && sessionParticipants.filter(participant => participant.full_name).length > 0 + ? [{ + target: "attendees", + value: sessionParticipants.map(participant => participant.full_name).filter(Boolean), + }] + : []), + ]; + + for (const { target, value } of targets) { + await patchVaultByFilename({ + client, + path: { filename: finalPath }, + headers: { + "Operation": "replace", + "Target-Type": "frontmatter", + "Target": target, + "Create-Target-If-Missing": "true", + }, + body: value as any, + }); + } + + const url = await obsidianCommands.getDeepLinkUrl(finalPath); + return { type: "obsidian", url }; + }, +}; + +function getDefaultSelectedFolder(folders: ObsidianFolder[], sessionTags: Tag[]): string { + if (!sessionTags || sessionTags.length === 0) { + return "default"; + } + + const tagNames = sessionTags.map((tag: Tag) => tag.name.toLowerCase()); + + for (const tagName of tagNames) { + const exactMatch = folders.find(folder => folder.value.toLowerCase() === tagName); + if (exactMatch) { + return exactMatch.value; + } + } + + for (const tagName of tagNames) { + const partialMatch = folders.find(folder => folder.value.toLowerCase().includes(tagName)); + if (partialMatch) { + return partialMatch.value; + } + } + + return "default"; +} + +async function fetchObsidianFolders(): Promise { + try { + const [apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + + client.setConfig({ + fetch: tauriFetch, + auth: apiKey!, + baseUrl: baseUrl!, + }); + + const response = await getVault({ client }); + + const folders = response.data?.files + ?.filter(item => item.endsWith("/")) + ?.map(folder => ({ + value: folder.slice(0, -1), + label: folder.slice(0, -1), + })) || []; + + return [ + { value: "default", label: "Default (Root)" }, + ...folders, + ]; + } catch (error) { + console.error("Failed to fetch Obsidian folders:", error); + + obsidianCommands.getDeepLinkUrl("").then((url) => { + openUrl(url); + }).catch((error) => { + console.error("Failed to open Obsidian:", error); + }); + + return [{ value: "default", label: "Default (Root)" }]; + } +} + +function convertWordsToTranscript(words: any[]): string { + if (!words || words.length === 0) { + return ""; + } + + const lines: string[] = []; + let currentSpeaker: any = null; + let currentText = ""; + + for (const word of words) { + const isSameSpeaker = (!currentSpeaker && !word.speaker) + || (currentSpeaker?.type === "unassigned" && word.speaker?.type === "unassigned" + && currentSpeaker.value?.index === word.speaker.value?.index) + || (currentSpeaker?.type === "assigned" && word.speaker?.type === "assigned" + && currentSpeaker.value?.id === word.speaker.value?.id); + + if (!isSameSpeaker) { + if (currentText.trim()) { + const speakerLabel = getSpeakerLabel(currentSpeaker); + lines.push(`[${speakerLabel}]\n${currentText.trim()}`); + } + + currentSpeaker = word.speaker; + currentText = word.text; + } else { + currentText += " " + word.text; + } + } + + if (currentText.trim()) { + const speakerLabel = getSpeakerLabel(currentSpeaker); + lines.push(`[${speakerLabel}]\n${currentText.trim()}`); + } + + return lines.join("\n\n"); +} + +function getSpeakerLabel(speaker: any): string { + if (!speaker) { + return "Speaker"; + } + + if (speaker.type === "assigned" && speaker.value?.label) { + return speaker.value.label; + } + + if (speaker.type === "unassigned" && typeof speaker.value?.index === "number") { + if (speaker.value.index === 0) { + return "You"; + } + return `Speaker ${speaker.value.index}`; + } + + return "Speaker"; +} + +// Custom hook for share functionality +export function useShareLogic() { + const { userId } = useHypr(); + const param = useParams({ from: "/app/note/$id", shouldThrow: true }); + const session = useSession(param.id, (s) => s.session); + + const [expandedId, setExpandedId] = useState(null); + const [selectedObsidianFolder, setSelectedObsidianFolder] = useState("default"); + const [selectedPdfTheme, setSelectedPdfTheme] = useState("default"); + const [includeTranscript, setIncludeTranscript] = useState(false); + const [copySuccess, setCopySuccess] = useState(false); + const hasEnhancedNote = !!session?.enhanced_memo_html; + + const isObsidianConfigured = useQuery({ + queryKey: ["integration", "obsidian", "enabled"], + queryFn: async () => { + const [enabled, apiKey, baseUrl] = await Promise.all([ + obsidianCommands.getEnabled(), + obsidianCommands.getApiKey(), + obsidianCommands.getBaseUrl(), + ]); + return enabled && apiKey && baseUrl; + }, + }); + + const obsidianFolders = useQuery({ + queryKey: ["obsidian", "folders"], + queryFn: () => fetchObsidianFolders(), + enabled: false, + }); + + const sessionTags = useQuery({ + queryKey: ["session", "tags", param.id], + queryFn: () => dbCommands.listSessionTags(param.id), + enabled: false, + staleTime: 5 * 60 * 1000, + }); + + const sessionParticipants = useQuery({ + queryKey: ["session", "participants", param.id], + queryFn: () => dbCommands.sessionListParticipants(param.id), + enabled: false, + staleTime: 5 * 60 * 1000, + }); + + const directActions: DirectAction[] = [ + { + id: "copy", + title: "Copy Note", + icon: , + description: "", + }, + ]; + + const exportOptions: ExportCard[] = [ + { + id: "pdf", + title: "PDF", + icon: , + description: "Save as PDF document", + docsUrl: "https://docs.hyprnote.com/sharing#pdf", + }, + { + id: "email", + title: "Email", + icon: , + description: "Share via email", + docsUrl: "https://docs.hyprnote.com/sharing#email", + }, + isObsidianConfigured.data + ? { + id: "obsidian", + title: "Obsidian", + icon: , + description: "Export to Obsidian", + docsUrl: "https://docs.hyprnote.com/sharing#obsidian", + } + : null, + ].filter(Boolean) as ExportCard[]; + + const toggleExpanded = (id: string) => { + setExpandedId(expandedId === id ? null : id); + + if (id === "obsidian" && expandedId !== id && isObsidianConfigured.data) { + Promise.all([ + obsidianFolders.refetch(), + sessionTags.refetch(), + ]).then(([foldersResult, tagsResult]) => { + const freshFolders = foldersResult.data; + const freshTags = tagsResult.data; + + if (freshFolders && freshFolders.length > 0) { + const defaultFolder = getDefaultSelectedFolder(freshFolders, freshTags ?? []); + setSelectedObsidianFolder(defaultFolder); + } + }).catch((error) => { + console.error("Error fetching Obsidian data:", error); + setSelectedObsidianFolder("default"); + }); + } + }; + + const exportMutation = useMutation({ + mutationFn: async ({ session, optionId }: { session: Session; optionId: string }) => { + const start = performance.now(); + let result: ExportResult | null = null; + + if (optionId === "copy") { + result = await exportHandlers.copy(session); + } else if (optionId === "pdf") { + result = await exportHandlers.pdf(session, selectedPdfTheme); + } else if (optionId === "email") { + try { + // fetch participants directly, bypassing cache + const freshParticipants = await dbCommands.sessionListParticipants(param.id); + result = await exportHandlers.email(session, freshParticipants); + } catch (participantError) { + console.warn("Failed to fetch participants, sending email without them:", participantError); + result = await exportHandlers.email(session, undefined); + } + } else if (optionId === "obsidian") { + sessionTags.refetch(); + sessionParticipants.refetch(); + + let sessionTagsData = sessionTags.data; + let sessionParticipantsData = sessionParticipants.data; + + if (!sessionTagsData) { + const tagsResult = await sessionTags.refetch(); + sessionTagsData = tagsResult.data; + } + + if (!sessionParticipantsData) { + const participantsResult = await sessionParticipants.refetch(); + sessionParticipantsData = participantsResult.data; + } + + result = await exportHandlers.obsidian( + session, + selectedObsidianFolder, + sessionTagsData, + sessionParticipantsData, + includeTranscript, + ); + } + + const elapsed = performance.now() - start; + if (elapsed < 800) { + await new Promise((resolve) => setTimeout(resolve, 800 - elapsed)); + } + + return result; + }, + onMutate: ({ optionId }) => { + analyticsCommands.event({ + event: "share_triggered", + distinct_id: userId, + type: optionId, + }); + }, + onSuccess: (result) => { + if (result?.type === "copy" && result.success) { + setCopySuccess(true); + // Reset after 2 seconds + setTimeout(() => setCopySuccess(false), 2000); + } else if (result?.type === "pdf" && result.path) { + openPath(result.path); + } else if (result?.type === "email" && result.url) { + openUrl(result.url); + } else if (result?.type === "obsidian" && result.url) { + openUrl(result.url); + } + }, + onError: (error) => { + console.error(error); + message(JSON.stringify(error), { title: "Error", kind: "error" }); + }, + }); + + const handleExport = (optionId: string) => { + exportMutation.mutate({ session, optionId }); + }; + + const resetExpandedState = () => { + setExpandedId(null); + setCopySuccess(false); + }; + + const handleOpenStateChange = (isOpen: boolean) => { + if (isOpen) { + isObsidianConfigured.refetch().then((configResult) => { + if (configResult.data) { + obsidianFolders.refetch(); + } + }); + + analyticsCommands.event({ + event: "share_option_expanded", + distinct_id: userId, + }); + } else { + resetExpandedState(); + } + }; + + return { + session, + hasEnhancedNote, + expandedId, + selectedObsidianFolder, + setSelectedObsidianFolder, + selectedPdfTheme, + setSelectedPdfTheme, + includeTranscript, + setIncludeTranscript, + copySuccess, + isObsidianConfigured, + obsidianFolders, + directActions, + exportOptions, + exportMutation, + toggleExpanded, + handleExport, + handleOpenStateChange, + resetExpandedState, + }; +} + +// Reusable Share Content Component +export function SharePopoverContent() { + const { + expandedId, + selectedObsidianFolder, + setSelectedObsidianFolder, + selectedPdfTheme, + setSelectedPdfTheme, + includeTranscript, + setIncludeTranscript, + copySuccess, + obsidianFolders, + directActions, + exportOptions, + exportMutation, + toggleExpanded, + handleExport, + } = useShareLogic(); + + return ( +
+
Share Enhanced Note
+
+ {/* Direct action buttons */} + {directActions.map((action) => { + const isLoading = exportMutation.isPending && exportMutation.variables?.optionId === action.id; + const isSuccess = action.id === "copy" && copySuccess; + + return ( +
+ +
+ ); + })} + + {/* Expandable export options */} + {exportOptions.map((option) => { + const expanded = expandedId === option.id; + + return ( +
+ + {expanded && ( +
+
+

{option.description}

+ +
+ + {option.id === "pdf" && ( +
+ + +
+ )} + + {option.id === "obsidian" && ( + <> +
+ + +
+ +
+ +
+ + )} + + +
+ )} +
+ ); + })} +
+
+ +
+
+ ); +} diff --git a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx index 54ae4aa3bc..9ec4209924 100644 --- a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx +++ b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx @@ -3,7 +3,7 @@ import { useMatch } from "@tanstack/react-router"; import { DeleteNoteButton } from "@/components/toolbar/buttons/delete-note-button"; import { NewNoteButton } from "@/components/toolbar/buttons/new-note-button"; import { NewWindowButton } from "@/components/toolbar/buttons/new-window-button"; -import { ShareButton } from "@/components/toolbar/buttons/share-button"; +//import { ShareButton } from "@/components/toolbar/buttons/share-button"; import { useLeftSidebar } from "@/contexts"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { cn } from "@hypr/ui/lib/utils"; @@ -57,7 +57,7 @@ export function MainToolbar() { {isMain && ( <> {(organizationMatch || humanMatch) && } - {isNote && } + {/*isNote && */} From 53dc10d9e128d6b403c3aa738a4b865c72bfb871 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 13:28:22 -0700 Subject: [PATCH 10/12] ran tests --- apps/desktop/src/components/toolbar/bars/main-toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx index 9ec4209924..eb3e2bc1dd 100644 --- a/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx +++ b/apps/desktop/src/components/toolbar/bars/main-toolbar.tsx @@ -3,7 +3,7 @@ import { useMatch } from "@tanstack/react-router"; import { DeleteNoteButton } from "@/components/toolbar/buttons/delete-note-button"; import { NewNoteButton } from "@/components/toolbar/buttons/new-note-button"; import { NewWindowButton } from "@/components/toolbar/buttons/new-window-button"; -//import { ShareButton } from "@/components/toolbar/buttons/share-button"; +// import { ShareButton } from "@/components/toolbar/buttons/share-button"; import { useLeftSidebar } from "@/contexts"; import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { cn } from "@hypr/ui/lib/utils"; From 0f26db641dd0b9ea4223113e7a1d19af729b9a97 Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 15:01:57 -0700 Subject: [PATCH 11/12] refined pdf --- .../src/components/toolbar/utils/pdf-export.ts | 6 +++++- .../src/components/toolbar/utils/pdf-themes.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/toolbar/utils/pdf-export.ts b/apps/desktop/src/components/toolbar/utils/pdf-export.ts index c00c68ac25..851636151d 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-export.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-export.ts @@ -137,6 +137,10 @@ const htmlToStructuredText = (html: string): TextSegment[] => { }); listStack.pop(); + + if (level === 0) { + segments.push({ text: "\n" }); + } }; const processListItem = (liElement: Element) => { @@ -319,7 +323,7 @@ export const exportToPDF = async ( const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 20; const maxWidth = pageWidth - (margin * 2); - const lineHeight = 6; + const lineHeight = 5.5; const applyBackgroundColor = () => { if ( diff --git a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts index dfcbb362bc..aa5614f782 100644 --- a/apps/desktop/src/components/toolbar/utils/pdf-themes.ts +++ b/apps/desktop/src/components/toolbar/utils/pdf-themes.ts @@ -10,7 +10,8 @@ export type ThemeName = | "retro" | "spring" | "summer" - | "winter"; + | "winter" + | "homebrew"; export interface PDFTheme { font: string; @@ -182,6 +183,19 @@ export const getPDFTheme = (themeName: ThemeName): PDFTheme => { bullets: [100, 181, 246], // Light blue }, }, + + homebrew: { + font: "courier", + colors: { + background: [0, 0, 0], // Terminal black + mainContent: [0, 255, 0], // Terminal green + headers: [0, 255, 128], // Bright terminal green + metadata: [128, 255, 128], // Light terminal green + hyprnoteLink: [0, 255, 255], // Terminal cyan + separatorLine: [0, 128, 0], // Dark green + bullets: [0, 255, 0], // Terminal green + }, + }, }; return themes[themeName] || themes.default; @@ -201,6 +215,7 @@ export const getAvailableThemes = (): ThemeName[] => { "spring", "summer", "winter", + "homebrew", ]; }; @@ -229,6 +244,7 @@ const getThemeDescription = (themeName: ThemeName): string => { spring: "Fresh greens on yellow-green with Courier", summer: "Bright reds on yellow with Courier", winter: "Deep blues on icy background with Times", + homebrew: "Classic terminal green on black with Courier", }; return descriptions[themeName] || descriptions.default; From 546cac74ed5cfc6410819ac8978e91033eff192a Mon Sep 17 00:00:00 2001 From: Deokhaeng Lee Date: Wed, 20 Aug 2025 15:47:10 -0700 Subject: [PATCH 12/12] coderabbit --- .../src/components/editor-area/note-header/chips/index.tsx | 2 +- .../components/editor-area/note-header/chips/share-chip.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx index 0075bde4ec..f8e6a66a1f 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/index.tsx @@ -48,7 +48,7 @@ export default function NoteHeaderChips({ - + {/* Temporarily commented out chat button */} {/* */} diff --git a/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx index c3c50b2fac..124308904e 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/share-chip.tsx @@ -4,12 +4,10 @@ import { useState } from "react"; import { SharePopoverContent, useShareLogic } from "../share-button-header"; interface ShareChipProps { - sessionId: string; isVeryNarrow?: boolean; - isNarrow?: boolean; } -export function ShareChip({ sessionId, isVeryNarrow = false, isNarrow = false }: ShareChipProps) { +export function ShareChip({ isVeryNarrow = false }: ShareChipProps) { const [open, setOpen] = useState(false); const { hasEnhancedNote, handleOpenStateChange } = useShareLogic();