diff --git a/e2e/tests/app-features/clipboard-images.spec.ts b/e2e/tests/app-features/clipboard-images.spec.ts new file mode 100644 index 0000000000..5adb9b627d --- /dev/null +++ b/e2e/tests/app-features/clipboard-images.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from '../../fixtures/test.fixture'; + +test.describe('Clipboard Images Settings', () => { + test('should show IndexedDB manager button in web but not in config', async ({ + page, + settingsPage, + }) => { + test.setTimeout(30000); + + // Navigate to settings + await settingsPage.navigateToSettings(); + + // Wait for settings page to load + await page.waitForLoadState('networkidle'); + + // Find and click on clipboard images section + // Look for the collapsible section title (not an h2/h3, but a div.collapsible-title) + const sectionHeading = page + .locator('.collapsible-title') + .filter({ hasText: /clipboard.*image/i }); + await sectionHeading.first().waitFor({ state: 'visible', timeout: 10000 }); + + // Scroll into view if needed + await sectionHeading.first().scrollIntoViewIfNeeded(); + + // Click to expand the section if it's collapsed + await sectionHeading.first().click(); + + // Wait a moment for expansion animation + await page.waitForTimeout(500); + + // In web version, verify IndexedDB manager button IS visible + const managerBtn = page.locator('button', { + hasText: /manage.*image|open.*manager/i, + }); + await expect(managerBtn.first()).toBeVisible({ timeout: 5000 }); + + // Verify the button text contains expected text + const btnText = await managerBtn.first().textContent(); + expect(btnText?.toLowerCase()).toMatch(/manage|manager/); + }); + + test('should open clipboard images manager dialog', async ({ page, settingsPage }) => { + test.setTimeout(30000); + + // Navigate to settings + await settingsPage.navigateToSettings(); + await page.waitForLoadState('networkidle'); + + // Find clipboard images section (collapsible title, not h2/h3) + const sectionHeading = page + .locator('.collapsible-title') + .filter({ hasText: /clipboard.*image/i }); + await sectionHeading.first().waitFor({ state: 'visible', timeout: 10000 }); + await sectionHeading.first().scrollIntoViewIfNeeded(); + + // Click to expand the section if it's collapsed + await sectionHeading.first().click(); + + // Wait a moment for expansion animation + await page.waitForTimeout(500); + + // Click the manager button + const managerBtn = page.locator('button', { + hasText: /manage.*image|open.*manager/i, + }); + await managerBtn.first().click(); + + // Wait for dialog to open + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Verify dialog title + const dialogTitle = dialog.locator('h1, h2').filter({ hasText: /clipboard.*image/i }); + await expect(dialogTitle).toBeVisible(); + + // Verify close button exists (looking for button with "Close" text) + const closeBtn = dialog.locator('button').filter({ hasText: /close/i }); + await expect(closeBtn.first()).toBeVisible(); + + // Close the dialog + await closeBtn.first().click(); + + // Verify dialog is closed + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/electron/clipboard-image-handler.ts b/electron/clipboard-image-handler.ts new file mode 100644 index 0000000000..5c2198d1ba --- /dev/null +++ b/electron/clipboard-image-handler.ts @@ -0,0 +1,284 @@ +import { ipcMain, clipboard } from 'electron'; +import { promises as fsPromises } from 'fs'; +import * as fs from 'fs'; +import * as path from 'path'; +import { IPC } from './shared-with-frontend/ipc-events.const'; +import { createValidatedHandler } from './ipc-handler-wrapper'; +import { EXTENSION_MIME_TYPES } from './shared-with-frontend/mime-type-mapping.const'; + +interface ClipboardImageMeta { + id: string; + mimeType: string; + createdAt: number; + size: number; +} + +const SUPPORTED_IMAGE_EXTENSIONS = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.bmp', +]; + +/** + * Ensures the clipboard-images directory exists. + */ +const ensureDir = (dirPath: string): void => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +}; + +/** + * Gets the MIME type from file extension. + */ +const getMimeFromExt = (ext: string): string => { + const extLower = ext.toLowerCase(); + return ( + EXTENSION_MIME_TYPES[extLower as keyof typeof EXTENSION_MIME_TYPES] || 'image/png' + ); +}; + +/** + * Finds the image file by ID (checking various extensions). + * @electron-only This function is only available in the Electron main process. + */ +const findImageFile = (basePath: string, imageId: string): string | null => { + for (const ext of SUPPORTED_IMAGE_EXTENSIONS) { + const filePath = path.join(basePath, `${imageId}${ext}`); + if (fs.existsSync(filePath)) { + return filePath; + } + } + + return null; +}; + +export const initClipboardImageHandlers = (): void => { + // Save clipboard image + ipcMain.handle( + IPC.CLIPBOARD_IMAGE_SAVE, + createValidatedHandler( + async ({ + basePath, + fileName, + base64Data, + }: { + basePath: string; + fileName: string; + base64Data: string; + mimeType: string; + }) => { + ensureDir(basePath); + + const filePath = path.join(basePath, fileName); + const buffer = Buffer.from(base64Data, 'base64'); + + await fsPromises.writeFile(filePath, new Uint8Array(buffer)); + + return filePath; + }, + { validatePath: true }, + ), + ); + + // Load clipboard image + ipcMain.handle( + IPC.CLIPBOARD_IMAGE_LOAD, + createValidatedHandler( + async ({ basePath, imageId }: { basePath: string; imageId: string }) => { + const filePath = findImageFile(basePath, imageId); + if (!filePath) { + return null; + } + + const buffer = await fsPromises.readFile(filePath); + const ext = path.extname(filePath); + const mimeType = getMimeFromExt(ext); + + return { + base64: buffer.toString('base64'), + mimeType, + }; + }, + { validatePath: true, errorValue: null }, + ), + ); + + // Delete clipboard image + ipcMain.handle( + IPC.CLIPBOARD_IMAGE_DELETE, + createValidatedHandler( + async ({ basePath, imageId }: { basePath: string; imageId: string }) => { + const filePath = findImageFile(basePath, imageId); + if (!filePath) { + return false; + } + + await fsPromises.unlink(filePath); + return true; + }, + { validatePath: true, errorValue: false }, + ), + ); + + // List clipboard images + ipcMain.handle( + IPC.CLIPBOARD_IMAGE_LIST, + createValidatedHandler( + async ({ basePath }: { basePath: string }) => { + if (!fs.existsSync(basePath)) { + return []; + } + + const files = await fsPromises.readdir(basePath); + const imageExtensions = new Set(SUPPORTED_IMAGE_EXTENSIONS); + + const images: ClipboardImageMeta[] = []; + + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + if (!imageExtensions.has(ext)) { + continue; + } + + const filePath = path.join(basePath, file); + const stats = await fsPromises.stat(filePath); + const id = path.basename(file, ext); + + images.push({ + id, + mimeType: getMimeFromExt(ext), + createdAt: stats.birthtimeMs, + size: stats.size, + }); + } + + return images; + }, + { validatePath: true, errorValue: [] }, + ), + ); + + // Get clipboard image file path + ipcMain.handle( + IPC.CLIPBOARD_IMAGE_GET_PATH, + createValidatedHandler( + async ({ basePath, imageId }: { basePath: string; imageId: string }) => { + return findImageFile(basePath, imageId); + }, + { validatePath: true, errorValue: null }, + ), + ); + + // Copy image file from clipboard to clipboard-images directory + ipcMain.handle( + IPC.CLIPBOARD_COPY_IMAGE_FILE, + createValidatedHandler( + async ({ basePath, filePath }: { basePath: string; filePath: string }) => { + ensureDir(basePath); + + // Generate unique ID + const id = Date.now().toString(36) + Math.random().toString(36).substring(2); + const ext = path.extname(filePath).toLowerCase(); + const destFileName = `${id}${ext}`; + const destPath = path.join(basePath, destFileName); + + // Copy the file + await fsPromises.copyFile(filePath, destPath); + + // Get file stats + const stats = await fsPromises.stat(destPath); + const mimeType = getMimeFromExt(ext); + + return { + id, + mimeType, + size: stats.size, + createdAt: Date.now(), + }; + }, + { validatePath: true, errorValue: null }, + ), + ); + + // Read image directly from clipboard + ipcMain.handle( + IPC.CLIPBOARD_READ_IMAGE, + createValidatedHandler( + async ({ basePath }: { basePath: string }) => { + const image = clipboard.readImage(); + + if (image.isEmpty()) { + return null; + } + + ensureDir(basePath); + + // Generate unique ID + const id = Date.now().toString(36) + Math.random().toString(36).substring(2); + const fileName = `${id}.png`; + const filePath = path.join(basePath, fileName); + + // Save as PNG + const pngBuffer = image.toPNG(); + await fsPromises.writeFile(filePath, new Uint8Array(pngBuffer)); + + const stats = await fsPromises.stat(filePath); + + return { + id, + mimeType: 'image/png', + size: stats.size, + createdAt: Date.now(), + }; + }, + { validatePath: true, errorValue: null }, + ), + ); + + ipcMain.handle( + IPC.CLIPBOARD_GET_FILE_PATHS, + createValidatedHandler( + async () => { + const filePaths: string[] = []; + + // Note: Electron's clipboard API on Windows doesn't reliably read file paths + // when files are copied. clipboard.readImage() is used as fallback. + + // Try reading plain text (sometimes contains file paths) + const plainText = clipboard.readText(); + if (plainText && plainText.startsWith('file://')) { + const lines = plainText.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('file://')) { + let filePath = trimmed.substring(7); + if (process.platform === 'win32' && filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + filePath = decodeURIComponent(filePath); + if (process.platform === 'win32') { + filePath = filePath.replace(/\//g, '\\'); + } + filePaths.push(filePath); + } + } + } + + // Filter to only existing image files + const imageFiles = filePaths.filter((filePath) => { + if (!fs.existsSync(filePath)) return false; + const ext = path.extname(filePath).toLowerCase(); + return SUPPORTED_IMAGE_EXTENSIONS.includes(ext); + }); + + return imageFiles; + }, + { errorValue: [] }, + ), + ); +}; diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index e8e9ac3ee6..86359f0e28 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -55,6 +55,12 @@ export interface ElectronAPI { pickDirectory(): Promise; + showOpenDialog(options: { + properties: string[]; + title?: string; + defaultPath?: string; + }): Promise; + // checkDirExists(dirPath: string): Promise; // STANDARD @@ -87,6 +93,48 @@ export interface ElectronAPI { isFlatpak(): boolean; + // CLIPBOARD IMAGES + // ---------------- + saveClipboardImage( + basePath: string, + fileName: string, + base64Data: string, + mimeType: string, + ): Promise; + + loadClipboardImage( + basePath: string, + imageId: string, + ): Promise<{ base64: string; mimeType: string } | null>; + + deleteClipboardImage(basePath: string, imageId: string): Promise; + + listClipboardImages( + basePath: string, + ): Promise<{ id: string; mimeType: string; createdAt: number; size: number }[]>; + + getClipboardImagePath(basePath: string, imageId: string): Promise; + + getClipboardFilePaths(): Promise; + copyClipboardImageFile( + basePath: string, + filePath: string, + ): Promise<{ + id: string; + mimeType: string; + size: number; + createdAt: number; + } | null>; + + readClipboardImage(basePath: string): Promise<{ + id: string; + mimeType: string; + size: number; + createdAt: number; + } | null>; + + getPathForFile(file: File): string | null; + // SEND // ---- reloadMainWin(): void; diff --git a/electron/ipc-handler-wrapper.ts b/electron/ipc-handler-wrapper.ts new file mode 100644 index 0000000000..0a476e190a --- /dev/null +++ b/electron/ipc-handler-wrapper.ts @@ -0,0 +1,43 @@ +import { app } from 'electron'; +import * as path from 'path'; + +/** + * Validates if the given path is within the userData directory. + */ +export const validatePathInUserData = (basePath: string): boolean => { + const userDataPath = app.getPath('userData'); + const resolvedBasePath = path.resolve(basePath); + const resolvedUserDataPath = path.resolve(userDataPath); + return resolvedBasePath.startsWith(resolvedUserDataPath); +}; + +/** + * Creates a validated IPC handler with consistent error handling and optional path validation. + */ +export const createValidatedHandler = ( + handler: (args: TArgs) => Promise, + options?: { + validatePath?: boolean; + errorValue?: TResult; + }, +): ((event: Electron.IpcMainInvokeEvent, args: TArgs) => Promise) => { + return async (_, args: TArgs) => { + try { + // Add path validation if requested + if (options?.validatePath && 'basePath' in args) { + const basePath = (args as any).basePath; + if (!validatePathInUserData(basePath)) { + throw new Error('Invalid base path'); + } + } + + return await handler(args); + } catch (error) { + console.error(`IPC handler error:`, error); + if (options?.errorValue !== undefined) { + return options.errorValue; + } + throw error; + } + }; +}; diff --git a/electron/ipc-handler.ts b/electron/ipc-handler.ts index 603c422079..71dfc70c39 100644 --- a/electron/ipc-handler.ts +++ b/electron/ipc-handler.ts @@ -8,6 +8,7 @@ import { initJiraIpc, initSystemIpc, } from './ipc-handlers'; +import { initClipboardImageHandlers } from './clipboard-image-handler'; export const initIpcInterfaces = (): void => { // Initialize plugin node executor (registers IPC handlers) @@ -24,4 +25,5 @@ export const initIpcInterfaces = (): void => { initJiraIpc(); initGlobalShortcutsIpc(); initExecIpc(); + initClipboardImageHandlers(); }; diff --git a/electron/local-file-sync.ts b/electron/local-file-sync.ts index e85feab54f..1581938cdd 100644 --- a/electron/local-file-sync.ts +++ b/electron/local-file-sync.ts @@ -137,7 +137,7 @@ export const initLocalFileSyncAdapter = (): void => { ); ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise => { - const { canceled, filePaths } = await dialog.showOpenDialog(getWin(), { + const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), { title: 'Select sync folder', buttonLabel: 'Select Folder', properties: [ @@ -146,13 +146,33 @@ export const initLocalFileSyncAdapter = (): void => { 'promptToCreate', 'dontAddToRecent', ], - }); + })) as unknown as { canceled: boolean; filePaths: string[] }; if (canceled) { return undefined; } else { return filePaths[0]; } }); + + ipcMain.handle( + IPC.SHOW_OPEN_DIALOG, + async ( + _, + options: { properties: string[]; title?: string; defaultPath?: string }, + ): Promise => { + const { canceled, filePaths } = (await dialog.showOpenDialog(getWin(), { + title: options.title || 'Select folder', + buttonLabel: 'Select', + properties: options.properties as any, + defaultPath: options.defaultPath, + })) as unknown as { canceled: boolean; filePaths: string[] }; + if (canceled) { + return undefined; + } else { + return filePaths; + } + }, + ); }; const getRev = (filePath: string): string => { diff --git a/electron/preload.ts b/electron/preload.ts index 8b1202629d..1e287a56e9 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,10 @@ -import { ipcRenderer, IpcRendererEvent, webFrame, contextBridge } from 'electron'; +import { + ipcRenderer, + IpcRendererEvent, + webFrame, + contextBridge, + webUtils, +} from 'electron'; import { ElectronAPI } from './electronAPI.d'; import { IPCEventValue } from './shared-with-frontend/ipc-events.const'; import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model'; @@ -51,6 +57,11 @@ const ea: ElectronAPI = { pickDirectory: () => _invoke('PICK_DIRECTORY') as Promise, + showOpenDialog: (options: { + properties: string[]; + title?: string; + defaultPath?: string; + }) => _invoke('SHOW_OPEN_DIALOG', options) as Promise, // STANDARD // -------- setZoomFactor: (zoomFactor: number) => { @@ -62,6 +73,68 @@ const ea: ElectronAPI = { isSnap: () => process && process.env && !!process.env.SNAP, isFlatpak: () => process && process.env && !!process.env.FLATPAK_ID, + // CLIPBOARD IMAGES + // ---------------- + saveClipboardImage: ( + basePath: string, + fileName: string, + base64Data: string, + mimeType: string, + ) => + _invoke('CLIPBOARD_IMAGE_SAVE', { + basePath, + fileName, + base64Data, + mimeType, + }) as Promise, + + loadClipboardImage: (basePath: string, imageId: string) => + _invoke('CLIPBOARD_IMAGE_LOAD', { basePath, imageId }) as Promise<{ + base64: string; + mimeType: string; + } | null>, + + deleteClipboardImage: (basePath: string, imageId: string) => + _invoke('CLIPBOARD_IMAGE_DELETE', { basePath, imageId }) as Promise, + + listClipboardImages: (basePath: string) => + _invoke('CLIPBOARD_IMAGE_LIST', { basePath }) as Promise< + { id: string; mimeType: string; createdAt: number; size: number }[] + >, + + getClipboardImagePath: (basePath: string, imageId: string) => + _invoke('CLIPBOARD_IMAGE_GET_PATH', { basePath, imageId }) as Promise, + + getClipboardFilePaths: () => _invoke('CLIPBOARD_GET_FILE_PATHS') as Promise, + + copyClipboardImageFile: (basePath: string, filePath: string) => + _invoke('CLIPBOARD_COPY_IMAGE_FILE', { + basePath, + filePath, + }) as Promise<{ + id: string; + mimeType: string; + size: number; + createdAt: number; + } | null>, + + readClipboardImage: (basePath: string) => + _invoke('CLIPBOARD_READ_IMAGE', { basePath }) as Promise<{ + id: string; + mimeType: string; + size: number; + createdAt: number; + } | null>, + + getPathForFile: (file: File) => { + try { + return webUtils.getPathForFile(file); + } catch (error) { + console.error('[CLIPBOARD] Error getting path for file:', error); + return null; + } + }, + // SEND // ---- relaunch: () => _send('RELAUNCH'), diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index 8411540d0b..615ad6ccd2 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -48,6 +48,7 @@ export enum IPC { CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS', PICK_DIRECTORY = 'PICK_DIRECTORY', + SHOW_OPEN_DIALOG = 'SHOW_OPEN_DIALOG', ANY_FILE_DOWNLOADED = 'ANY_FILE_DOWNLOADED', @@ -66,6 +67,16 @@ export enum IPC { SHARE_NATIVE = 'SHARE_NATIVE', + // Clipboard Images + CLIPBOARD_IMAGE_SAVE = 'CLIPBOARD_IMAGE_SAVE', + CLIPBOARD_IMAGE_LOAD = 'CLIPBOARD_IMAGE_LOAD', + CLIPBOARD_IMAGE_DELETE = 'CLIPBOARD_IMAGE_DELETE', + CLIPBOARD_IMAGE_LIST = 'CLIPBOARD_IMAGE_LIST', + CLIPBOARD_IMAGE_GET_PATH = 'CLIPBOARD_IMAGE_GET_PATH', + CLIPBOARD_GET_FILE_PATHS = 'CLIPBOARD_GET_FILE_PATHS', + CLIPBOARD_COPY_IMAGE_FILE = 'CLIPBOARD_COPY_IMAGE_FILE', + CLIPBOARD_READ_IMAGE = 'CLIPBOARD_READ_IMAGE', + // Plugin Node Execution PLUGIN_EXEC_NODE_SCRIPT = 'PLUGIN_EXEC_NODE_SCRIPT', diff --git a/electron/shared-with-frontend/mime-type-mapping.const.ts b/electron/shared-with-frontend/mime-type-mapping.const.ts new file mode 100644 index 0000000000..935ab70229 --- /dev/null +++ b/electron/shared-with-frontend/mime-type-mapping.const.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export const MIME_TYPE_EXTENSIONS = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', + 'image/bmp': '.bmp', +} as const; + +export const EXTENSION_MIME_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', +} as const; +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/src/app/core/clipboard-image/clipboard-image.service.ts b/src/app/core/clipboard-image/clipboard-image.service.ts new file mode 100644 index 0000000000..73207bb250 --- /dev/null +++ b/src/app/core/clipboard-image/clipboard-image.service.ts @@ -0,0 +1,581 @@ +import { Injectable, inject } from '@angular/core'; +import { IS_ELECTRON } from '../../app.constants'; +import { SnackService } from '../snack/snack.service'; +import { T } from '../../t.const'; +import { GlobalConfigService } from '../../features/config/global-config.service'; +import { getDefaultClipboardImagesPath } from '../../util/get-default-clipboard-images-path'; +import { MIME_TYPE_EXTENSIONS } from '../../../../electron/shared-with-frontend/mime-type-mapping.const'; + +const DB_NAME = 'sp-clipboard-images'; +const DB_VERSION = 1; +const STORE_NAME = 'images'; +const MAX_IMAGE_SIZE_BYTES = 2 * 1024 * 1024; // 2MB +const INDEXEDDB_PROTOCOL = 'indexeddb://clipboard-images/'; + +export interface ClipboardImageEntry { + id: string; + blob: Blob; + mimeType: string; + createdAt: number; + size: number; +} + +export interface ClipboardImageMetadata { + id: string; + mimeType: string; + createdAt: number; + size: number; +} + +export interface ClipboardImagePasteResult { + success: boolean; + imageUrl?: string; + markdownText?: string; + errorMessage?: string; +} + +export interface ClipboardImagePasteProgress { + placeholderText: string; + resultPromise: Promise; +} + +/** + * Unified service for clipboard image operations. + * Handles storage (IndexedDB/Electron), URL resolution, and paste events. + */ +@Injectable({ + providedIn: 'root', +}) +export class ClipboardImageService { + private _snackService = inject(SnackService); + private _globalConfigService = inject(GlobalConfigService); + private _db: IDBDatabase | null = null; + private _dbPromise: Promise | null = null; + private _blobUrlCache = new Map(); + + // =========================================================================== + // Paste handling + // =========================================================================== + + /** + * Handles paste with loading placeholder. Returns immediately with placeholder, + * and provides a promise for the final result. + */ + handlePasteWithProgress(event: ClipboardEvent): ClipboardImagePasteProgress | null { + const clipboardData = event.clipboardData; + if (!clipboardData || !this._hasImageInClipboard(clipboardData)) { + return null; + } + + const placeholderText = '![Saving image...]()'; + const resultPromise = this._saveImageFromClipboard(clipboardData); + + return { placeholderText, resultPromise }; + } + + private _hasImageInClipboard(clipboardData: DataTransfer): boolean { + for (let i = 0; i < clipboardData.items.length; i++) { + if (clipboardData.items[i].type.startsWith('image/')) { + return true; + } + } + for (let i = 0; i < clipboardData.files.length; i++) { + if (clipboardData.files[i].type.startsWith('image/')) { + return true; + } + } + return false; + } + + private async _saveImageFromClipboard( + clipboardData: DataTransfer, + ): Promise { + // In Electron, first check if clipboard contains file paths + if (IS_ELECTRON) { + // Check for files in clipboard using clipboardData.files + if (clipboardData.files && clipboardData.files.length > 0) { + // Try to get file paths using webUtils.getPathForFile + for (let i = 0; i < clipboardData.files.length; i++) { + const file = clipboardData.files[i]; + + if (file.type.startsWith('image/')) { + try { + const filePath = window.ea.getPathForFile(file); + + if (filePath) { + // Don't copy the file, just use the original path directly + const imageUrl = `file://${filePath.replace(/\\/g, '/')}`; + const fileName = file.name || 'image'; + const fileNameWithoutExt = fileName.replace(/\.[^.]+$/, ''); + const markdownText = `![${fileNameWithoutExt}](${imageUrl})`; + + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.CLIPBOARD_IMAGE.PASTE_SUCCESS, + }); + + return { success: true, imageUrl, markdownText }; + } + } catch (error) { + console.error('[CLIPBOARD] Error getting file path:', error); + } + } + } + } + + // Try reading image directly from clipboard using Electron API + const basePath = await this._getElectronImagePath(); + const result = await window.ea.readClipboardImage(basePath); + + if (result) { + // Get the saved file path and generate file:// URL + const savedFilePath = await window.ea.getClipboardImagePath(basePath, result.id); + if (savedFilePath) { + const imageUrl = `file://${savedFilePath.replace(/\\/g, '/')}`; + const markdownText = `![pasted image](${imageUrl})`; + + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.CLIPBOARD_IMAGE.PASTE_SUCCESS, + }); + + return { success: true, imageUrl, markdownText }; + } + } + } + + // Fall back to extracting image from clipboard data + const imageBlob = await this._extractImageFromClipboard(clipboardData); + if (!imageBlob) { + return { success: false }; + } + + try { + const imageUrl = await this.saveImage(imageBlob); + if (!imageUrl) { + return { success: false, errorMessage: 'Failed to save image' }; + } + + const markdownText = `![pasted image](${imageUrl})`; + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.CLIPBOARD_IMAGE.PASTE_SUCCESS, + }); + + return { success: true, imageUrl, markdownText }; + } catch (error) { + console.error('[CLIPBOARD] Error saving clipboard image:', error); + console.error( + '[CLIPBOARD] Error stack:', + error instanceof Error ? error.stack : 'no stack', + ); + return { + success: false, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + private async _tryGetImageFromFilePaths(): Promise { + try { + const filePaths = await window.ea.getClipboardFilePaths(); + if (!filePaths || filePaths.length === 0) { + // Try reading image directly from clipboard + const basePath = await this._getElectronImagePath(); + const result = await window.ea.readClipboardImage(basePath); + + if (result) { + // Get the saved file path and generate file:// URL + const savedFilePath = await window.ea.getClipboardImagePath( + basePath, + result.id, + ); + if (savedFilePath) { + const imageUrl = `file://${savedFilePath.replace(/\\/g, '/')}`; + const markdownText = `![pasted image](${imageUrl})`; + + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.CLIPBOARD_IMAGE.PASTE_SUCCESS, + }); + + return { success: true, imageUrl, markdownText }; + } + } + + return null; + } + + // Use the first image file found + const filePath = filePaths[0]; + const basePath = await this._getElectronImagePath(); + + // Copy the file to clipboard-images directory + const result = await window.ea.copyClipboardImageFile(basePath, filePath); + if (!result) { + return null; + } + + // Get the saved file path and generate file:// URL + const savedFilePath = await window.ea.getClipboardImagePath(basePath, result.id); + if (!savedFilePath) { + return null; + } + + const imageUrl = `file://${savedFilePath.replace(/\\/g, '/')}`; + const fileName = filePath.split(/[\\/]/).pop() || 'image'; + const fileNameWithoutExt = fileName.replace(/\.[^.]+$/, ''); + const markdownText = `![${fileNameWithoutExt}](${imageUrl})`; + + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.CLIPBOARD_IMAGE.PASTE_SUCCESS, + }); + + return { success: true, imageUrl, markdownText }; + } catch (error) { + console.error('Error getting image from file paths:', error); + return null; // Fall back to regular clipboard handling + } + } + + private async _extractImageFromClipboard( + clipboardData: DataTransfer, + ): Promise { + for (let i = 0; i < clipboardData.items.length; i++) { + const item = clipboardData.items[i]; + if (item.type.startsWith('image/')) { + const blob = item.getAsFile(); + if (blob) return blob; + } + } + for (let i = 0; i < clipboardData.files.length; i++) { + const file = clipboardData.files[i]; + if (file.type.startsWith('image/')) { + return file; + } + } + return null; + } + + // =========================================================================== + // URL resolution + // =========================================================================== + + isIndexedDbUrl(url: string): boolean { + return url.startsWith(INDEXEDDB_PROTOCOL); + } + + extractImageId(url: string): string | null { + const match = url.match(/^indexeddb:\/\/clipboard-images\/([^?\s=]+)/); + return match ? match[1] : null; + } + + /** + * Pre-resolves all indexeddb:// URLs in markdown content to blob/file URLs. + * Call this before rendering markdown to ensure images display correctly. + */ + async resolveMarkdownImages(markdown: string): Promise { + const urlPattern = /indexeddb:\/\/clipboard-images\/[^)\s=]+/g; + const matches = markdown.match(urlPattern); + if (!matches) return markdown; + + const uniqueUrls = [...new Set(matches)]; + const resolved = new Map(); + + await Promise.all( + uniqueUrls.map(async (url) => { + const blobUrl = await this.resolveIndexedDbUrl(url); + if (blobUrl) { + resolved.set(url, blobUrl); + } + }), + ); + + let result = markdown; + for (const [original, replacement] of resolved) { + result = result.split(original).join(replacement); + } + return result; + } + + async resolveIndexedDbUrl(indexedDbUrl: string): Promise { + if (!this.isIndexedDbUrl(indexedDbUrl)) return null; + + const imageId = this.extractImageId(indexedDbUrl); + if (!imageId) return null; + + const cached = this._blobUrlCache.get(imageId); + if (cached) return cached; + + try { + const blob = await this.getImage(imageId); + if (!blob) return null; + + const blobUrl = URL.createObjectURL(blob); + this._blobUrlCache.set(imageId, blobUrl); + return blobUrl; + } catch (error) { + console.error('Error resolving indexeddb URL for clipboard image:', error); + return null; + } + } + + // =========================================================================== + // Storage operations + // =========================================================================== + + async saveImage(blob: Blob, preferredId?: string): Promise { + // Only enforce size limit in web environment (IndexedDB has limitations) + if (!IS_ELECTRON && blob.size > MAX_IMAGE_SIZE_BYTES) { + this._snackService.open({ + type: 'ERROR', + msg: T.F.CLIPBOARD_IMAGE.SIZE_EXCEEDED, + translateParams: { + maxSize: this._formatSize(MAX_IMAGE_SIZE_BYTES), + actualSize: this._formatSize(blob.size), + }, + }); + return null; + } + + const id = preferredId || this._generateImageId(); + const mimeType = blob.type || 'image/png'; + + return IS_ELECTRON + ? this._saveImageElectron(id, blob, mimeType) + : this._saveImageWeb(id, blob, mimeType); + } + + async getImage(id: string): Promise { + return IS_ELECTRON ? this._getImageElectron(id) : this._getImageWeb(id); + } + + async deleteImage(id: string): Promise { + return IS_ELECTRON ? this._deleteImageElectron(id) : this._deleteImageWeb(id); + } + + async listImages(): Promise { + return IS_ELECTRON ? this._listImagesElectron() : this._listImagesWeb(); + } + + getImageUrl(id: string): string { + return `${INDEXEDDB_PROTOCOL}${id}`; + } + + private _generateImageId(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 10); + return `clip-${timestamp}-${random}`; + } + + // =========================================================================== + // Web (IndexedDB) implementation + // =========================================================================== + + private async _getDb(): Promise { + if (this._db) return this._db; + if (this._dbPromise) return this._dbPromise; + + this._dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (): void => { + reject(new Error('Failed to open clipboard images database')); + }; + + request.onsuccess = (): void => { + this._db = request.result; + resolve(request.result); + }; + + request.onupgradeneeded = (event): void => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('createdAt', 'createdAt', { unique: false }); + } + }; + }); + + return this._dbPromise; + } + + private async _saveImageWeb(id: string, blob: Blob, mimeType: string): Promise { + const db = await this._getDb(); + const entry: ClipboardImageEntry = { + id, + blob, + mimeType, + createdAt: Date.now(), + size: blob.size, + }; + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(entry); + + request.onsuccess = (): void => resolve(this.getImageUrl(id)); + request.onerror = (): void => { + const error = request.error; + if (error?.name === 'QuotaExceededError') { + this._snackService.open({ + type: 'ERROR', + msg: T.F.CLIPBOARD_IMAGE.STORAGE_QUOTA_EXCEEDED, + }); + reject(new Error('Storage quota exceeded')); + } else { + reject(new Error('Failed to save clipboard image')); + } + }; + }); + } + + private async _getImageWeb(id: string): Promise { + const db = await this._getDb(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(id); + + request.onsuccess = (): void => { + const entry = request.result as ClipboardImageEntry | undefined; + resolve(entry?.blob ?? null); + }; + request.onerror = (): void => reject(new Error('Failed to get clipboard image')); + }); + } + + private async _deleteImageWeb(id: string): Promise { + // Revoke cached blob URL to prevent memory leak + const cachedUrl = this._blobUrlCache.get(id); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + this._blobUrlCache.delete(id); + } + + const db = await this._getDb(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(id); + + request.onsuccess = (): void => resolve(true); + request.onerror = (): void => reject(new Error('Failed to delete clipboard image')); + }); + } + + private async _listImagesWeb(): Promise { + const db = await this._getDb(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onsuccess = (): void => { + const entries = request.result as ClipboardImageEntry[]; + resolve( + entries.map((entry) => ({ + id: entry.id, + mimeType: entry.mimeType, + createdAt: entry.createdAt, + size: entry.size, + })), + ); + }; + request.onerror = (): void => reject(new Error('Failed to list clipboard images')); + }); + } + + // =========================================================================== + // Electron (Filesystem) implementation + // =========================================================================== + + private async _getElectronImagePath(): Promise { + // Always fetch from config to respect user changes + const customPath = this._globalConfigService.clipboardImages()?.imagePath; + if (customPath) { + return customPath; + } + + // Use default path + return getDefaultClipboardImagesPath(); + } + + private async _saveImageElectron( + id: string, + blob: Blob, + mimeType: string, + ): Promise { + const basePath = await this._getElectronImagePath(); + const ext = this._getExtensionFromMimeType(mimeType); + const fileName = `${id}${ext}`; + + const arrayBuffer = await blob.arrayBuffer(); + const base64 = this._arrayBufferToBase64(arrayBuffer); + + const savedPath = await window.ea.saveClipboardImage( + basePath, + fileName, + base64, + mimeType, + ); + + // Return file:// URL directly for Electron + const fileUrl = `file://${savedPath.replace(/\\/g, '/')}`; + return fileUrl; + } + + private async _getImageElectron(id: string): Promise { + const basePath = await this._getElectronImagePath(); + const result = await window.ea.loadClipboardImage(basePath, id); + if (!result) return null; + + const binary = atob(result.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: result.mimeType }); + } + + private async _deleteImageElectron(id: string): Promise { + // Clean up cache entry (file:// URLs don't need revocation) + this._blobUrlCache.delete(id); + + const basePath = await this._getElectronImagePath(); + return window.ea.deleteClipboardImage(basePath, id); + } + + private async _listImagesElectron(): Promise { + const basePath = await this._getElectronImagePath(); + return window.ea.listClipboardImages(basePath); + } + + // =========================================================================== + // Utility methods + // =========================================================================== + + private _formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private _getExtensionFromMimeType(mimeType: string): string { + return MIME_TYPE_EXTENSIONS[mimeType as keyof typeof MIME_TYPE_EXTENSIONS] || '.png'; + } + + private _arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = ''; + const bytes = new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } +} diff --git a/src/app/core/clipboard-image/clipboard-paste-handler.service.ts b/src/app/core/clipboard-image/clipboard-paste-handler.service.ts new file mode 100644 index 0000000000..f3275b9079 --- /dev/null +++ b/src/app/core/clipboard-image/clipboard-paste-handler.service.ts @@ -0,0 +1,93 @@ +import { Injectable, inject } from '@angular/core'; +import { ClipboardImageService } from './clipboard-image.service'; +import { TaskAttachmentService } from '../../features/tasks/task-attachment/task-attachment.service'; + +// Paste context interface +export interface PasteContext { + currentPlaceholder: { + get(): string | null; + set(val: string | null): void; + }; + getContent(): string; + setContent(content: string): void; + getTextarea(): HTMLTextAreaElement | null; + getTaskId(): string | null; + onPasteComplete?(content: string): void; +} + +@Injectable({ + providedIn: 'root', +}) +export class ClipboardPasteHandlerService { + private _clipboardImageService = inject(ClipboardImageService); + private _taskAttachmentService = inject(TaskAttachmentService); + + async handlePaste(ev: ClipboardEvent, context: PasteContext): Promise { + if (!ev.clipboardData) return false; + + const progress = this._clipboardImageService.handlePasteWithProgress(ev); + if (!progress) return false; + + ev.preventDefault(); + + // Clean up old placeholder if exists + if (context.currentPlaceholder.get()) { + const cleaned = context.getContent().replace(context.currentPlaceholder.get()!, ''); + context.setContent(cleaned); + } + + const textarea = context.getTextarea(); + if (!textarea) return false; + + const { value, selectionStart, selectionEnd } = textarea; + + // Track and insert placeholder + context.currentPlaceholder.set(progress.placeholderText); + const newContent = + value.substring(0, selectionStart) + + progress.placeholderText + + value.substring(selectionEnd); + context.setContent(newContent); + + // Wait for result + const result = await progress.resultPromise; + + // Only update if still current operation + if (context.currentPlaceholder.get() === progress.placeholderText) { + if (result.success && result.markdownText) { + // Replace placeholder + const finalContent = context + .getContent() + .replace(progress.placeholderText, result.markdownText); + context.setContent(finalContent); + + // Add attachment if taskId provided + const taskId = context.getTaskId(); + if (taskId && result.imageUrl) { + this._taskAttachmentService.addAttachment(taskId, { + id: null, + type: 'IMG', + path: result.imageUrl, + title: 'Pasted image', + }); + } + + // Move cursor + const newCursorPos = selectionStart + result.markdownText.length; + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(newCursorPos, newCursorPos); + context.onPasteComplete?.(finalContent); + }); + } else { + // Remove placeholder on failure + const cleaned = context.getContent().replace(progress.placeholderText, ''); + context.setContent(cleaned); + } + + context.currentPlaceholder.set(null); + } + + return true; + } +} diff --git a/src/app/core/clipboard-image/index.ts b/src/app/core/clipboard-image/index.ts new file mode 100644 index 0000000000..3f09a06da4 --- /dev/null +++ b/src/app/core/clipboard-image/index.ts @@ -0,0 +1,2 @@ +export * from './clipboard-image.service'; +export * from './resolve-clipboard-images.directive'; diff --git a/src/app/core/clipboard-image/resolve-clipboard-images.directive.ts b/src/app/core/clipboard-image/resolve-clipboard-images.directive.ts new file mode 100644 index 0000000000..ea675631c1 --- /dev/null +++ b/src/app/core/clipboard-image/resolve-clipboard-images.directive.ts @@ -0,0 +1,110 @@ +import { + Directive, + ElementRef, + inject, + OnDestroy, + OnInit, + Input, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { ClipboardImageService } from './clipboard-image.service'; + +/** + * Directive that resolves indexeddb:// URLs in img elements within the host element. + * Apply this directive to elements containing markdown-rendered content. + */ +@Directive({ + selector: '[spResolveClipboardImages]', + standalone: true, +}) +export class ResolveClipboardImagesDirective implements OnInit, OnDestroy, OnChanges { + private _elementRef = inject(ElementRef); + private _clipboardImageService = inject(ClipboardImageService); + private _observer: MutationObserver | null = null; + private _resolvedImageIds = new Set(); + private _debounceTimer: ReturnType | null = null; + private _isResolving = false; + + @Input() spResolveClipboardImages: string | undefined; + + ngOnInit(): void { + this._setupMutationObserver(); + // Initial resolve with delay to allow markdown to render + setTimeout(() => this._resolveImages(), 100); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['spResolveClipboardImages']) { + this._resolvedImageIds.clear(); + // Delay to allow markdown to re-render + setTimeout(() => this._resolveImages(), 100); + } + } + + ngOnDestroy(): void { + this._observer?.disconnect(); + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + } + + private _setupMutationObserver(): void { + this._observer = new MutationObserver(() => this._resolveImagesDebounced()); + this._observer.observe(this._elementRef.nativeElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['src', 'class'], + }); + } + + private _resolveImagesDebounced(): void { + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + this._debounceTimer = setTimeout(() => this._resolveImages(), 50); + } + + private async _resolveImages(): Promise { + if (this._isResolving) return; + this._isResolving = true; + + try { + const element = this._elementRef.nativeElement as HTMLElement; + // Find images with either the indexeddb-image class OR indexeddb:// URLs in src + const images = element.querySelectorAll( + 'img.indexeddb-image, img[src^="indexeddb://"]', + ); + + for (const img of Array.from(images)) { + // Read the indexeddb:// URL directly from src attribute + const indexedDbUrl = img.getAttribute('src'); + if (!indexedDbUrl || !indexedDbUrl.startsWith('indexeddb://')) continue; + + const imageId = this._clipboardImageService.extractImageId(indexedDbUrl); + if (!imageId || this._resolvedImageIds.has(imageId)) continue; + + try { + const resolvedUrl = + await this._clipboardImageService.resolveIndexedDbUrl(indexedDbUrl); + if (resolvedUrl) { + img.src = resolvedUrl; + img.classList.remove('indexeddb-image'); + img.classList.add('indexeddb-image-resolved'); + this._resolvedImageIds.add(imageId); + } else { + img.alt = 'Image not found'; + img.classList.add('indexeddb-image-error'); + } + } catch (error) { + console.error('Error resolving clipboard image:', error); + img.alt = 'Error loading image'; + img.classList.add('indexeddb-image-error'); + } + } + } finally { + this._isResolving = false; + } + } +} diff --git a/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts b/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts new file mode 100644 index 0000000000..10c7c018f3 --- /dev/null +++ b/src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts @@ -0,0 +1,51 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { ClipboardImageService } from './clipboard-image.service'; +import { Observable, from, of, defer } from 'rxjs'; +import { map, shareReplay, catchError } from 'rxjs/operators'; + +@Pipe({ + name: 'resolveClipboardUrl', + standalone: true, +}) +export class ResolveClipboardUrlPipe implements PipeTransform { + private readonly _clipboardImageService = inject(ClipboardImageService); + private readonly _cache = new Map>(); + + transform(url: string | undefined | null): Observable { + if (!url) { + return of(''); + } + + // If it's not an indexeddb URL, return as-is + if (!url.startsWith('indexeddb://clipboard-images/')) { + return of(url); + } + + // Check cache first - if already resolving or resolved, return the cached observable + const cached = this._cache.get(url); + if (cached) { + return cached; + } + + // Use defer to ensure the promise is created fresh and properly handled + const resolved$ = defer(() => + from(this._clipboardImageService.resolveIndexedDbUrl(url)), + ).pipe( + map((resolvedUrl) => { + if (resolvedUrl) { + return resolvedUrl; + } + return url; + }), + catchError((error) => { + console.error('Error resolving clipboard URL:', error); + return of(url); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + // Cache immediately before returning so concurrent calls will get the same observable + this._cache.set(url, resolved$); + return resolved$; + } +} diff --git a/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.html b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.html new file mode 100644 index 0000000000..47f6f7e30c --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.html @@ -0,0 +1,47 @@ +
+ @if (!IS_ELECTRON) { +
+

{{ T.GCF.CLIPBOARD_IMAGES.MANAGE_TITLE | translate }}

+

{{ T.GCF.CLIPBOARD_IMAGES.MANAGE_DESCRIPTION | translate }}

+ +
+ } + + @if (IS_ELECTRON) { +
+

{{ T.GCF.CLIPBOARD_IMAGES.PATH_TITLE | translate }}

+

{{ T.GCF.CLIPBOARD_IMAGES.PATH_DESCRIPTION | translate }}

+ + + {{ T.GCF.CLIPBOARD_IMAGES.PATH_LABEL | translate }} + @if (!imagePath && defaultImagePath) { + ({{ defaultImagePath }}) + } + + + + + +
+ } +
diff --git a/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.scss b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.scss new file mode 100644 index 0000000000..b6d225bea7 --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.scss @@ -0,0 +1,32 @@ +.clipboard-images-cfg { + padding: 16px 0; + + section { + margin-bottom: 24px; + } + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 500; + } + + p { + margin: 0 0 16px 0; + color: var(--text-color-muted); + } + + mat-form-field { + display: block; + margin-bottom: 8px; + } + + button { + margin-right: 8px; + } + + .manage-images-section { + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + } +} diff --git a/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.ts b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.ts new file mode 100644 index 0000000000..4966a5b909 --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/clipboard-images-cfg.component.ts @@ -0,0 +1,128 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnInit, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslatePipe } from '@ngx-translate/core'; +import { T } from '../../../t.const'; +import { + ClipboardImagesConfig, + ConfigFormSection, + GlobalConfigSectionKey, +} from '../global-config.model'; +import { ProjectCfgFormKey } from '../../project/project.model'; +import { IS_ELECTRON } from '../../../app.constants'; +import { MatFormField, MatHint, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { FormsModule } from '@angular/forms'; +import { DialogClipboardImagesManagerComponent } from './dialog-clipboard-images-manager/dialog-clipboard-images-manager.component'; +import { getDefaultClipboardImagesPath } from '../../../util/get-default-clipboard-images-path'; + +@Component({ + selector: 'clipboard-images-cfg', + templateUrl: './clipboard-images-cfg.component.html', + styleUrls: ['./clipboard-images-cfg.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatButton, + MatIcon, + TranslatePipe, + MatFormField, + MatLabel, + MatHint, + MatInput, + FormsModule, + ], +}) +export class ClipboardImagesCfgComponent implements OnInit { + private readonly _matDialog = inject(MatDialog); + private readonly _cd = inject(ChangeDetectorRef); + + private _cfg?: ClipboardImagesConfig; + + @Input() + get cfg(): ClipboardImagesConfig | undefined { + return this._cfg; + } + set cfg(value: ClipboardImagesConfig | undefined) { + this._cfg = value; + this.imagePath = value?.imagePath ?? null; + this._cd.markForCheck(); + } + @Input() section?: ConfigFormSection; + + readonly save = output<{ + sectionKey: GlobalConfigSectionKey | ProjectCfgFormKey; + config: ClipboardImagesConfig; + }>(); + + readonly T = T; + readonly IS_ELECTRON = IS_ELECTRON; + imagePath: string | null = null; + defaultImagePath: string = ''; + + async ngOnInit(): Promise { + this.imagePath = this.cfg?.imagePath ?? null; + + if (IS_ELECTRON) { + await this.loadDefaultPath(); + } + } + + private async loadDefaultPath(): Promise { + try { + this.defaultImagePath = await getDefaultClipboardImagesPath(); + this._cd.markForCheck(); + } catch (error) { + console.error('Error loading default clipboard image path:', error); + } + } + + openImageManager(): void { + this._matDialog.open(DialogClipboardImagesManagerComponent, { + width: '600px', + maxHeight: '80vh', + }); + } + + getImagePath(): string { + return this.imagePath || this.defaultImagePath; + } + + async selectImagePath(): Promise { + if (!IS_ELECTRON) return; + + const path = await window.ea.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + title: this.T.GCF.CLIPBOARD_IMAGES.SELECT_PATH_TITLE, + defaultPath: this.getImagePath(), + }); + + if (path && path.length > 0) { + this.imagePath = path[0]; + this.saveConfig(); + } + } + + onImagePathChange(): void { + this.saveConfig(); + } + + private saveConfig(): void { + if (!this.section) return; + + this.save.emit({ + sectionKey: this.section.key, + config: { + imagePath: this.imagePath, + }, + }); + } +} diff --git a/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.html b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.html new file mode 100644 index 0000000000..0bae9c3e5b --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.html @@ -0,0 +1,108 @@ +

{{ T.GCF.CLIPBOARD_IMAGES.MANAGER_TITLE | translate }}

+ + + @if (isLoading()) { +
+ +

{{ T.GCF.CLIPBOARD_IMAGES.LOADING | translate }}

+
+ } @else { + @if (images().length === 0) { +
+ image_not_supported +

{{ T.GCF.CLIPBOARD_IMAGES.NO_IMAGES | translate }}

+
+ } @else { +
+
+

+ {{ T.GCF.CLIPBOARD_IMAGES.TOTAL_IMAGES | translate }}: + {{ images().length }} +

+

+ {{ T.GCF.CLIPBOARD_IMAGES.TOTAL_SIZE | translate }}: + {{ getTotalSize() }} +

+
+ + {{ T.GCF.CLIPBOARD_IMAGES.SORT_BY | translate }} + + {{ + T.GCF.CLIPBOARD_IMAGES.SORT_NEWEST | translate + }} + {{ + T.GCF.CLIPBOARD_IMAGES.SORT_OLDEST | translate + }} + {{ + T.GCF.CLIPBOARD_IMAGES.SORT_LARGEST | translate + }} + {{ + T.GCF.CLIPBOARD_IMAGES.SORT_SMALLEST | translate + }} + + +
+ +
+ @for (image of sortedImages(); track image.id) { +
+
+ @if (imageUrls().get(image.id)) { + + } @else { + image + } +
+
+
{{ image.id }}
+
+ {{ image.createdAt | date: 'medium' }} + + {{ formatSize(image.size) }} + + {{ image.mimeType }} +
+
+ +
+ } +
+ } + } +
+ + + @if (!isLoading() && images().length > 0) { + + } + + diff --git a/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.scss b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.scss new file mode 100644 index 0000000000..2206b1e9f7 --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.scss @@ -0,0 +1,122 @@ +::ng-deep .mat-mdc-dialog-container { + .mat-mdc-dialog-content { + padding: 20px 24px; + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + gap: 16px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: var(--text-color-muted); + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + margin-bottom: 16px; + } + + p { + margin: 0; + font-size: 16px; + } +} + +.summary { + padding: 16px; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + .summary-info { + flex: 1; + + p { + margin: 4px 0; + } + } + + .sort-field { + min-width: 160px; + margin-bottom: -1.25em; + } +} + +.images-list { + max-height: 400px; + overflow-y: auto; +} + +.image-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + + &:hover { + background: rgba(0, 0, 0, 0.03); + } +} + +.image-preview { + width: 60px; + height: 60px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + overflow: hidden; + background: rgba(0, 0, 0, 0.02); + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + mat-icon { + color: rgba(0, 0, 0, 0.3); + } +} + +.image-info { + flex: 1; + min-width: 0; +} + +.image-id { + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-details { + font-size: 12px; + color: var(--text-color-muted); + margin-top: 4px; + + span { + margin-right: 4px; + } +} diff --git a/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.ts b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.ts new file mode 100644 index 0000000000..da2e1b109e --- /dev/null +++ b/src/app/features/config/clipboard-images-cfg/dialog-clipboard-images-manager/dialog-clipboard-images-manager.component.ts @@ -0,0 +1,189 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + OnInit, + signal, +} from '@angular/core'; +import { MatDialogRef, MatDialogModule } from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { T } from '../../../../t.const'; +import { + ClipboardImageService, + ClipboardImageMetadata, +} from '../../../../core/clipboard-image/clipboard-image.service'; +import { SnackService } from '../../../../core/snack/snack.service'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { DatePipe } from '@angular/common'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatSelect } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; + +type SortOrder = 'newest' | 'oldest' | 'largest' | 'smallest'; + +@Component({ + selector: 'dialog-clipboard-images-manager', + templateUrl: './dialog-clipboard-images-manager.component.html', + styleUrls: ['./dialog-clipboard-images-manager.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatDialogModule, + MatButton, + MatIcon, + TranslatePipe, + MatProgressSpinner, + DatePipe, + MatTooltip, + MatFormField, + MatLabel, + MatSelect, + MatOption, + ], +}) +export class DialogClipboardImagesManagerComponent implements OnInit { + private readonly _matDialogRef = inject( + MatDialogRef, + ); + private readonly _clipboardImageService = inject(ClipboardImageService); + private readonly _snackService = inject(SnackService); + private readonly _translateService = inject(TranslateService); + + readonly T = T; + readonly images = signal([]); + readonly isLoading = signal(true); + readonly imageUrls = signal>(new Map()); + readonly sortOrder = signal('newest'); + + readonly sortedImages = computed(() => { + const images = this.images(); + const order = this.sortOrder(); + + if (images.length === 0) return images; + + return [...images].sort((a, b) => { + switch (order) { + case 'newest': + return b.createdAt - a.createdAt; + case 'oldest': + return a.createdAt - b.createdAt; + case 'largest': + return b.size - a.size; + case 'smallest': + return a.size - b.size; + } + }); + }); + + async ngOnInit(): Promise { + await this.loadImages(); + } + + async loadImages(): Promise { + this.isLoading.set(true); + try { + const images = await this._clipboardImageService.listImages(); + this.images.set(images); + + // Load image URLs for preview + const urlMap = new Map(); + for (const image of images) { + const url = await this._clipboardImageService.resolveIndexedDbUrl( + this._clipboardImageService.getImageUrl(image.id), + ); + if (url) { + urlMap.set(image.id, url); + } + } + this.imageUrls.set(urlMap); + } catch (error) { + console.error('Error loading clipboard images:', error); + this._snackService.open({ + type: 'ERROR', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_LOADING), + }); + } finally { + this.isLoading.set(false); + } + } + + async deleteImage(image: ClipboardImageMetadata): Promise { + try { + const success = await this._clipboardImageService.deleteImage(image.id); + if (success) { + // Update local state instead of reloading all images + this.images.update((images) => images.filter((img) => img.id !== image.id)); + this._snackService.open({ + type: 'SUCCESS', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.DELETE_SUCCESS), + }); + } else { + throw new Error('Delete operation returned false'); + } + } catch (error) { + console.error('Error deleting clipboard image:', error); + this._snackService.open({ + type: 'ERROR', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_DELETING), + }); + } + } + + async deleteAll(): Promise { + const images = this.images(); + if (images.length === 0) return; + + try { + const results = await Promise.allSettled( + images.map((image) => this._clipboardImageService.deleteImage(image.id)), + ); + + const failedCount = results.filter((r) => r.status === 'rejected').length; + + if (failedCount === 0) { + this.images.set([]); + this.imageUrls.set(new Map()); + this._snackService.open({ + type: 'SUCCESS', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.DELETE_ALL_SUCCESS), + }); + } else { + // Reload to see which images remain + await this.loadImages(); + this._snackService.open({ + type: 'ERROR', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_DELETING_ALL), + }); + } + } catch (error) { + console.error('Error deleting all clipboard images:', error); + await this.loadImages(); + this._snackService.open({ + type: 'ERROR', + msg: this._translateService.instant(T.GCF.CLIPBOARD_IMAGES.ERROR_DELETING_ALL), + }); + } + } + + formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + getTotalSize(): string { + const total = this.images().reduce((sum, img) => sum + img.size, 0); + return this.formatSize(total); + } + + onSortChange(order: SortOrder): void { + this.sortOrder.set(order); + } + + close(): void { + this._matDialogRef.close(); + } +} diff --git a/src/app/features/config/custom-config-form-section-component.ts b/src/app/features/config/custom-config-form-section-component.ts index 2adb6660c1..ab1403bf6d 100644 --- a/src/app/features/config/custom-config-form-section-component.ts +++ b/src/app/features/config/custom-config-form-section-component.ts @@ -2,6 +2,7 @@ import { FileImexComponent } from '../../imex/file-imex/file-imex.component'; import { SimpleCounterCfgComponent } from '../simple-counter/simple-counter-cfg/simple-counter-cfg.component'; import { CustomCfgSection } from './global-config.model'; import { ClickUpAdditionalCfgComponent } from '../issue/providers/clickup/clickup-view-components/clickup-cfg/clickup-additional-cfg.component'; +import { ClipboardImagesCfgComponent } from './clipboard-images-cfg/clipboard-images-cfg.component'; export const customConfigFormSectionComponent = ( customSection: CustomCfgSection, @@ -16,6 +17,9 @@ export const customConfigFormSectionComponent = ( case 'CLICKUP_CFG': return ClickUpAdditionalCfgComponent; + case 'CLIPBOARD_IMAGES_CFG': + return ClipboardImagesCfgComponent; + default: throw new Error('Invalid component'); } diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index 3e8d7e1978..70ad1a9f73 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -97,6 +97,9 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { isSyncSessionWithTracking: false, isStartInBackground: false, }, + clipboardImages: { + imagePath: null, + }, pomodoro: { duration: 25 * minute, breakDuration: 5 * minute, diff --git a/src/app/features/config/form-cfgs/clipboard-images-form.const.ts b/src/app/features/config/form-cfgs/clipboard-images-form.const.ts new file mode 100644 index 0000000000..c4ee699ab4 --- /dev/null +++ b/src/app/features/config/form-cfgs/clipboard-images-form.const.ts @@ -0,0 +1,10 @@ +import { ConfigFormSection } from '../global-config.model'; +import { T } from '../../../t.const'; + +export const CLIPBOARD_IMAGES_FORM: ConfigFormSection<{ [key: string]: any }> = { + title: T.GCF.CLIPBOARD_IMAGES.TITLE, + // @ts-ignore + key: 'clipboardImages', + help: T.GCF.CLIPBOARD_IMAGES.HELP, + customSection: 'CLIPBOARD_IMAGES_CFG', +}; diff --git a/src/app/features/config/global-config-form-config.const.ts b/src/app/features/config/global-config-form-config.const.ts index ab481bcc87..1cb1f348f7 100644 --- a/src/app/features/config/global-config-form-config.const.ts +++ b/src/app/features/config/global-config-form-config.const.ts @@ -16,6 +16,7 @@ import { VOICE_REMINDER_FORM } from './form-cfgs/voice-reminder-form.const'; import { FOCUS_MODE_FORM_CFG } from './form-cfgs/focus-mode-form.const'; import { REMINDER_FORM_CFG } from './form-cfgs/reminder-form.const'; import { SHORT_SYNTAX_FORM_CFG } from './form-cfgs/short-syntax-form.const'; +import { CLIPBOARD_IMAGES_FORM } from './form-cfgs/clipboard-images-form.const'; import { TASKS_SETTINGS_FORM_CFG } from './form-cfgs/tasks-settings-form.const'; const filterGlobalConfigForm = (cfg: ConfigFormSection): boolean => { @@ -31,6 +32,7 @@ export const GLOBAL_GENERAL_FORM_CONFIG: ConfigFormConfig = [ APP_FEATURES_FORM_CFG, MISC_SETTINGS_FORM_CFG, KEYBOARD_SETTINGS_FORM_CFG, + CLIPBOARD_IMAGES_FORM, ].filter(filterGlobalConfigForm); // Tab: Time & Tracking - Time Tracking, Idle, Schedule, Reminder diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 6febfcbc22..fd5fc0a74c 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -222,6 +222,10 @@ export type FocusModeConfig = Readonly<{ isManualBreakStart?: boolean; }>; +export type ClipboardImagesConfig = Readonly<{ + imagePath?: string | null; +}>; + export type DailySummaryNote = Readonly<{ txt?: string; lastUpdateDayStr?: string; @@ -246,6 +250,7 @@ export type GlobalConfigState = Readonly<{ schedule: ScheduleConfig; dominaMode: DominaModeConfig; focusMode: FocusModeConfig; + clipboardImages: ClipboardImagesConfig; sync: SyncConfig; dailySummaryNote?: DailySummaryNote; @@ -261,7 +266,8 @@ export type GlobalSectionConfig = | ScheduleConfig | ReminderConfig | DailySummaryNote - | SyncConfig; + | SyncConfig + | ClipboardImagesConfig; type Omit = Pick>; export interface LimitedFormlyFieldConfig extends Omit< @@ -276,7 +282,8 @@ export type CustomCfgSection = | 'JIRA_CFG' | 'SIMPLE_COUNTER_CFG' | 'OPENPROJECT_CFG' - | 'CLICKUP_CFG'; + | 'CLICKUP_CFG' + | 'CLIPBOARD_IMAGES_CFG'; // Intermediate model export interface ConfigFormSection { diff --git a/src/app/features/config/global-config.service.ts b/src/app/features/config/global-config.service.ts index 97fe921cb8..3c0a8e0e30 100644 --- a/src/app/features/config/global-config.service.ts +++ b/src/app/features/config/global-config.service.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { DEFAULT_GLOBAL_CONFIG } from './default-global-config.const'; import { AppFeaturesConfig, + ClipboardImagesConfig, EvaluationConfig, GlobalConfigSectionKey, GlobalConfigState, @@ -23,6 +24,7 @@ import { } from './global-config.model'; import { selectAppFeaturesConfig, + selectClipboardImagesConfig, selectConfigFeatureState, selectEvaluationConfig, selectIdleConfig, @@ -100,6 +102,11 @@ export class GlobalConfigService { shareReplay(1), ); + clipboardImages$: Observable = this._store.pipe( + select(selectClipboardImagesConfig), + shareReplay(1), + ); + timelineCfg$: Observable = this._store.pipe( select(selectTimelineConfig), ); @@ -148,6 +155,10 @@ export class GlobalConfigService { initialValue: DEFAULT_GLOBAL_CONFIG.pomodoro, }, ); + readonly clipboardImages: Signal = toSignal( + this.clipboardImages$, + { initialValue: undefined }, + ); readonly timelineCfg: Signal = toSignal(this.timelineCfg$, { initialValue: undefined, }); diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index 15ab28c15e..c62256a79c 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -2,6 +2,7 @@ import { updateGlobalConfigSection } from './global-config.actions'; import { createFeatureSelector, createReducer, createSelector, on } from '@ngrx/store'; import { AppFeaturesConfig, + ClipboardImagesConfig, DominaModeConfig, EvaluationConfig, FocusModeConfig, @@ -75,6 +76,10 @@ export const selectFocusModeConfig = createSelector( selectConfigFeatureState, (cfg): FocusModeConfig => cfg?.focusMode ?? DEFAULT_GLOBAL_CONFIG.focusMode, ); +export const selectClipboardImagesConfig = createSelector( + selectConfigFeatureState, + (cfg): ClipboardImagesConfig => cfg.clipboardImages, +); export const selectPomodoroConfig = createSelector( selectConfigFeatureState, (cfg): PomodoroConfig => cfg?.pomodoro ?? DEFAULT_GLOBAL_CONFIG.pomodoro, diff --git a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.html b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.html index 7603b0e6d4..4e60e265be 100644 --- a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.html +++ b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.html @@ -314,6 +314,7 @@ [isShowControls]="true" [isDefaultText]="!ct.notes" [model]="ct.notes || defaultTaskNotes()" + [taskId]="ct.id" > @if (ct.attachments.length) { diff --git a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts index c158ddc322..ceb70f01d9 100644 --- a/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts +++ b/src/app/features/focus-mode/focus-mode-main/focus-mode-main.component.spec.ts @@ -3,6 +3,8 @@ import { Store } from '@ngrx/store'; import { BehaviorSubject, of } from 'rxjs'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; import { FocusModeMainComponent } from './focus-mode-main.component'; import { GlobalConfigService } from '../../config/global-config.service'; import { TaskService } from '../../tasks/task.service'; @@ -37,7 +39,7 @@ class MockFocusModeTaskSelectorComponent { describe('FocusModeMainComponent', () => { let component: FocusModeMainComponent; let fixture: ComponentFixture; - let mockStore: jasmine.SpyObj; + let mockStore: MockStore; let mockTaskService: jasmine.SpyObj; let mockTaskAttachmentService: jasmine.SpyObj; let mockIssueService: jasmine.SpyObj; @@ -64,9 +66,6 @@ describe('FocusModeMainComponent', () => { } as TaskCopy; beforeEach(async () => { - const storeSpy = jasmine.createSpyObj('Store', ['dispatch', 'select']); - storeSpy.select.and.returnValue(of([])); - const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], { tasks: jasmine.createSpy().and.returnValue({ notesTemplate: 'Default task notes template', @@ -118,7 +117,8 @@ describe('FocusModeMainComponent', () => { EffectsModule.forRoot([]), ], providers: [ - { provide: Store, useValue: storeSpy }, + provideMockStore(), + provideMockActions(() => of()), { provide: GlobalConfigService, useValue: globalConfigServiceSpy }, { provide: TaskService, useValue: taskServiceSpy }, { provide: TaskAttachmentService, useValue: taskAttachmentServiceSpy }, @@ -136,7 +136,8 @@ describe('FocusModeMainComponent', () => { fixture = TestBed.createComponent(FocusModeMainComponent); component = fixture.componentInstance; - mockStore = TestBed.inject(Store) as jasmine.SpyObj; + mockStore = TestBed.inject(Store) as MockStore; + spyOn(mockStore, 'dispatch'); mockTaskService = TestBed.inject(TaskService) as jasmine.SpyObj; mockTaskAttachmentService = TestBed.inject( TaskAttachmentService, @@ -144,7 +145,7 @@ describe('FocusModeMainComponent', () => { mockIssueService = TestBed.inject(IssueService) as jasmine.SpyObj; fixture.detectChanges(); - mockStore.dispatch.calls.reset(); + (mockStore.dispatch as jasmine.Spy).calls.reset(); }); describe('initialization', () => { @@ -353,7 +354,7 @@ describe('FocusModeMainComponent', () => { it('should set doneOn to current timestamp', () => { component.finishCurrentTask(); - const calls = mockStore.dispatch.calls.all(); + const calls = (mockStore.dispatch as jasmine.Spy).calls.all(); const actionTypes = calls.map((c: any) => c.args[0].type); // Verify exact actions dispatched @@ -400,7 +401,7 @@ describe('FocusModeMainComponent', () => { describe('startSession', () => { beforeEach(() => { - mockStore.dispatch.calls.reset(); + (mockStore.dispatch as jasmine.Spy).calls.reset(); focusModeServiceSpy.mode.and.returnValue(FocusModeMode.Pomodoro); focusModeServiceSpy.focusModeConfig.and.returnValue({ isSkipPreparation: false, @@ -578,9 +579,6 @@ describe('FocusModeMainComponent - notes panel (issue #5752)', () => { mainStateSignal = signal(FocusMainUIState.InProgress); isSessionRunningSignal = signal(true); - const storeSpy = jasmine.createSpyObj('Store', ['dispatch', 'select']); - storeSpy.select.and.returnValue(of([])); - const globalConfigServiceSpy = jasmine.createSpyObj('GlobalConfigService', [], { tasks: jasmine.createSpy().and.returnValue({ notesTemplate: 'Default task notes template', @@ -635,7 +633,8 @@ describe('FocusModeMainComponent - notes panel (issue #5752)', () => { MarkdownModule.forRoot(), ], providers: [ - { provide: Store, useValue: storeSpy }, + provideMockStore(), + provideMockActions(() => of()), { provide: GlobalConfigService, useValue: globalConfigServiceSpy }, { provide: TaskService, useValue: taskServiceSpy }, { provide: TaskAttachmentService, useValue: taskAttachmentServiceSpy }, diff --git a/src/app/features/note/dialog-add-note/dialog-add-note.component.ts b/src/app/features/note/dialog-add-note/dialog-add-note.component.ts index 4114313cd3..5efa795a72 100644 --- a/src/app/features/note/dialog-add-note/dialog-add-note.component.ts +++ b/src/app/features/note/dialog-add-note/dialog-add-note.component.ts @@ -8,9 +8,10 @@ import { MarkdownComponent } from 'ngx-markdown'; import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIcon } from '@angular/material/icon'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatIconButton } from '@angular/material/button'; import { TranslatePipe } from '@ngx-translate/core'; import { SnackService } from '../../../core/snack/snack.service'; +import { AsyncPipe } from '@angular/common'; @Component({ // selector: 'dialog-add-note', @@ -29,7 +30,9 @@ import { SnackService } from '../../../core/snack/snack.service'; MatTooltip, MatIcon, MatButton, + MatIconButton, TranslatePipe, + AsyncPipe, ], }) export class DialogAddNoteComponent extends DialogFullscreenMarkdownComponent { diff --git a/src/app/features/note/note/note.component.html b/src/app/features/note/note/note.component.html index f6787573cd..e9edd22f4b 100644 --- a/src/app/features/note/note/note.component.html +++ b/src/app/features/note/note/note.component.html @@ -17,7 +17,7 @@
diff --git a/src/app/features/note/note/note.component.ts b/src/app/features/note/note/note.component.ts index ee7777b4a1..a007087fd6 100644 --- a/src/app/features/note/note/note.component.ts +++ b/src/app/features/note/note/note.component.ts @@ -5,6 +5,7 @@ import { input, Input, OnChanges, + signal, SimpleChanges, viewChild, } from '@angular/core'; @@ -34,6 +35,7 @@ import { import { AsyncPipe } from '@angular/common'; import { TranslatePipe } from '@ngx-translate/core'; import { DEFAULT_PROJECT_COLOR } from '../../work-context/work-context.const'; +import { ClipboardImageService } from '../../../core/clipboard-image/clipboard-image.service'; @Component({ selector: 'note', @@ -60,6 +62,7 @@ export class NoteComponent implements OnChanges { private readonly _noteService = inject(NoteService); private readonly _projectService = inject(ProjectService); private readonly _workContextService = inject(WorkContextService); + private readonly _clipboardImageService = inject(ClipboardImageService); note!: Note; @@ -68,6 +71,7 @@ export class NoteComponent implements OnChanges { @Input('note') set noteSet(v: Note) { this.note = v; this._note$.next(v); + this._updateNoteTxt(); } readonly isFocus = input(); @@ -76,6 +80,8 @@ export class NoteComponent implements OnChanges { isLongNote?: boolean; shortenedNote?: string; + resolvedContent = signal(''); + resolvedShortenedContent = signal(''); T: typeof T = T; @@ -196,5 +202,20 @@ export class NoteComponent implements OnChanges { const LIMIT = 320; this.isLongNote = this.note.content.length > LIMIT; this.shortenedNote = this.note.content.substr(0, 160) + '\n\n (...)'; + this._updateResolvedContent(); + } + + private async _updateResolvedContent(): Promise { + const resolved = await this._clipboardImageService.resolveMarkdownImages( + this.note.content, + ); + this.resolvedContent.set(resolved); + + if (this.isLongNote && this.shortenedNote) { + const resolvedShort = await this._clipboardImageService.resolveMarkdownImages( + this.shortenedNote, + ); + this.resolvedShortenedContent.set(resolvedShort); + } } } diff --git a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.html b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.html index fd0b2c0d99..16d55238fb 100644 --- a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.html +++ b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.html @@ -3,7 +3,7 @@ class="attachments" > @for ( - attachment of attachments(); + attachment of resolvedAttachments(); track trackByFn($index, attachment); let i = $index ) { @@ -14,7 +14,7 @@ {{ attachment.title || attachment.path }} } @if (attachment.type === 'IMG' && !isError[i]) { - + @if (attachment.isLoading) { +
+ image + Loading image... +
+ } @else { + + } }
@if (!isDisableControls()) { @@ -41,7 +48,7 @@ class="view-btn" mat-flat-button [class.isImage]="attachment.type === 'IMG' && !isError[i]" - [href]="attachment.path" + [href]="attachment.resolvedPath" [type]="isError[i] ? 'LINK' : attachment.type" draggable="false" target="_blank" diff --git a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.scss b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.scss index e667b66bb6..8472d2899b 100644 --- a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.scss +++ b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.scss @@ -218,3 +218,24 @@ transform: translate(100%, 50%); } } + +.image-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: var(--this-upper-height); + background-color: var(--attachment-bg); + color: var(--text-color-less-intense); + font-size: 12px; + + mat-icon { + margin-bottom: 4px; + opacity: 0.7; + } + + span { + font-size: 11px; + opacity: 0.8; + } +} diff --git a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts index 9bf820fb8d..6423681141 100644 --- a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts +++ b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.spec.ts @@ -6,6 +6,7 @@ import { MatDialog } from '@angular/material/dialog'; import { TaskAttachment } from '../task-attachment.model'; import { T } from '../../../../t.const'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ClipboardImageService } from '../../../../core/clipboard-image/clipboard-image.service'; describe('TaskAttachmentListComponent', () => { let component: TaskAttachmentListComponent; @@ -20,6 +21,9 @@ describe('TaskAttachmentListComponent', () => { 'deleteAttachment', ]); const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); + const clipboardImageServiceSpy = jasmine.createSpyObj('ClipboardImageService', [ + 'resolveUrl', + ]); await TestBed.configureTestingModule({ imports: [TaskAttachmentListComponent, NoopAnimationsModule], @@ -27,6 +31,7 @@ describe('TaskAttachmentListComponent', () => { { provide: SnackService, useValue: snackServiceSpy }, { provide: TaskAttachmentService, useValue: attachmentServiceSpy }, { provide: MatDialog, useValue: matDialogSpy }, + { provide: ClipboardImageService, useValue: clipboardImageServiceSpy }, ], }).compileComponents(); diff --git a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.ts b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.ts index e17b8a6bbb..9ed09128ea 100644 --- a/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.ts +++ b/src/app/features/tasks/task-attachment/task-attachment-list/task-attachment-list.component.ts @@ -1,4 +1,12 @@ -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + computed, + effect, + signal, +} from '@angular/core'; import { TaskAttachment } from '../task-attachment.model'; import { TaskAttachmentService } from '../task-attachment.service'; import { MatDialog } from '@angular/material/dialog'; @@ -10,6 +18,13 @@ import { TaskAttachmentLinkDirective } from '../task-attachment-link/task-attach import { MatIcon } from '@angular/material/icon'; import { EnlargeImgDirective } from '../../../../ui/enlarge-img/enlarge-img.directive'; import { MatAnchor, MatButton } from '@angular/material/button'; +import { ClipboardImageService } from '../../../../core/clipboard-image/clipboard-image.service'; + +interface ResolvedAttachment extends TaskAttachment { + resolvedPath?: string; + resolvedOriginalPath?: string; + isLoading?: boolean; +} @Component({ selector: 'task-attachment-list', @@ -29,14 +44,102 @@ export class TaskAttachmentListComponent { readonly attachmentService = inject(TaskAttachmentService); private readonly _matDialog = inject(MatDialog); private readonly _snackService = inject(SnackService); + private readonly _clipboardImageService = inject(ClipboardImageService); readonly taskId = input(); readonly attachments = input(); readonly isDisableControls = input(false); + private readonly _resolvedUrlsMap = signal>(new Map()); + private readonly _loadingUrls = signal>(new Set()); + + readonly resolvedAttachments = computed(() => { + const attachments = this.attachments(); + const urlMap = this._resolvedUrlsMap(); + const loadingUrls = this._loadingUrls(); + + if (!attachments) return []; + + return attachments.map((att) => { + const resolvedPath = att.path?.startsWith('indexeddb://clipboard-images/') + ? urlMap.get(att.path) || att.path + : att.path; + + const imgPath = att.originalImgPath || att.path; + const resolvedOriginalPath = imgPath?.startsWith('indexeddb://clipboard-images/') + ? urlMap.get(imgPath) || imgPath + : imgPath; + + const isLoading = + att.path?.startsWith('indexeddb://clipboard-images/') && + !urlMap.has(att.path) && + loadingUrls.has(att.path); + + return { + ...att, + resolvedPath, + resolvedOriginalPath, + isLoading, + } as ResolvedAttachment; + }); + }); + T: typeof T = T; isError: boolean[] = []; + constructor() { + // Effect to resolve URLs asynchronously when attachments change + effect(() => { + const attachments = this.attachments(); + if (!attachments) return; + + attachments.forEach(async (att) => { + try { + const urlsToResolve: string[] = []; + + if (att.path?.startsWith('indexeddb://clipboard-images/')) { + urlsToResolve.push(att.path); + } + + const imgPath = att.originalImgPath || att.path; + if ( + imgPath?.startsWith('indexeddb://clipboard-images/') && + imgPath !== att.path + ) { + urlsToResolve.push(imgPath); + } + + for (const url of urlsToResolve) { + // Mark as loading + this._loadingUrls.update((set) => { + const newSet = new Set(set); + newSet.add(url); + return newSet; + }); + + const resolved = await this._clipboardImageService.resolveIndexedDbUrl(url); + if (resolved) { + this._resolvedUrlsMap.update((map) => { + const newMap = new Map(map); + newMap.set(url, resolved); + return newMap; + }); + } + + // Remove from loading + this._loadingUrls.update((set) => { + const newSet = new Set(set); + newSet.delete(url); + return newSet; + }); + } + } catch (error) { + console.error('Error resolving clipboard image:', error); + } + }); + }); + } + openEditDialog(attachment?: TaskAttachment): void { if (!this.taskId()) { throw new Error('No task id given'); diff --git a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html index f73be0e145..7230e439f1 100644 --- a/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html +++ b/src/app/features/tasks/task-detail-panel/task-detail-panel.component.html @@ -201,6 +201,7 @@ [isShowChecklistToggle]="true" [isDefaultText]="!task().notes" [model]="task().notes || defaultTaskNotes()" + [taskId]="task().id" > diff --git a/src/app/t.const.ts b/src/app/t.const.ts index be6b376130..0d42f66fb5 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -524,6 +524,11 @@ const T = { WELCOME_USER: 'F.JIRA.STEPPER.WELCOME_USER', }, }, + CLIPBOARD_IMAGE: { + PASTE_SUCCESS: 'F.CLIPBOARD_IMAGE.PASTE_SUCCESS', + SIZE_EXCEEDED: 'F.CLIPBOARD_IMAGE.SIZE_EXCEEDED', + STORAGE_QUOTA_EXCEEDED: 'F.CLIPBOARD_IMAGE.STORAGE_QUOTA_EXCEEDED', + }, MARKDOWN_PASTE: { CONFIRM_ADD_TO_SUB_TASK_NOTES: 'F.MARKDOWN_PASTE.CONFIRM_ADD_TO_SUB_TASK_NOTES', CONFIRM_PARENT_TASKS: 'F.MARKDOWN_PASTE.CONFIRM_PARENT_TASKS', @@ -1846,6 +1851,36 @@ const T = { HELP: 'GCF.CALENDARS.HELP', SHOW_BANNER_THRESHOLD: 'GCF.CALENDARS.SHOW_BANNER_THRESHOLD', }, + CLIPBOARD_IMAGES: { + DELETE: 'GCF.CLIPBOARD_IMAGES.DELETE', + DELETE_ALL: 'GCF.CLIPBOARD_IMAGES.DELETE_ALL', + DELETE_ALL_SUCCESS: 'GCF.CLIPBOARD_IMAGES.DELETE_ALL_SUCCESS', + DELETE_SUCCESS: 'GCF.CLIPBOARD_IMAGES.DELETE_SUCCESS', + ERROR_DELETING: 'GCF.CLIPBOARD_IMAGES.ERROR_DELETING', + ERROR_DELETING_ALL: 'GCF.CLIPBOARD_IMAGES.ERROR_DELETING_ALL', + ERROR_LOADING: 'GCF.CLIPBOARD_IMAGES.ERROR_LOADING', + HELP: 'GCF.CLIPBOARD_IMAGES.HELP', + LOADING: 'GCF.CLIPBOARD_IMAGES.LOADING', + MANAGE_DESCRIPTION: 'GCF.CLIPBOARD_IMAGES.MANAGE_DESCRIPTION', + MANAGE_TITLE: 'GCF.CLIPBOARD_IMAGES.MANAGE_TITLE', + MANAGER_TITLE: 'GCF.CLIPBOARD_IMAGES.MANAGER_TITLE', + NO_IMAGES: 'GCF.CLIPBOARD_IMAGES.NO_IMAGES', + OPEN_MANAGER: 'GCF.CLIPBOARD_IMAGES.OPEN_MANAGER', + PATH_DESCRIPTION: 'GCF.CLIPBOARD_IMAGES.PATH_DESCRIPTION', + PATH_LABEL: 'GCF.CLIPBOARD_IMAGES.PATH_LABEL', + PATH_PLACEHOLDER: 'GCF.CLIPBOARD_IMAGES.PATH_PLACEHOLDER', + PATH_TITLE: 'GCF.CLIPBOARD_IMAGES.PATH_TITLE', + SELECT_PATH: 'GCF.CLIPBOARD_IMAGES.SELECT_PATH', + SELECT_PATH_TITLE: 'GCF.CLIPBOARD_IMAGES.SELECT_PATH_TITLE', + SORT_BY: 'GCF.CLIPBOARD_IMAGES.SORT_BY', + SORT_LARGEST: 'GCF.CLIPBOARD_IMAGES.SORT_LARGEST', + SORT_NEWEST: 'GCF.CLIPBOARD_IMAGES.SORT_NEWEST', + SORT_OLDEST: 'GCF.CLIPBOARD_IMAGES.SORT_OLDEST', + SORT_SMALLEST: 'GCF.CLIPBOARD_IMAGES.SORT_SMALLEST', + TITLE: 'GCF.CLIPBOARD_IMAGES.TITLE', + TOTAL_IMAGES: 'GCF.CLIPBOARD_IMAGES.TOTAL_IMAGES', + TOTAL_SIZE: 'GCF.CLIPBOARD_IMAGES.TOTAL_SIZE', + }, EVALUATION: { IS_HIDE_EVALUATION_SHEET: 'GCF.EVALUATION.IS_HIDE_EVALUATION_SHEET', TITLE: 'GCF.EVALUATION.TITLE', diff --git a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html index 0315e37b80..6629775108 100644 --- a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html +++ b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.html @@ -158,6 +158,7 @@ #textareaEl [(ngModel)]="data.content" (keydown)="keydownHandler($event)" + (paste)="pasteHandler($event)" (ngModelChange)="ngModelChange($event)" rows="1" tabindex="3" @@ -165,13 +166,13 @@ } @if (viewMode === 'PARSED' || viewMode === 'SPLIT') { -
+ > } diff --git a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.ts b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.ts index 5c60ae3e4d..51a9d856a7 100644 --- a/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.ts +++ b/src/app/ui/dialog-fullscreen-markdown/dialog-fullscreen-markdown.component.ts @@ -1,28 +1,36 @@ import { AfterViewInit, ChangeDetectionStrategy, + ChangeDetectorRef, Component, DestroyRef, + effect, ElementRef, inject, + OnInit, output, + signal, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { MatButton, MatIconButton } from '@angular/material/button'; -import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton } from '@angular/material/button'; import { MatTooltip } from '@angular/material/tooltip'; -import { TranslatePipe } from '@ngx-translate/core'; import { MarkdownComponent } from 'ngx-markdown'; +import { TranslatePipe } from '@ngx-translate/core'; import { Subject } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { LS } from '../../core/persistence/storage-keys.const'; import { T } from '../../t.const'; import { isSmallScreen } from '../../util/is-small-screen'; import * as MarkdownToolbar from '../inline-markdown/markdown-toolbar.util'; +import { ClipboardImageService } from '../../core/clipboard-image/clipboard-image.service'; +import { TaskAttachmentService } from '../../features/tasks/task-attachment/task-attachment.service'; +import { ClipboardPasteHandlerService } from '../../core/clipboard-image/clipboard-paste-handler.service'; type ViewMode = 'SPLIT' | 'PARSED' | 'TEXT_ONLY'; const ALL_VIEW_MODES: ['SPLIT', 'PARSED', 'TEXT_ONLY'] = ['SPLIT', 'PARSED', 'TEXT_ONLY']; @@ -32,22 +40,27 @@ const ALL_VIEW_MODES: ['SPLIT', 'PARSED', 'TEXT_ONLY'] = ['SPLIT', 'PARSED', 'TE templateUrl: './dialog-fullscreen-markdown.component.html', styleUrls: ['./dialog-fullscreen-markdown.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, imports: [ FormsModule, MarkdownComponent, - MatButtonToggleGroup, - MatButtonToggle, - MatTooltip, - MatIcon, MatButton, + MatButtonToggle, + MatButtonToggleGroup, + MatIcon, MatIconButton, + MatTooltip, TranslatePipe, ], }) -export class DialogFullscreenMarkdownComponent implements AfterViewInit { +export class DialogFullscreenMarkdownComponent implements OnInit, AfterViewInit { private readonly _destroyRef = inject(DestroyRef); + private readonly _clipboardImageService = inject(ClipboardImageService); + private readonly _taskAttachmentService = inject(TaskAttachmentService); + private readonly _clipboardPasteHandler = inject(ClipboardPasteHandlerService); + private readonly _cdr = inject(ChangeDetectorRef); _matDialogRef = inject>(MatDialogRef); - data: { content: string } = inject(MAT_DIALOG_DATA) || { content: '' }; + data: { content: string; taskId?: string } = inject(MAT_DIALOG_DATA) || { content: '' }; T: typeof T = T; viewMode: ViewMode = isSmallScreen() ? 'TEXT_ONLY' : 'SPLIT'; @@ -55,8 +68,20 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { readonly textareaEl = viewChild('textareaEl'); readonly contentChanged = output(); private readonly _contentChanges$ = new Subject(); + private _currentPastePlaceholder: string | null = null; + + /** + * Resolved content with blob URLs for images (for preview rendering). + * Initialized in ngOnInit with raw content, updated asynchronously when images resolve. + */ + resolvedContent = signal(''); + // Plain property for markdown component compatibility + resolvedContentData: string | undefined; constructor() { + // Set initial content synchronously for immediate rendering + this.resolvedContentData = this.data.content || ''; + const lastViewMode = localStorage.getItem(LS.LAST_FULLSCREEN_EDIT_VIEW_MODE); if ( ALL_VIEW_MODES.includes(lastViewMode as ViewMode) && @@ -71,6 +96,12 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { } } + // Sync signal to plain property for markdown component + effect(() => { + this.resolvedContentData = this.resolvedContent(); + this._cdr.markForCheck(); + }); + // Auto-save with debounce this._contentChanges$ .pipe(debounceTime(500), takeUntilDestroyed(this._destroyRef)) @@ -78,6 +109,13 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { this.contentChanged.emit(value); }); + // Update resolved content when content changes (for preview with images) + this._contentChanges$ + .pipe(debounceTime(100), takeUntilDestroyed(this._destroyRef)) + .subscribe((value) => { + this._updateResolvedContent(value); + }); + // Handle Escape key - save and close this._matDialogRef.disableClose = true; this._matDialogRef @@ -91,6 +129,13 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { }); } + async ngOnInit(): Promise { + // Update resolved content asynchronously for image processing + if (this.data.content) { + await this._updateResolvedContent(this.data.content); + } + } + ngAfterViewInit(): void { // Focus textarea if present (not in PARSED view mode) this.textareaEl()?.nativeElement?.focus(); @@ -102,6 +147,22 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { } } + async pasteHandler(ev: ClipboardEvent): Promise { + await this._clipboardPasteHandler.handlePaste(ev, { + currentPlaceholder: { + get: () => this._currentPastePlaceholder, + set: (val) => (this._currentPastePlaceholder = val), + }, + getContent: () => this.data.content, + setContent: (content) => { + this.data.content = content; + this._contentChanges$.next(content); + }, + getTextarea: () => this.textareaEl()?.nativeElement || null, + getTaskId: () => this.data.taskId || null, + }); + } + ngModelChange(content: string): void { this._contentChanges$.next(content); } @@ -151,6 +212,11 @@ export class DialogFullscreenMarkdownComponent implements AfterViewInit { } } + private async _updateResolvedContent(content: string): Promise { + const resolved = await this._clipboardImageService.resolveMarkdownImages(content); + this.resolvedContent.set(resolved); + } + // ========================================================================= // Toolbar actions // ========================================================================= diff --git a/src/app/ui/inline-markdown/inline-markdown.component.html b/src/app/ui/inline-markdown/inline-markdown.component.html index fbd79f784a..2c02f6e2d2 100644 --- a/src/app/ui/inline-markdown/inline-markdown.component.html +++ b/src/app/ui/inline-markdown/inline-markdown.component.html @@ -2,6 +2,7 @@ #wrapperEl [class.isHideOverflow]="isHideOverflow()" class="markdown-wrapper" + spResolveClipboardImages > @if (isShowEdit() || !isMarkdownFormattingEnabled()) {