Adding Ctrl-V pasting function from clipboard. (#5998)

* docs(clipboard): Added the plan to guide AI implementation.

* feat(clipboard): Changed requirement to reflect the fact that some functions can't be done in browsers.

* feat(clipboard): Added Ctrl-v pasting functionality from clipboard.

- Storing image in IndexedDB in browser, and storing image in userdata path in Electron.
- Added image resizing in markdown.

* docs(clipboard): Removed requirement file since it's implemented.

* feat(clipboard): Added clipboard images management in settings.

* feat(tests): Enhance focus mode and inline markdown tests with mock store and actions

* feat(clipboard): Add electron-only annotation to findImageFile function

* feat(clipboard): Prevent memory leaks by revoking cached blob URLs on image deletion

* feat(clipboard): Improve paste handling by tracking current placeholder and cleaning up old progress

* Merge branch 'master' into feat/clipboard-files

* feat(marked-options): Refactor renderer methods and add hooks for image sizing syntax

* feat(clipboard): Added feature that pasted image will be added to task attachment.

* revert: Revert the change of lock file from last merge.

* revert: Removed unwanted changes.

* refactor(clipboard): Cleaned up code that seems not necessary.

* feat(clipboard): Handle storage quota exceeded error and update messages

* feat(clipboard): Implement validated IPC handlers for clipboard image operations

* feat(clipboard): Add error handling for clipboard image URL resolution

* feat(clipboard): Refactor file operations to use async fs promises

* feat(clipboard): Integrate ClipboardPasteHandlerService for improved paste handling

* feat(clipboard): Rename resolveUrl to resolveIndexedDbUrl for clarity and update references

* feat(clipboard): Add defaultPath option to showOpenDialog for improved user experience

* feat(clipboard): Implement getDefaultClipboardImagesPath utility for consistent image path retrieval

* feat(clipboard): Refactor MIME type handling to use centralized mapping for improved maintainability

* feat(clipboard): Add computed property to toggle markdown parsing based on formatting settings

* revert: Removed unwanted changes.

* revert: Removed unwanted chagnes.

* revert: Removed unwanted chagnes.

* fix(clipboard-images-cfg): remove debug log from selectImagePath method

* refactor(paste-handler): update currentPlaceholder to use getter/setter methods

* fix(docs): correct formatting and improve clarity in multiple wiki pages

* fix: update dialog handling in initLocalFileSyncAdapter for type safety

* revert: Revert space change.

* fix(inline-markdown): ensure model is set to an empty string instead of undefined

* fix(tests): update clipboard images section locator to use collapsible title
This commit is contained in:
Pue-Tsuâ 2026-02-04 22:46:51 +08:00 committed by GitHub
parent 343fc47d1d
commit 9a9e31f36a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2554 additions and 55 deletions

View file

@ -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 });
});
});

View file

@ -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: [] },
),
);
};

View file

@ -55,6 +55,12 @@ export interface ElectronAPI {
pickDirectory(): Promise<string | undefined>;
showOpenDialog(options: {
properties: string[];
title?: string;
defaultPath?: string;
}): Promise<string[] | undefined>;
// checkDirExists(dirPath: string): Promise<true | Error>;
// STANDARD
@ -87,6 +93,48 @@ export interface ElectronAPI {
isFlatpak(): boolean;
// CLIPBOARD IMAGES
// ----------------
saveClipboardImage(
basePath: string,
fileName: string,
base64Data: string,
mimeType: string,
): Promise<string>;
loadClipboardImage(
basePath: string,
imageId: string,
): Promise<{ base64: string; mimeType: string } | null>;
deleteClipboardImage(basePath: string, imageId: string): Promise<boolean>;
listClipboardImages(
basePath: string,
): Promise<{ id: string; mimeType: string; createdAt: number; size: number }[]>;
getClipboardImagePath(basePath: string, imageId: string): Promise<string | null>;
getClipboardFilePaths(): Promise<string[]>;
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;

View file

@ -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 = <TArgs extends object, TResult>(
handler: (args: TArgs) => Promise<TResult>,
options?: {
validatePath?: boolean;
errorValue?: TResult;
},
): ((event: Electron.IpcMainInvokeEvent, args: TArgs) => Promise<TResult>) => {
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;
}
};
};

View file

@ -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();
};

View file

@ -137,7 +137,7 @@ export const initLocalFileSyncAdapter = (): void => {
);
ipcMain.handle(IPC.PICK_DIRECTORY, async (): Promise<string | undefined> => {
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<string[] | undefined> => {
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 => {

View file

@ -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<string | undefined>,
showOpenDialog: (options: {
properties: string[];
title?: string;
defaultPath?: string;
}) => _invoke('SHOW_OPEN_DIALOG', options) as Promise<string[] | undefined>,
// 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<string>,
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<boolean>,
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<string | null>,
getClipboardFilePaths: () => _invoke('CLIPBOARD_GET_FILE_PATHS') as Promise<string[]>,
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'),

View file

@ -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',

View file

@ -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 */

View file

@ -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<ClipboardImagePasteResult>;
}
/**
* 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<IDBDatabase> | null = null;
private _blobUrlCache = new Map<string, string>();
// ===========================================================================
// 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<ClipboardImagePasteResult> {
// 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<ClipboardImagePasteResult | null> {
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<Blob | null> {
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<string> {
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<string, string>();
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<string | null> {
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<string | null> {
// 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<Blob | null> {
return IS_ELECTRON ? this._getImageElectron(id) : this._getImageWeb(id);
}
async deleteImage(id: string): Promise<boolean> {
return IS_ELECTRON ? this._deleteImageElectron(id) : this._deleteImageWeb(id);
}
async listImages(): Promise<ClipboardImageMetadata[]> {
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<IDBDatabase> {
if (this._db) return this._db;
if (this._dbPromise) return this._dbPromise;
this._dbPromise = new Promise<IDBDatabase>((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<string> {
const db = await this._getDb();
const entry: ClipboardImageEntry = {
id,
blob,
mimeType,
createdAt: Date.now(),
size: blob.size,
};
return new Promise<string>((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<Blob | null> {
const db = await this._getDb();
return new Promise<Blob | null>((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<boolean> {
// 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<boolean>((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<ClipboardImageMetadata[]> {
const db = await this._getDb();
return new Promise<ClipboardImageMetadata[]>((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<string> {
// 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<string> {
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<Blob | null> {
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<boolean> {
// 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<ClipboardImageMetadata[]> {
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);
}
}

View file

@ -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<boolean> {
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;
}
}

View file

@ -0,0 +1,2 @@
export * from './clipboard-image.service';
export * from './resolve-clipboard-images.directive';

View file

@ -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<string>();
private _debounceTimer: ReturnType<typeof setTimeout> | 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<void> {
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<HTMLImageElement>(
'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;
}
}
}

View file

@ -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<string, Observable<string>>();
transform(url: string | undefined | null): Observable<string> {
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$;
}
}

View file

@ -0,0 +1,47 @@
<div class="clipboard-images-cfg">
@if (!IS_ELECTRON) {
<section class="manage-images-section">
<h3>{{ T.GCF.CLIPBOARD_IMAGES.MANAGE_TITLE | translate }}</h3>
<p>{{ T.GCF.CLIPBOARD_IMAGES.MANAGE_DESCRIPTION | translate }}</p>
<button
(click)="openImageManager()"
color="primary"
mat-raised-button
>
<mat-icon>image</mat-icon>
{{ T.GCF.CLIPBOARD_IMAGES.OPEN_MANAGER | translate }}
</button>
</section>
}
@if (IS_ELECTRON) {
<section class="image-path-section">
<h3>{{ T.GCF.CLIPBOARD_IMAGES.PATH_TITLE | translate }}</h3>
<p>{{ T.GCF.CLIPBOARD_IMAGES.PATH_DESCRIPTION | translate }}</p>
<mat-form-field appearance="outline">
<mat-label
>{{ T.GCF.CLIPBOARD_IMAGES.PATH_LABEL | translate }}
@if (!imagePath && defaultImagePath) {
({{ defaultImagePath }})
}
</mat-label>
<input
[(ngModel)]="imagePath"
(ngModelChange)="onImagePathChange()"
matInput
[placeholder]="imagePath ? '' : defaultImagePath"
/>
</mat-form-field>
<button
(click)="selectImagePath()"
color="primary"
mat-stroked-button
>
<mat-icon>folder_open</mat-icon>
{{ T.GCF.CLIPBOARD_IMAGES.SELECT_PATH | translate }}
</button>
</section>
}
</div>

View file

@ -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);
}
}

View file

@ -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<ClipboardImagesConfig>;
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<void> {
this.imagePath = this.cfg?.imagePath ?? null;
if (IS_ELECTRON) {
await this.loadDefaultPath();
}
}
private async loadDefaultPath(): Promise<void> {
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<void> {
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,
},
});
}
}

View file

@ -0,0 +1,108 @@
<h1 mat-dialog-title>{{ T.GCF.CLIPBOARD_IMAGES.MANAGER_TITLE | translate }}</h1>
<mat-dialog-content>
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
<p>{{ T.GCF.CLIPBOARD_IMAGES.LOADING | translate }}</p>
</div>
} @else {
@if (images().length === 0) {
<div class="empty-state">
<mat-icon>image_not_supported</mat-icon>
<p>{{ T.GCF.CLIPBOARD_IMAGES.NO_IMAGES | translate }}</p>
</div>
} @else {
<div class="summary">
<div class="summary-info">
<p>
{{ T.GCF.CLIPBOARD_IMAGES.TOTAL_IMAGES | translate }}:
<strong>{{ images().length }}</strong>
</p>
<p>
{{ T.GCF.CLIPBOARD_IMAGES.TOTAL_SIZE | translate }}:
<strong>{{ getTotalSize() }}</strong>
</p>
</div>
<mat-form-field
appearance="outline"
class="sort-field"
>
<mat-label>{{ T.GCF.CLIPBOARD_IMAGES.SORT_BY | translate }}</mat-label>
<mat-select
[value]="sortOrder()"
(selectionChange)="onSortChange($event.value)"
>
<mat-option value="newest">{{
T.GCF.CLIPBOARD_IMAGES.SORT_NEWEST | translate
}}</mat-option>
<mat-option value="oldest">{{
T.GCF.CLIPBOARD_IMAGES.SORT_OLDEST | translate
}}</mat-option>
<mat-option value="largest">{{
T.GCF.CLIPBOARD_IMAGES.SORT_LARGEST | translate
}}</mat-option>
<mat-option value="smallest">{{
T.GCF.CLIPBOARD_IMAGES.SORT_SMALLEST | translate
}}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="images-list">
@for (image of sortedImages(); track image.id) {
<div class="image-item">
<div class="image-preview">
@if (imageUrls().get(image.id)) {
<img
[src]="imageUrls().get(image.id)"
[alt]="image.id"
/>
} @else {
<mat-icon>image</mat-icon>
}
</div>
<div class="image-info">
<div class="image-id">{{ image.id }}</div>
<div class="image-details">
<span>{{ image.createdAt | date: 'medium' }}</span>
<span></span>
<span>{{ formatSize(image.size) }}</span>
<span></span>
<span>{{ image.mimeType }}</span>
</div>
</div>
<button
(click)="deleteImage(image)"
color="warn"
mat-icon-button
[matTooltip]="T.GCF.CLIPBOARD_IMAGES.DELETE | translate"
>
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!isLoading() && images().length > 0) {
<button
(click)="deleteAll()"
color="warn"
mat-button
>
<mat-icon>delete_sweep</mat-icon>
{{ T.GCF.CLIPBOARD_IMAGES.DELETE_ALL | translate }}
</button>
}
<button
(click)="close()"
color="primary"
mat-stroked-button
>
{{ T.G.CLOSE | translate }}
</button>
</mat-dialog-actions>

View file

@ -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;
}
}

View file

@ -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<DialogClipboardImagesManagerComponent>,
);
private readonly _clipboardImageService = inject(ClipboardImageService);
private readonly _snackService = inject(SnackService);
private readonly _translateService = inject(TranslateService);
readonly T = T;
readonly images = signal<ClipboardImageMetadata[]>([]);
readonly isLoading = signal(true);
readonly imageUrls = signal<Map<string, string>>(new Map());
readonly sortOrder = signal<SortOrder>('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<void> {
await this.loadImages();
}
async loadImages(): Promise<void> {
this.isLoading.set(true);
try {
const images = await this._clipboardImageService.listImages();
this.images.set(images);
// Load image URLs for preview
const urlMap = new Map<string, string>();
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<void> {
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<void> {
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();
}
}

View file

@ -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');
}

View file

@ -97,6 +97,9 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isSyncSessionWithTracking: false,
isStartInBackground: false,
},
clipboardImages: {
imagePath: null,
},
pomodoro: {
duration: 25 * minute,
breakDuration: 5 * minute,

View file

@ -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',
};

View file

@ -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<any>): 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

View file

@ -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<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export interface LimitedFormlyFieldConfig<FormModel> 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<FormModel> {

View file

@ -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<ClipboardImagesConfig> = this._store.pipe(
select(selectClipboardImagesConfig),
shareReplay(1),
);
timelineCfg$: Observable<ScheduleConfig> = this._store.pipe(
select(selectTimelineConfig),
);
@ -148,6 +155,10 @@ export class GlobalConfigService {
initialValue: DEFAULT_GLOBAL_CONFIG.pomodoro,
},
);
readonly clipboardImages: Signal<ClipboardImagesConfig | undefined> = toSignal(
this.clipboardImages$,
{ initialValue: undefined },
);
readonly timelineCfg: Signal<ScheduleConfig | undefined> = toSignal(this.timelineCfg$, {
initialValue: undefined,
});

View file

@ -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,

View file

@ -314,6 +314,7 @@
[isShowControls]="true"
[isDefaultText]="!ct.notes"
[model]="ct.notes || defaultTaskNotes()"
[taskId]="ct.id"
></inline-markdown>
</div>
@if (ct.attachments.length) {

View file

@ -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<FocusModeMainComponent>;
let mockStore: jasmine.SpyObj<Store>;
let mockStore: MockStore;
let mockTaskService: jasmine.SpyObj<TaskService>;
let mockTaskAttachmentService: jasmine.SpyObj<TaskAttachmentService>;
let mockIssueService: jasmine.SpyObj<IssueService>;
@ -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<Store>;
mockStore = TestBed.inject(Store) as MockStore;
spyOn(mockStore, 'dispatch');
mockTaskService = TestBed.inject(TaskService) as jasmine.SpyObj<TaskService>;
mockTaskAttachmentService = TestBed.inject(
TaskAttachmentService,
@ -144,7 +145,7 @@ describe('FocusModeMainComponent', () => {
mockIssueService = TestBed.inject(IssueService) as jasmine.SpyObj<IssueService>;
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 },

View file

@ -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 {

View file

@ -17,7 +17,7 @@
<div
(click)="editFullscreen($event)"
markdown
[data]="isLongNote ? shortenedNote : note.content"
[data]="isLongNote ? resolvedShortenedContent() : resolvedContent()"
class="markdown-preview markdown"
></div>
</div>

View file

@ -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<boolean>();
@ -76,6 +80,8 @@ export class NoteComponent implements OnChanges {
isLongNote?: boolean;
shortenedNote?: string;
resolvedContent = signal<string>('');
resolvedShortenedContent = signal<string>('');
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<void> {
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);
}
}
}

View file

@ -3,7 +3,7 @@
class="attachments"
>
@for (
attachment of attachments();
attachment of resolvedAttachments();
track trackByFn($index, attachment);
let i = $index
) {
@ -14,7 +14,7 @@
<!-- NOTE: view link exists twice, both for mobile and for clicks before animation is done -->
<a
[class.isImage]="attachment.type === 'IMG' && !isError[i]"
[href]="attachment.path"
[href]="attachment.resolvedPath"
[type]="isError[i] ? 'LINK' : attachment.type"
class="attachment-link mat-elevation-z3"
draggable="false"
@ -28,11 +28,18 @@
<div class="title">{{ attachment.title || attachment.path }}</div>
}
@if (attachment.type === 'IMG' && !isError[i]) {
<img
(error)="isError[i] = true"
[enlargeImg]="attachment.originalImgPath"
[src]="attachment.originalImgPath || attachment.path"
/>
@if (attachment.isLoading) {
<div class="image-loading">
<mat-icon>image</mat-icon>
<span>Loading image...</span>
</div>
} @else {
<img
(error)="isError[i] = true"
[enlargeImg]="attachment.resolvedOriginalPath"
[src]="attachment.resolvedOriginalPath"
/>
}
}
</a>
@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"

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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<string>();
readonly attachments = input<TaskAttachment[]>();
readonly isDisableControls = input<boolean>(false);
private readonly _resolvedUrlsMap = signal<Map<string, string>>(new Map());
private readonly _loadingUrls = signal<Set<string>>(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');

View file

@ -201,6 +201,7 @@
[isShowChecklistToggle]="true"
[isDefaultText]="!task().notes"
[model]="task().notes || defaultTaskNotes()"
[taskId]="task().id"
></inline-markdown>
</ng-container>
</task-detail-item>

View file

@ -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',

View file

@ -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 @@
</div>
}
@if (viewMode === 'PARSED' || viewMode === 'SPLIT') {
<div
<markdown
#previewEl
(click)="clickPreview($event)"
markdown
[data]="data.content"
[data]="resolvedContentData"
[disableSanitizer]="true"
class="markdown-preview markdown"
></div>
></markdown>
}
</div>

View file

@ -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<DialogFullscreenMarkdownComponent>>(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<ElementRef>('textareaEl');
readonly contentChanged = output<string>();
private readonly _contentChanges$ = new Subject<string>();
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<string>('');
// 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<void> {
// 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<void> {
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<void> {
const resolved = await this._clipboardImageService.resolveMarkdownImages(content);
this.resolvedContent.set(resolved);
}
// =========================================================================
// Toolbar actions
// =========================================================================

View file

@ -2,6 +2,7 @@
#wrapperEl
[class.isHideOverflow]="isHideOverflow()"
class="markdown-wrapper"
spResolveClipboardImages
>
@if (isShowEdit() || !isMarkdownFormattingEnabled()) {
<textarea
@ -11,6 +12,7 @@
(input)="resizeTextareaToFit()"
(keydown)="keypressHandler($event)"
(keypress)="keypressHandler($event)"
(paste)="pasteHandler($event)"
[@fadeIn]
[ngModel]="modelCopy()"
[placeholder]="placeholderTxt()"
@ -20,16 +22,16 @@
}
<!-- (focus)="clickOrFocusPreview($event, true)"-->
@if (isMarkdownFormattingEnabled()) {
<div
@if (!isTurnOffMarkdownParsing()) {
<markdown
#previewEl
(click)="clickPreview($event)"
[data]="model"
[hidden]="isShowEdit()"
[data]="resolvedMarkdownData"
[disableSanitizer]="true"
[class.preview-while-editing]="isShowEdit()"
class="mat-body-2 markdown-parsed markdown"
markdown
tabindex="0"
></div>
></markdown>
}
</div>

View file

@ -47,6 +47,15 @@
cursor: text;
}
.markdown-parsed.preview-while-editing {
position: relative;
margin-top: var(--s2);
padding-top: var(--s2);
border-top: 1px solid var(--color-border);
opacity: 0.8;
pointer-events: none;
}
// NOTE:
// see global marked.scss for more styles

View file

@ -4,6 +4,10 @@ import { MarkdownModule } from 'ngx-markdown';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { InlineMarkdownComponent } from './inline-markdown.component';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { provideMockStore } from '@ngrx/store/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { of } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
describe('InlineMarkdownComponent', () => {
let component: InlineMarkdownComponent;
@ -18,10 +22,17 @@ describe('InlineMarkdownComponent', () => {
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
await TestBed.configureTestingModule({
imports: [InlineMarkdownComponent, MarkdownModule.forRoot(), NoopAnimationsModule],
imports: [
InlineMarkdownComponent,
MarkdownModule.forRoot(),
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: MatDialog, useValue: mockMatDialog },
provideMockStore(),
provideMockActions(() => of()),
],
}).compileComponents();

View file

@ -3,6 +3,7 @@ import {
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
HostBinding,
inject,
@ -25,6 +26,10 @@ import { GlobalConfigService } from '../../features/config/global-config.service
import { isMarkdownChecklist } from '../../features/markdown-checklist/is-markdown-checklist';
import { fadeInAnimation } from '../animations/fade.ani';
import { DialogFullscreenMarkdownComponent } from '../dialog-fullscreen-markdown/dialog-fullscreen-markdown.component';
import { ClipboardImageService } from '../../core/clipboard-image/clipboard-image.service';
import { TaskAttachmentService } from '../../features/tasks/task-attachment/task-attachment.service';
import { ResolveClipboardImagesDirective } from '../../core/clipboard-image/resolve-clipboard-images.directive';
import { ClipboardPasteHandlerService } from '../../core/clipboard-image/clipboard-paste-handler.service';
const HIDE_OVERFLOW_TIMEOUT_DURATION = 300;
@ -34,18 +39,30 @@ const HIDE_OVERFLOW_TIMEOUT_DURATION = 300;
styleUrls: ['./inline-markdown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInAnimation],
imports: [FormsModule, MarkdownComponent, MatIconButton, MatTooltip, MatIcon],
imports: [
FormsModule,
MarkdownComponent,
MatIconButton,
MatTooltip,
MatIcon,
ResolveClipboardImagesDirective,
],
})
export class InlineMarkdownComponent implements OnInit, OnDestroy {
private _cd = inject(ChangeDetectorRef);
private _globalConfigService = inject(GlobalConfigService);
private _matDialog = inject(MatDialog);
private _clipboardImageService = inject(ClipboardImageService);
private _taskAttachmentService = inject(TaskAttachmentService);
private _clipboardPasteHandler = inject(ClipboardPasteHandlerService);
private _currentPastePlaceholder: string | null = null;
readonly isLock = input<boolean>(false);
readonly isShowControls = input<boolean>(false);
readonly isShowChecklistToggle = input<boolean>(false);
readonly isDefaultText = input<boolean>(false);
readonly placeholderTxt = input<string>('');
readonly placeholderTxt = input<string | undefined>(undefined);
readonly taskId = input<string | undefined>(undefined);
readonly changed = output<string>();
readonly focused = output<Event>();
@ -58,16 +75,27 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
isHideOverflow = signal(false);
isChecklistMode = signal(false);
isShowEdit = signal(false);
modelCopy = signal<string>('');
modelCopy = signal<string | undefined>(undefined);
resolvedModel = signal<string | undefined>(undefined);
// Plain property for markdown component compatibility
resolvedMarkdownData: string | undefined;
isMarkdownFormattingEnabled = computed(() => {
const tasks = this._globalConfigService.tasks();
return tasks?.isMarkdownFormattingInNotesEnabled ?? true;
});
isTurnOffMarkdownParsing = computed(() => !this.isMarkdownFormattingEnabled());
private _hideOverFlowTimeout: number | undefined;
constructor() {
this.resizeParsedToFit();
// Sync signal to plain property for markdown component
effect(() => {
this.resolvedMarkdownData = this.resolvedModel();
this._cd.markForCheck();
});
}
@HostBinding('class.isFocused') get isFocused(): boolean {
@ -86,6 +114,13 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
this._model = v || '';
this.modelCopy.set(v || '');
// Start resolving but don't update the rendered model yet
if (v) {
this._updateResolvedModel(v);
} else {
this.resolvedModel.set(undefined);
}
if (!this.isShowEdit()) {
window.setTimeout(() => {
this.resizeParsedToFit();
@ -148,6 +183,27 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
}
}
async pasteHandler(ev: ClipboardEvent): Promise<void> {
await this._clipboardPasteHandler.handlePaste(ev, {
currentPlaceholder: {
get: () => this._currentPastePlaceholder,
set: (val) => (this._currentPastePlaceholder = val),
},
getContent: () => this._model || '',
setContent: (content) => {
this.modelCopy.set(content);
this._model = content;
this.changed.emit(content);
},
getTextarea: () => this.textareaEl()?.nativeElement || null,
getTaskId: () => this.taskId() || null,
onPasteComplete: async (content) => {
this.resizeTextareaToFit();
await this._updateResolvedModel(content);
},
});
}
clickPreview($event: MouseEvent): void {
if (($event.target as HTMLElement).tagName === 'A') {
// Let links work normally
@ -177,7 +233,7 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
this.modelCopy.set(textareaEl.nativeElement.value);
if (this.modelCopy() !== this.model) {
this.model = this.modelCopy();
this.model = this.modelCopy() || '';
this.changed.emit(this.modelCopy() as string);
}
}
@ -205,6 +261,7 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
autoFocus: 'textarea',
data: {
content: this.modelCopy(),
taskId: this.taskId(),
},
});
@ -355,10 +412,22 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy {
// Update the markdown string
if (this.modelCopy() !== this.model) {
this.model = this.modelCopy();
this.model = this.modelCopy() || '';
this.changed.emit(this.modelCopy() as string);
}
}
}
}
private async _updateResolvedModel(content: string | undefined): Promise<void> {
if (!content) {
this.resolvedModel.set(content);
this._cd.markForCheck();
return;
}
// First resolve all URLs in the markdown
const resolved = await this._clipboardImageService.resolveMarkdownImages(content);
this.resolvedModel.set(resolved);
}
}

View file

@ -0,0 +1,17 @@
import { IS_ELECTRON } from '../app.constants';
/**
* Gets the default path for clipboard images storage.
* This is used both by the ClipboardImageService and ClipboardImagesCfgComponent
* to ensure consistent default path logic.
*/
export const getDefaultClipboardImagesPath = async (): Promise<string> => {
if (!IS_ELECTRON) {
throw new Error('Default clipboard images path is only available in Electron');
}
const userDataPath = await window.ea.getUserDataPath();
const isWindows = !window.ea.isLinux() && !window.ea.isMacOS();
const separator = isWindows ? '\\' : '/';
return `${userDataPath}${separator}clipboard-images`;
};

View file

@ -513,6 +513,11 @@
"WELCOME_USER": "Welcome {{user}}!"
}
},
"CLIPBOARD_IMAGE": {
"PASTE_SUCCESS": "Image pasted successfully",
"SIZE_EXCEEDED": "Image size ({{actualSize}}) exceeds maximum allowed size ({{maxSize}})",
"STORAGE_QUOTA_EXCEEDED": "Storage quota exceeded. Please free up space or reduce image sizes."
},
"MARKDOWN_PASTE": {
"CONFIRM_ADD_TO_SUB_TASK_NOTES": "Add the pasted Markdown list to the notes of subtask \"{{parentTaskTitle}}\"?",
"CONFIRM_PARENT_TASKS": "Create <strong>{{tasksCount}} new tasks</strong> from the pasted Markdown list?",
@ -1829,6 +1834,36 @@
"HELP": "You can integrate calendars to be reminded and add them as tasks within Super Productivity. The integration works by using the iCal format. For this to work your calendars need to be accessible either over the internet or via file system.",
"SHOW_BANNER_THRESHOLD": "Show a notification X before the event (blank for disabled)"
},
"CLIPBOARD_IMAGES": {
"DELETE": "Delete image",
"DELETE_ALL": "Delete All",
"DELETE_ALL_SUCCESS": "All clipboard images deleted successfully",
"DELETE_SUCCESS": "Clipboard image deleted successfully",
"ERROR_DELETING": "Failed to delete clipboard image",
"ERROR_DELETING_ALL": "Failed to delete all clipboard images",
"ERROR_LOADING": "Failed to load clipboard images",
"HELP": "Manage images pasted from clipboard. For browser version, images are stored in IndexedDB. For desktop version, configure the storage path.",
"LOADING": "Loading images...",
"MANAGE_DESCRIPTION": "View and delete images stored from pasting in markdown fields",
"MANAGE_TITLE": "Manage Stored Images",
"MANAGER_TITLE": "Clipboard Images",
"NO_IMAGES": "No clipboard images stored",
"OPEN_MANAGER": "Manage Images",
"PATH_DESCRIPTION": "Configure where clipboard images are stored on your filesystem (leave empty for default location)",
"PATH_LABEL": "Image Storage Path",
"PATH_PLACEHOLDER": "Leave empty for default location",
"PATH_TITLE": "Storage Location",
"SELECT_PATH": "Select Folder",
"SELECT_PATH_TITLE": "Select Clipboard Images Folder",
"SORT_BY": "Sort by",
"SORT_LARGEST": "Largest first",
"SORT_NEWEST": "Newest first",
"SORT_OLDEST": "Oldest first",
"SORT_SMALLEST": "Smallest first",
"TITLE": "Clipboard Images",
"TOTAL_IMAGES": "Total images",
"TOTAL_SIZE": "Total size"
},
"EVALUATION": {
"IS_HIDE_EVALUATION_SHEET": "Hide evaluation sheet in daily summary",
"TITLE": "Evaluation & Metrics"

View file

@ -80,7 +80,7 @@ TODO configure more restrictive Content-Security-Policy
connect-src * data: blob: filesystem: uploaded:;
frame-src * data: blob:;
font-src * data:;
img-src * data: filesystem: file:;
img-src * data: blob: filesystem: file:;
style-src * 'unsafe-inline' ;
script-src * 'self' 'unsafe-inline' 'unsafe-eval' blob:"
http-equiv="Content-Security-Policy"