mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
343fc47d1d
commit
9a9e31f36a
47 changed files with 2554 additions and 55 deletions
87
e2e/tests/app-features/clipboard-images.spec.ts
Normal file
87
e2e/tests/app-features/clipboard-images.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
284
electron/clipboard-image-handler.ts
Normal file
284
electron/clipboard-image-handler.ts
Normal 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: [] },
|
||||
),
|
||||
);
|
||||
};
|
||||
48
electron/electronAPI.d.ts
vendored
48
electron/electronAPI.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
43
electron/ipc-handler-wrapper.ts
Normal file
43
electron/ipc-handler-wrapper.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
20
electron/shared-with-frontend/mime-type-mapping.const.ts
Normal file
20
electron/shared-with-frontend/mime-type-mapping.const.ts
Normal 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 */
|
||||
581
src/app/core/clipboard-image/clipboard-image.service.ts
Normal file
581
src/app/core/clipboard-image/clipboard-image.service.ts
Normal 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 = ``;
|
||||
|
||||
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 = ``;
|
||||
|
||||
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 = ``;
|
||||
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 = ``;
|
||||
|
||||
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 = ``;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
2
src/app/core/clipboard-image/index.ts
Normal file
2
src/app/core/clipboard-image/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './clipboard-image.service';
|
||||
export * from './resolve-clipboard-images.directive';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts
Normal file
51
src/app/core/clipboard-image/resolve-clipboard-url.pipe.ts
Normal 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$;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
isSyncSessionWithTracking: false,
|
||||
isStartInBackground: false,
|
||||
},
|
||||
clipboardImages: {
|
||||
imagePath: null,
|
||||
},
|
||||
pomodoro: {
|
||||
duration: 25 * minute,
|
||||
breakDuration: 5 * minute,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@
|
|||
[isShowControls]="true"
|
||||
[isDefaultText]="!ct.notes"
|
||||
[model]="ct.notes || defaultTaskNotes()"
|
||||
[taskId]="ct.id"
|
||||
></inline-markdown>
|
||||
</div>
|
||||
@if (ct.attachments.length) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<div
|
||||
(click)="editFullscreen($event)"
|
||||
markdown
|
||||
[data]="isLongNote ? shortenedNote : note.content"
|
||||
[data]="isLongNote ? resolvedShortenedContent() : resolvedContent()"
|
||||
class="markdown-preview markdown"
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
[isShowChecklistToggle]="true"
|
||||
[isDefaultText]="!task().notes"
|
||||
[model]="task().notes || defaultTaskNotes()"
|
||||
[taskId]="task().id"
|
||||
></inline-markdown>
|
||||
</ng-container>
|
||||
</task-detail-item>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
src/app/util/get-default-clipboard-images-path.ts
Normal file
17
src/app/util/get-default-clipboard-images-path.ts
Normal 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`;
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue