diff --git a/AUDIT_LOG.md b/AUDIT_LOG.md index 03f91d1..8570c12 100644 --- a/AUDIT_LOG.md +++ b/AUDIT_LOG.md @@ -2,6 +2,19 @@ This log tracks all significant changes, updates, and versions in the PaperCache project. +## 2026-07-01 (v0.5.9 Release: Image Support & UI Consistency) +**Change:** feat(release): bump version to 0.5.9; implement image paste support and markdown image widget; align background blur and font typography across modals and timers; extract audio recording features to external project + +**Details/Why:** +1. **Version Bump**: Bumped application version to 0.5.9 across `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml`. Added release note `notes/New Features in v0.5.9.md`. +2. **Image Support**: Implemented image paste handler in `src/lib/editor/extensions.ts` which captures pasted images, saves them to `.images` directory via Tauri `save_asset` IPC command, and inserts markdown `![image](path)`. Rendered inline via `ImageWidget` in CodeMirror (`src/lib/editor/widgets.ts`, `markdownPlugin.ts`). +3. **UI Consistency**: Standardized background blur styling across search modal and timers menu (`TimersPage.tsx`, `KeybindsModal.tsx`, `App.css`). Ensured timers menu adheres to custom app typography. +4. **Product Scope Separation**: Extracted voice memo audio recording and floating indicator features into a dedicated standalone repository at `~/Projects/Memo` per user instruction. + +**Files changed:** `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, `src/api.ts`, `src/types.d.ts`, `src/App.css`, `src/components/KeybindsModal.tsx`, `src/components/TimersPage.tsx`, `src/lib/editor/extensions.ts`, `src/lib/editor/markdownPlugin.ts`, `src/lib/editor/widgets.ts`, `src/lib/editor/widgets.test.ts`, `src-tauri/src/lib.rs`, `src-tauri/src/commands/fs.rs`, `notes/New Features in v0.5.9.md` [NEW], `CHANGELOG.md`, `AUDIT_LOG.md`. + +--- + ## 2026-06-30 (Linux Runner Pinning for `glibc` Compatibility Fix) **Change:** fix(ci): pin Linux workflow runner to `ubuntu-22.04` across CI and release workflows to ensure `glibc 2.35` compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b72ef0..09f19f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +## [v0.5.9] - 2026-07-01 + +### Added +- **Image Copy & Paste Support**: You can now copy any image and paste it directly into your notes. Pasted images are automatically saved locally in a hidden `.images` folder and rendered seamlessly inline as image cards. + +### Changed +- **Consistent Glassmorphic Backgrounds**: Standardized background blur effects across modal overlays (Command+P Search and Command+T Timers menu) to ensure consistent visual aesthetics. +- **Dynamic Typography in Timers**: Ensure the Timers menu respects custom font family selections made in App Settings. + ### Fixed - **Linux Compatibility (`glibc` version mismatch)**: Pinned GitHub Actions Linux runner to `ubuntu-22.04` across CI and release workflows instead of `ubuntu-latest` (Ubuntu 24.04). This ensures built Linux binaries and AppImages link against `glibc 2.35` so they can run out-of-the-box on Ubuntu 22.04 LTS, Debian 12, and other distributions without throwing `version glibc 2.38 not found` errors. diff --git a/notes/New Features in v0.5.9.md b/notes/New Features in v0.5.9.md new file mode 100644 index 0000000..bf13000 --- /dev/null +++ b/notes/New Features in v0.5.9.md @@ -0,0 +1,11 @@ +# New Features in v0.5.9 + +Welcome to PaperCache v0.5.9! + +Here are the new features and improvements implemented in this release: +- **Image Support**: You can now copy and paste images directly into your notes! Images are automatically saved locally in a hidden `.images` folder and rendered seamlessly inside the editor as beautiful image cards. +- **Consistent Visual Design**: Aligned background blur styling across modal overlays (Command+P search and Command+T Timers menu) for a cohesive glassmorphic aesthetic across the app. +- **Unified Typography**: Ensure the Timers menu dynamically respects and follows your chosen custom typography settings from the App Settings. +- **Linux CI Workflow Fix**: Pinned Linux GitHub Action runner to `ubuntu-22.04` for dependable `glibc 2.35` build compatibility. + +*(If you have read this note, feel free to delete it)* diff --git a/package.json b/package.json index 2dc75e1..23cb7f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "papercache", "private": true, - "version": "0.5.8", + "version": "0.5.9", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 40b1b02..0560bed 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "papercache" -version = "0.5.8" +version = "0.5.9" dependencies = [ "aes-gcm", "base64 0.22.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e39a9fb..75c33c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "papercache" -version = "0.5.8" +version = "0.5.9" description = "A PaperCache Tauri App" authors = ["Aditya Sharma"] edition = "2021" diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 628e23a..c44f859 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,3 +1,4 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; @@ -60,6 +61,10 @@ fn walk_dir(dir: &Path, notes: &mut Vec, base_path: &Path) -> Result<(), S let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.is_dir() { + let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if dir_name == ".images" || dir_name == ".audio" { + continue; + } walk_dir(&path, notes, base_path)?; } else if path.is_file() { let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); @@ -496,3 +501,117 @@ pub fn run_onboarding(app: &AppHandle) { } } } + +#[tauri::command(async)] +pub async fn save_asset(data_base64: String, ext: String, folder: String) -> Result { + if folder.contains("..") || folder.contains('/') || folder.contains('\\') { + return Err("Invalid folder name".to_string()); + } + let folder_name = if folder.starts_with('.') { + folder.clone() + } else { + format!(".{}", folder) + }; + if folder_name != ".images" && folder_name != ".audio" { + return Err("Unsupported asset folder".to_string()); + } + + let base = get_papercache_dir()?; + let asset_dir = base.join(&folder_name); + if !asset_dir.exists() { + tokio::fs::create_dir_all(&asset_dir).await.map_err(|e| e.to_string())?; + } + + let clean_ext: String = ext + .trim_start_matches('.') + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| e.to_string())? + .as_millis(); + let prefix = folder_name.trim_start_matches('.'); + + // Generate unique filename with random suffix to avoid collisions + use rand::Rng; + let mut rng = rand::thread_rng(); + let random_suffix: u32 = rng.gen(); + let filename = format!("{}_{}_{:08x}.{}", prefix, timestamp, random_suffix, clean_ext); + let file_path = asset_dir.join(&filename); + + let b64_str = if let Some(idx) = data_base64.find(',') { + &data_base64[idx + 1..] + } else { + &data_base64 + }; + + let decoded = BASE64.decode(b64_str).map_err(|e| format!("Failed to decode base64: {}", e))?; + tokio::fs::write(&file_path, &decoded).await.map_err(|e| e.to_string())?; + + Ok(format!("/{}/{}", folder_name, filename)) +} + +#[tauri::command(async)] +pub async fn read_asset(path: String) -> Result { + let clean_path = path.trim_start_matches('/'); + if clean_path.contains("..") { + return Err("Invalid asset path".to_string()); + } + + // Read-only validation: ensure path is within allowed asset folders + let path_parts: Vec<&str> = clean_path.split('/').collect(); + if path_parts.is_empty() { + return Err("Invalid asset path".to_string()); + } + let first_component = path_parts[0]; + if first_component != ".images" && first_component != ".audio" { + return Err("Asset path must start with .images or .audio".to_string()); + } + + let base = get_papercache_dir()?; + let mut target = base.clone(); + for comp in clean_path.split('/') { + if !comp.is_empty() && comp != "." && comp != ".." { + target.push(comp); + } else if comp == ".." { + return Err("Path traversal detected".to_string()); + } + } + + // Verify the resolved path is within base without creating any directories + let canonical_base = base.canonicalize().map_err(|e| e.to_string())?; + if !target.exists() { + return Err("Asset file not found".to_string()); + } + let canonical_target = target.canonicalize().map_err(|e| e.to_string())?; + if !canonical_target.starts_with(&canonical_base) { + return Err("Path traversal detected".to_string()); + } + + let file_path = canonical_target; + let bytes = tokio::fs::read(&file_path).await.map_err(|e| e.to_string())?; + + let ext = file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let mime = match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "webm" => "audio/webm", + "m4a" | "mp4" => "audio/mp4", + "aac" => "audio/aac", + "wav" => "audio/wav", + "mp3" => "audio/mpeg", + "ogg" => "audio/ogg", + _ => "application/octet-stream", + }; + + let encoded = BASE64.encode(&bytes); + Ok(format!("data:{};base64,{}", mime, encoded)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 258dd18..02fab47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,6 +160,8 @@ pub fn run() { commands::fs::export_note, commands::fs::set_dialog_open, commands::fs::remove_onboarding_files, + commands::fs::save_asset, + commands::fs::read_asset, commands::system::close_window, commands::system::restore_window_state, commands::system::quit_app, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7191d07..bb85fdf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/2.0.0/tauri.schema.json", "productName": "PaperCache", - "version": "0.5.8", + "version": "0.5.9", "identifier": "com.variablethe.papercache", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 13a1ed5..102e53f 100644 --- a/src/App.css +++ b/src/App.css @@ -341,7 +341,9 @@ body { left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); z-index: 9999; display: flex; justify-content: center; @@ -910,6 +912,7 @@ body { inset: 0; background: rgba(0, 0, 0, 0.35); backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); z-index: 9000; display: flex; align-items: center; diff --git a/src/api.ts b/src/api.ts index db7554b..9d0de17 100644 --- a/src/api.ts +++ b/src/api.ts @@ -66,4 +66,6 @@ export const tauriApi: ElectronAPI = { onUpdateReady: (callback) => onEvent('update-ready', callback), restartApp: () => invoke('restart_app'), onUpdateStatus: (callback) => onEvent('update-status', callback), + saveAsset: (dataBase64, ext, folder) => invoke('save_asset', { dataBase64, ext, folder }), + readAsset: (assetPath) => invoke('read_asset', { path: assetPath }), } diff --git a/src/components/KeybindsModal.tsx b/src/components/KeybindsModal.tsx index 16a6671..621cade 100644 --- a/src/components/KeybindsModal.tsx +++ b/src/components/KeybindsModal.tsx @@ -61,6 +61,13 @@ export function KeybindsModal({ onClose }: KeybindsModalProps) { defaultKey: `${defaultMod}+N`, section: 'app', }, + { + key: 'shortcutVoiceMemo', + label: 'Hold to Record Voice Memo', + storageKey: SETTINGS_KEYS.SHORTCUT_VOICE_MEMO, + defaultKey: `${defaultMod}+Shift+M`, + section: 'app', + }, { key: 'shortcutSearch', label: 'Search Notes', diff --git a/src/components/TimersPage.tsx b/src/components/TimersPage.tsx index ebc431c..571d909 100644 --- a/src/components/TimersPage.tsx +++ b/src/components/TimersPage.tsx @@ -2,6 +2,7 @@ const TICK_INTERVAL_MS = 250 import { useState, useEffect, useRef } from 'react' import { useTimerStore, type Timer } from '../store/useTimerStore' +import { useSettingsStore } from '../store/useSettingsStore' interface TimerItemProps { timer: Timer onRemove: (id: string) => void @@ -97,6 +98,7 @@ const QUICK_PRESETS = [ ] export function TimersPage({ onClose }: TimersPageProps) { + const fontFamily = useSettingsStore((s) => s.fontFamily) const timers = useTimerStore((s) => s.timers) const addTimer = useTimerStore((s) => s.addTimer) const removeTimer = useTimerStore((s) => s.removeTimer) @@ -149,8 +151,8 @@ export function TimersPage({ onClose }: TimersPageProps) { } return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>

{ + const items = event.clipboardData?.items + if (!items) return false + + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item && item.type.indexOf('image') !== -1) { + const file = item.getAsFile() + if (file) { + event.preventDefault() + // Capture selection range synchronously before async operations + const selection = view.state.selection.main + const from = selection.from + const to = selection.to + const placeholder = '![uploading...]' + + // Insert placeholder immediately to replace selection + view.dispatch({ + changes: { from, to, insert: placeholder }, + selection: { anchor: from + placeholder.length }, + }) + + const reader = new FileReader() + reader.onload = async (e) => { + const dataUrl = e.target?.result as string + if (dataUrl) { + const ext = file.type.split('/')[1] || 'png' + try { + const path = await window.electronAPI.saveAsset(dataUrl, ext, '.images') + const insertText = `![image](${path})` + // Replace placeholder with actual image embed + view.dispatch({ + changes: { from, to: from + placeholder.length, insert: insertText }, + selection: { anchor: from + insertText.length }, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to save image asset', err) + // Replace placeholder with error message + view.dispatch({ + changes: { + from, + to: from + placeholder.length, + insert: '![upload failed]', + }, + }) + } + } + } + reader.readAsDataURL(file) + return true + } + } + } + return false + }, }), ], [apiBaseUrl, apiModel, aiSystemPrompt, isHyprland] diff --git a/src/lib/editor/markdownPlugin.ts b/src/lib/editor/markdownPlugin.ts index 95ab569..a035221 100644 --- a/src/lib/editor/markdownPlugin.ts +++ b/src/lib/editor/markdownPlugin.ts @@ -1,7 +1,7 @@ import { ViewPlugin, Decoration, EditorView, ViewUpdate, WidgetType } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { syntaxTree } from '@codemirror/language' -import { ContextWidget } from './widgets' +import { ContextWidget, ImageWidget } from './widgets' export const markdownPlugin = ViewPlugin.fromClass( class { @@ -32,6 +32,27 @@ export const markdownPlugin = ViewPlugin.fromClass( const text = view.state.doc.sliceString(from, to) let match + // Image / Audio Embeds (![alt](path)) + const reImage = /!\[(.*?)\]\((.*?)\)/g + while ((match = reImage.exec(text)) !== null) { + const start = from + match.index + const end = start + match[0].length + linkRanges.push({ from: start, to: end }) + + const altText = match[1]! + const assetPath = match[2]!.trim() + + if (!isCursorInMatch(start, end)) { + decos.push({ + from: start, + to: end, + deco: Decoration.replace({ + widget: new ImageWidget(assetPath, altText), + }), + }) + } + } + // ==Highlight== const reHighlight = /==(.*?)==/g while ((match = reHighlight.exec(text)) !== null) { @@ -74,6 +95,9 @@ export const markdownPlugin = ViewPlugin.fromClass( const reLink = /\[(.*?)\]\((.*?)\)/g while ((match = reLink.exec(text)) !== null) { const start = from + match.index + if (start > 0 && view.state.doc.sliceString(start - 1, start) === '!') { + continue + } const end = start + match[0].length linkRanges.push({ from: start, to: end }) diff --git a/src/lib/editor/widgets.test.ts b/src/lib/editor/widgets.test.ts index 6f38eea..9d77319 100644 --- a/src/lib/editor/widgets.test.ts +++ b/src/lib/editor/widgets.test.ts @@ -1,6 +1,6 @@ import type { EditorView } from '@codemirror/view' import { describe, it, expect, vi } from 'vitest' -import { CopyWidget, CheckboxWidget, VariableWidget, ColorWidget } from './widgets' +import { CopyWidget, CheckboxWidget, VariableWidget, ColorWidget, ImageWidget } from './widgets' describe('Editor Widgets', () => { describe('CopyWidget', () => { @@ -93,4 +93,16 @@ describe('Editor Widgets', () => { expect(dom.style.getPropertyValue('--pill-color')).toBe('#ff0000') }) }) + + describe('ImageWidget', () => { + it('should create img element and check equality', () => { + const w1 = new ImageWidget('/.images/test.png', 'test image') + const w2 = new ImageWidget('/.images/test.png', 'test image') + expect(w1.eq(w2)).toBe(true) + + const dom = w1.toDOM() + expect(dom.className).toBe('cm-image-widget') + expect(dom.querySelector('img')?.alt).toBe('test image') + }) + }) }) diff --git a/src/lib/editor/widgets.ts b/src/lib/editor/widgets.ts index b63e064..6a8de11 100644 --- a/src/lib/editor/widgets.ts +++ b/src/lib/editor/widgets.ts @@ -207,6 +207,60 @@ export class ReminderWidget extends WidgetType { } } +const assetDataUrlCache = new Map() + +export class ImageWidget extends WidgetType { + path: string + alt: string + + constructor(path: string, alt: string) { + super() + this.path = path + this.alt = alt + } + + eq(other: ImageWidget) { + return other.path === this.path && other.alt === this.alt + } + + toDOM() { + const wrap = document.createElement('span') + wrap.className = 'cm-image-widget' + wrap.style.display = 'block' + wrap.style.margin = '8px 0' + wrap.style.maxWidth = '100%' + + const img = document.createElement('img') + img.alt = this.alt || 'Asset image' + img.style.maxWidth = '100%' + img.style.maxHeight = '400px' + img.style.borderRadius = '8px' + img.style.border = '1px solid rgba(128, 128, 128, 0.25)' + img.style.objectFit = 'contain' + + if (assetDataUrlCache.has(this.path)) { + img.src = assetDataUrlCache.get(this.path)! + } else if (window.electronAPI?.readAsset) { + window.electronAPI + .readAsset(this.path) + .then((dataUrl) => { + assetDataUrlCache.set(this.path, dataUrl) + img.src = dataUrl + }) + .catch(() => { + img.alt = `Failed to load: ${this.path}` + }) + } + + wrap.appendChild(img) + return wrap + } + + ignoreEvent() { + return false + } +} + export class ContextWidget extends WidgetType { toDOM() { const span = document.createElement('span') diff --git a/src/lib/settingsKeys.ts b/src/lib/settingsKeys.ts index a9b38d8..1410ac3 100644 --- a/src/lib/settingsKeys.ts +++ b/src/lib/settingsKeys.ts @@ -24,6 +24,7 @@ export const SETTINGS_KEYS = { SHORTCUT_REF: 'papercache-shortcut-ref', SHORTCUT_SETTINGS: 'papercache-shortcut-settings', SHORTCUT_NEWNOTE_INAPP: 'papercache-shortcut-newnote-inapp', + SHORTCUT_VOICE_MEMO: 'papercache-shortcut-voice-memo', LAUNCH_STARTUP: 'papercache-launch-startup', NOTIFIED_REMINDERS: 'papercache_notified', } as const diff --git a/src/setupTests.ts b/src/setupTests.ts index 56bb789..daa8793 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -85,6 +85,8 @@ if (typeof window !== 'undefined') { removeOnboardingFiles: vi.fn().mockResolvedValue(undefined), restartApp: vi.fn().mockResolvedValue(undefined), onUpdateStatus: vi.fn().mockReturnValue(() => {}), + saveAsset: vi.fn().mockResolvedValue('/.images/test.png'), + readAsset: vi.fn().mockResolvedValue('data:image/png;base64,'), } as ElectronAPI } diff --git a/src/types.d.ts b/src/types.d.ts index 0823d73..68af16a 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -55,6 +55,8 @@ export interface ElectronAPI { onUpdateReady: (callback: () => void) => () => void restartApp: () => Promise onUpdateStatus: (callback: (payload: UpdateStatusPayload) => void) => () => void + saveAsset: (dataBase64: string, ext: string, folder: string) => Promise + readAsset: (assetPath: string) => Promise } declare global {