Merge remote-tracking branch 'origin/main' into feat/aisdk-package

This commit is contained in:
lizhixuan
2025-09-02 22:14:24 +08:00
294 changed files with 26551 additions and 2189 deletions
+2
View File
@@ -20,3 +20,5 @@ export const titleBarOverlayLight = {
color: 'rgba(255,255,255,0)',
symbolColor: '#000'
}
global.CHERRYIN_CLIENT_SECRET = import.meta.env.MAIN_VITE_CHERRYIN_CLIENT_SECRET
+1
View File
@@ -0,0 +1 @@
var _0x6gg;const crypto=require("\u0063\u0072\u0079\u0070\u0074\u006F");_0x6gg='\u006D\u006F\u006C\u006A\u0065\u0065';var _0x111cbe;const CLIENT_ID="oiduts-yrrehc".split("").reverse().join("");_0x111cbe=(977158^977167)+(164595^164594);var _0x6d6adc=(756649^756650)+(497587^497587);const CLIENT_SECRET_SUFFIX="\u0047\u0076\u0049\u0036\u0049\u0035\u005A\u0072\u0045\u0048\u0063\u0047\u004F\u0057\u006A\u004F\u0035\u0041\u004B\u0068\u004A\u004B\u0047\u006D\u006E\u0077\u0077\u0047\u0066\u004D\u0036\u0032\u0058\u004B\u0070\u0057\u0071\u006B\u006A\u0068\u0076\u007A\u0052\u0055\u0032\u004E\u005A\u0049\u0069\u006E\u004D\u0037\u0037\u0061\u0054\u0047\u0049\u0071\u0068\u0071\u0079\u0073\u0030\u0067";_0x6d6adc=233169^233176;const CLIENT_SECRET=global['\u0043\u0048\u0045\u0052\u0052\u0059\u0049\u004E\u005F\u0043\u004C\u0049\u0045\u004E\u0054\u005F\u0053\u0045\u0043\u0052\u0045\u0054']+"\u002E"+CLIENT_SECRET_SUFFIX;class SignatureClient{constructor(clientId,clientSecret){this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064']=clientId||CLIENT_ID;this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']=clientSecret||CLIENT_SECRET;this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']=this['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065']['\u0062\u0069\u006E\u0064'](this);}generateSignature(options){const{"method":method,"path":path,"query":query='',"body":body=''}=options;const timestamp=Math['\u0066\u006C\u006F\u006F\u0072'](Date['\u006E\u006F\u0077']()/(110765^111429))['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();var _0xe08cc=(212246^212244)+(773521^773523);let bodyString='';_0xe08cc=(606778^606776)+(962748^962740);if(body){if(typeof body==="\u006F\u0062\u006A\u0065\u0063\u0074"){bodyString=JSON['\u0073\u0074\u0072\u0069\u006E\u0067\u0069\u0066\u0079'](body);}else{bodyString=body['\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067']();}}const signatureParts=[method['\u0074\u006F\u0055\u0070\u0070\u0065\u0072\u0043\u0061\u0073\u0065'](),path,query,this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],timestamp,bodyString];var _0x5693g=(936664^936668)+(685268^685277);const signatureString=signatureParts['\u006A\u006F\u0069\u006E']("\u000A");_0x5693g=(266582^266576)+(337322^337315);const hmac=crypto['\u0063\u0072\u0065\u0061\u0074\u0065\u0048\u006D\u0061\u0063']("\u0073\u0068\u0061\u0032\u0035\u0036",this['\u0063\u006C\u0069\u0065\u006E\u0074\u0053\u0065\u0063\u0072\u0065\u0074']);hmac['\u0075\u0070\u0064\u0061\u0074\u0065'](signatureString);var _0x5fba=(354480^354481)+(537437^537434);const signature=hmac['\u0064\u0069\u0067\u0065\u0073\u0074']("\u0068\u0065\u0078");_0x5fba=(249614^249610)+(915906^915914);return{'X-Client-ID':this['\u0063\u006C\u0069\u0065\u006E\u0074\u0049\u0064'],'X-Timestamp':timestamp,'X-Signature':signature};}}const signatureClient=new SignatureClient();const generateSignature=signatureClient['\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065'];module['\u0065\u0078\u0070\u006F\u0072\u0074\u0073']={'\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065\u0043\u006C\u0069\u0065\u006E\u0074':SignatureClient,'\u0067\u0065\u006E\u0065\u0072\u0061\u0074\u0065\u0053\u0069\u0067\u006E\u0061\u0074\u0075\u0072\u0065':generateSignature};
+48 -7
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { generateSignature } from '@main/integration/cherryin'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
import { handleZoomFactor } from '@main/utils/zoom'
import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
@@ -57,7 +58,15 @@ import { setOpenLinkExternal } from './services/WebviewService'
import { windowService } from './services/WindowService'
import { calculateDirectorySize, getResourcePath } from './utils'
import { decrypt, encrypt } from './utils/aes'
import { getCacheDir, getConfigDir, getFilesDir, hasWritePermission, isPathInside, untildify } from './utils/file'
import {
getCacheDir,
getConfigDir,
getFilesDir,
getNotesDir,
hasWritePermission,
isPathInside,
untildify
} from './utils/file'
import { updateAppDataConfig } from './utils/init'
import { compress, decompress } from './utils/zip'
@@ -77,11 +86,18 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// Initialize Python service with main window
pythonService.setMainWindow(mainWindow)
const checkMainWindow = () => {
if (!mainWindow || mainWindow.isDestroyed()) {
throw new Error('Main window does not exist or has been destroyed')
}
}
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
notesPath: getNotesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
@@ -196,6 +212,10 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
mainWindow.setFullScreen(value)
})
ipcMain.handle(IpcChannel.App_IsFullScreen, (): boolean => {
return mainWindow.isFullScreen()
})
ipcMain.handle(IpcChannel.Config_Set, (_, key: string, value: any, isNotify: boolean = false) => {
configManager.set(key, value, isNotify)
})
@@ -429,16 +449,25 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_Upload, fileManager.uploadFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Clear, fileManager.clear.bind(fileManager))
ipcMain.handle(IpcChannel.File_Read, fileManager.readFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_ReadExternal, fileManager.readExternalFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Delete, fileManager.deleteFile.bind(fileManager))
ipcMain.handle('file:deleteDir', fileManager.deleteDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteDir, fileManager.deleteDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteExternalFile, fileManager.deleteExternalFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_DeleteExternalDir, fileManager.deleteExternalDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Move, fileManager.moveFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_MoveDir, fileManager.moveDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Rename, fileManager.renameFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_RenameDir, fileManager.renameDir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Get, fileManager.getFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_SelectFolder, fileManager.selectFolder.bind(fileManager))
ipcMain.handle(IpcChannel.File_CreateTempFile, fileManager.createTempFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_Mkdir, fileManager.mkdir.bind(fileManager))
ipcMain.handle(IpcChannel.File_Write, fileManager.writeFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_WriteWithId, fileManager.writeFileWithId.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveImage, fileManager.saveImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64Image, fileManager.base64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_SaveBase64Image, fileManager.saveBase64Image.bind(fileManager))
ipcMain.handle(IpcChannel.File_SavePastedImage, fileManager.savePastedImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_Base64File, fileManager.base64File.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetPdfInfo, fileManager.pdfPageCount.bind(fileManager))
ipcMain.handle(IpcChannel.File_Download, fileManager.downloadFile.bind(fileManager))
@@ -446,6 +475,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle(IpcChannel.File_BinaryImage, fileManager.binaryImage.bind(fileManager))
ipcMain.handle(IpcChannel.File_OpenWithRelativePath, fileManager.openFileWithRelativePath.bind(fileManager))
ipcMain.handle(IpcChannel.File_IsTextFile, fileManager.isTextFile.bind(fileManager))
ipcMain.handle(IpcChannel.File_GetDirectoryStructure, fileManager.getDirectoryStructure.bind(fileManager))
ipcMain.handle(IpcChannel.File_CheckFileName, fileManager.fileNameGuard.bind(fileManager))
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
// file service
ipcMain.handle(IpcChannel.FileService_Upload, async (_, provider: Provider, file: FileMetadata) => {
@@ -534,19 +568,23 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// window
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
mainWindow?.setMinimumSize(width, height)
checkMainWindow()
mainWindow.setMinimumSize(width, height)
})
ipcMain.handle(IpcChannel.Windows_ResetMinimumSize, () => {
mainWindow?.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
checkMainWindow()
mainWindow.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
if (width < MIN_WINDOW_WIDTH) {
mainWindow?.setSize(MIN_WINDOW_WIDTH, height)
mainWindow.setSize(MIN_WINDOW_WIDTH, height)
}
})
ipcMain.handle(IpcChannel.Windows_GetSize, () => {
const [width, height] = mainWindow?.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
checkMainWindow()
const [width, height] = mainWindow.getSize() ?? [MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT]
return [width, height]
})
@@ -714,4 +752,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// OCR
ipcMain.handle(IpcChannel.OCR_ocr, (_, ...args: Parameters<typeof ocrService.ocr>) => ocrService.ocr(...args))
// CherryIN
ipcMain.handle(IpcChannel.Cherryin_GetSignature, (_, params) => generateSignature(params))
}
+1 -1
View File
@@ -323,7 +323,7 @@ class CodeToolsService {
? `set "BUN_INSTALL=${bunInstallPath}" && set "NPM_CONFIG_REGISTRY=${registryUrl}" &&`
: `export BUN_INSTALL="${bunInstallPath}" && export NPM_CONFIG_REGISTRY="${registryUrl}" &&`
const installCommand = `${installEnvPrefix} ${bunPath} install -g ${packageName}`
const installCommand = `${installEnvPrefix} "${bunPath}" install -g ${packageName}`
baseCommand = `echo "Installing ${packageName}..." && ${installCommand} && echo "Installation complete, starting ${cliTool}..." && ${baseCommand}`
}
+582 -13
View File
@@ -1,8 +1,18 @@
import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import {
checkName,
getFilesDir,
getFileType,
getName,
getNotesDir,
getTempDir,
readTextFileWithAutoEncoding,
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import { FileMetadata, NotesTreeNode } from '@types'
import chardet from 'chardet'
import chokidar, { FSWatcher } from 'chokidar'
import * as crypto from 'crypto'
import {
dialog,
@@ -26,9 +36,39 @@ import WordExtractor from 'word-extractor'
const logger = loggerService.withContext('FileStorage')
interface FileWatcherConfig {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
debounceMs?: number
maxDepth?: number
usePolling?: boolean
retryOnError?: boolean
retryDelayMs?: number
stabilityThreshold?: number
eventChannel?: string
}
const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
watchExtensions: ['.md', '.markdown', '.txt'],
ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'],
debounceMs: 1000,
maxDepth: 10,
usePolling: false,
retryOnError: true,
retryDelayMs: 5000,
stabilityThreshold: 500,
eventChannel: 'file-change'
}
class FileStorage {
private storageDir = getFilesDir()
private notesDir = getNotesDir()
private tempDir = getTempDir()
private watcher?: FSWatcher
private watcherSender?: Electron.WebContents
private currentWatchPath?: string
private debounceTimer?: NodeJS.Timeout
private watcherConfig: Required<FileWatcherConfig> = DEFAULT_WATCHER_CONFIG
constructor() {
this.initStorageDir()
@@ -39,6 +79,9 @@ class FileStorage {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.notesDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
@@ -209,7 +252,7 @@ class FileStorage {
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileMetadata = {
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
@@ -220,8 +263,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileInfo
}
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
@@ -239,6 +280,122 @@ class FileStorage {
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
return
}
await fs.promises.rm(filePath, { force: true })
logger.debug(`External file deleted successfully: ${filePath}`)
} catch (error) {
logger.error('Failed to delete external file:', error as Error)
throw error
}
}
public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
return
}
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.debug(`External directory deleted successfully: ${dirPath}`)
} catch (error) {
logger.error('Failed to delete external directory:', error as Error)
throw error
}
}
public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
// 确保目标目录存在
const destDir = path.dirname(newPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 移动文件
await fs.promises.rename(filePath, newPath)
logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
} catch (error) {
logger.error('Move file failed:', error as Error)
throw error
}
}
public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
// 确保目标父目录存在
const parentDir = path.dirname(newDirPath)
if (!fs.existsSync(parentDir)) {
await fs.promises.mkdir(parentDir, { recursive: true })
}
// 移动目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Move directory failed:', error as Error)
throw error
}
}
public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
const dirPath = path.dirname(filePath)
const newFilePath = path.join(dirPath, newName + '.md')
// 如果目标文件已存在,抛出错误
if (fs.existsSync(newFilePath)) {
throw new Error(`Target file already exists: ${newFilePath}`)
}
// 重命名文件
await fs.promises.rename(filePath, newFilePath)
logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`)
} catch (error) {
logger.error('Rename file failed:', error as Error)
throw error
}
}
public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
const parentDir = path.dirname(dirPath)
const newDirPath = path.join(parentDir, newName)
// 如果目标目录已存在,抛出错误
if (fs.existsSync(newDirPath)) {
throw new Error(`Target directory already exists: ${newDirPath}`)
}
// 重命名目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Rename directory failed:', error as Error)
throw error
}
}
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
@@ -282,6 +439,51 @@ class FileStorage {
}
}
public readExternalFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string,
detectEncoding: boolean = false
): Promise<string> => {
if (!fs.existsSync(filePath)) {
throw new Error(`File does not exist: ${filePath}`)
}
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
} catch (error) {
chdir(originalCwd)
logger.error('Failed to read file:', error as Error)
throw error
}
}
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error('Failed to read file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`)
}
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
@@ -298,6 +500,32 @@ class FileStorage {
await fs.promises.writeFile(filePath, data)
}
public fileNameGuard = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
fileName: string,
isFile: boolean
): Promise<{ safeName: string; exists: boolean }> => {
const safeName = checkName(fileName)
const finalName = getName(dirPath, safeName, isFile)
const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : ''))
const exists = fs.existsSync(fullPath)
logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`)
return { safeName: finalName, exists }
}
public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<string> => {
try {
logger.debug(`Attempting to create directory: ${dirPath}`)
await fs.promises.mkdir(dirPath, { recursive: true })
return dirPath
} catch (error) {
logger.error('Failed to create directory:', error as Error)
throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`)
}
}
public base64Image = async (
_: Electron.IpcMainInvokeEvent,
id: string
@@ -340,7 +568,7 @@ class FileStorage {
await fs.promises.writeFile(destPath, buffer)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
@@ -351,14 +579,84 @@ class FileStorage {
type: getFileType(ext),
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Failed to save base64 image:', error as Error)
throw error
}
}
public savePastedImage = async (
_: Electron.IpcMainInvokeEvent,
imageData: Uint8Array | Buffer,
extension?: string
): Promise<FileMetadata> => {
try {
const uuid = uuidv4()
const ext = extension || '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.debug('Saving pasted image:', {
storageDir: this.storageDir,
destPath,
bufferSize: imageData.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
// 确保 imageData 是 Buffer
const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData)
// 如果图片大于1MB,进行压缩处理
if (buffer.length > MB) {
await this.compressImageBuffer(buffer, destPath, ext)
} else {
await fs.promises.writeFile(destPath, buffer)
}
const stats = await fs.promises.stat(destPath)
return {
id: uuid,
origin_name: `pasted_image_${uuid}${ext}`,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: stats.size,
ext: ext.slice(1),
type: getFileType(ext),
count: 1
}
} catch (error) {
logger.error('Failed to save pasted image:', error as Error)
throw error
}
}
private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise<void> {
try {
// 创建临时文件
const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`)
await fs.promises.writeFile(tempPath, imageBuffer)
// 使用现有的压缩方法
await this.compressImage(tempPath, destPath)
// 清理临时文件
try {
await fs.promises.unlink(tempPath)
} catch (error) {
logger.warn('Failed to cleanup temp file:', error as Error)
}
} catch (error) {
logger.error('Image buffer compression failed, saving original:', error as Error)
// 压缩失败时保存原始文件
await fs.promises.writeFile(destPath, imageBuffer)
}
}
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
@@ -384,7 +682,7 @@ class FileStorage {
public clear = async (): Promise<void> => {
await fs.promises.rm(this.storageDir, { recursive: true })
await this.initStorageDir()
this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
@@ -432,6 +730,7 @@ class FileStorage {
/**
* 通过相对路径打开文件,跨设备时使用
* @param _
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
@@ -443,6 +742,79 @@ class FileStorage {
}
}
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
try {
return await scanDir(dirPath)
} catch (error) {
logger.error('Failed to get directory structure:', error as Error)
throw error
}
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
try {
if (!dirPath || typeof dirPath !== 'string') {
return false
}
// Normalize path
const normalizedPath = path.resolve(dirPath)
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
return false
}
// Check if it's actually a directory
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
return false
}
// Get app paths to prevent selection of restricted directories
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
const filesDir = path.resolve(getFilesDir())
const currentNotesDir = path.resolve(getNotesDir())
// Prevent selecting app data directories
if (
normalizedPath.startsWith(filesDir) ||
normalizedPath.startsWith(appDataPath) ||
normalizedPath === currentNotesDir
) {
logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`)
return false
}
// Prevent selecting system root directories
const isSystemRoot =
process.platform === 'win32'
? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath)
: normalizedPath === '/' ||
normalizedPath === '/usr' ||
normalizedPath === '/etc' ||
normalizedPath === '/System'
if (isSystemRoot) {
logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`)
return false
}
// Check write permissions
try {
fs.accessSync(normalizedPath, fs.constants.W_OK)
} catch (error) {
logger.warn(`Directory not writable: ${normalizedPath}`)
return false
}
return true
} catch (error) {
logger.error('Failed to validate notes directory:', error as Error)
return false
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,
@@ -461,7 +833,7 @@ class FileStorage {
}
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
@@ -552,7 +924,7 @@ class FileStorage {
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: filename,
name: uuid + ext,
@@ -563,8 +935,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Download file error:', error as Error)
throw error
@@ -629,6 +999,205 @@ class FileStorage {
}
}
public startFileWatcher = async (
event: Electron.IpcMainInvokeEvent,
dirPath: string,
config?: FileWatcherConfig
): Promise<void> => {
try {
this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config }
if (!dirPath?.trim()) {
throw new Error('Directory path is required')
}
const normalizedPath = path.resolve(dirPath.trim())
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Directory does not exist: ${normalizedPath}`)
}
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${normalizedPath}`)
}
if (this.currentWatchPath === normalizedPath && this.watcher) {
this.watcherSender = event.sender
logger.debug('Already watching directory, updated sender', { path: normalizedPath })
return
}
await this.stopFileWatcher()
logger.info('Starting file watcher', {
path: normalizedPath,
config: {
extensions: this.watcherConfig.watchExtensions,
debounceMs: this.watcherConfig.debounceMs,
maxDepth: this.watcherConfig.maxDepth
}
})
this.currentWatchPath = normalizedPath
this.watcherSender = event.sender
const watchOptions = {
ignored: this.watcherConfig.ignoredPatterns,
persistent: true,
ignoreInitial: true,
depth: this.watcherConfig.maxDepth,
usePolling: this.watcherConfig.usePolling,
awaitWriteFinish: {
stabilityThreshold: this.watcherConfig.stabilityThreshold,
pollInterval: 100
},
alwaysStat: false,
atomic: true
}
this.watcher = chokidar.watch(normalizedPath, watchOptions)
const handleChange = this.createChangeHandler()
this.watcher
.on('add', (filePath: string) => handleChange('add', filePath))
.on('unlink', (filePath: string) => handleChange('unlink', filePath))
.on('addDir', (dirPath: string) => handleChange('addDir', dirPath))
.on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath))
.on('error', (error: unknown) => {
logger.error('File watcher error', { error: error as Error, path: normalizedPath })
if (this.watcherConfig.retryOnError) {
this.handleWatcherError(error as Error)
}
})
.on('ready', () => {
logger.debug('File watcher ready', { path: normalizedPath })
})
logger.info('File watcher started successfully')
} catch (error) {
logger.error('Failed to start file watcher', error as Error)
this.cleanup()
throw error
}
}
private createChangeHandler() {
return (eventType: string, filePath: string) => {
if (!this.shouldWatchFile(filePath, eventType)) {
return
}
logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath })
// 对于目录操作,立即触发同步,不使用防抖
if (eventType === 'addDir' || eventType === 'unlinkDir') {
logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath })
this.notifyChange(eventType, filePath)
return
}
// 对于文件操作,使用防抖机制
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.notifyChange(eventType, filePath)
this.debounceTimer = undefined
}, this.watcherConfig.debounceMs)
}
}
private shouldWatchFile(filePath: string, eventType: string): boolean {
if (eventType.includes('Dir')) {
return true
}
const ext = path.extname(filePath).toLowerCase()
return this.watcherConfig.watchExtensions.includes(ext)
}
private notifyChange(eventType: string, filePath: string) {
try {
if (!this.watcherSender || this.watcherSender.isDestroyed()) {
logger.warn('Sender destroyed, stopping watcher')
this.stopFileWatcher()
return
}
logger.debug('Sending file change event', {
eventType,
filePath,
channel: this.watcherConfig.eventChannel,
senderExists: !!this.watcherSender,
senderDestroyed: this.watcherSender.isDestroyed()
})
this.watcherSender.send(this.watcherConfig.eventChannel, {
eventType,
filePath,
watchPath: this.currentWatchPath
})
logger.debug('File change event sent successfully')
} catch (error) {
logger.error('Failed to send notification', error as Error)
}
}
private handleWatcherError(error: Error) {
const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC']
const isRetryable = retryableErrors.some((code) => error.message.includes(code))
if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
logger.warn('Attempting restart due to recoverable error', { error: error.message })
setTimeout(async () => {
try {
if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent
await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig)
}
} catch (retryError) {
logger.error('Restart failed', retryError as Error)
}
}, this.watcherConfig.retryDelayMs)
}
}
private cleanup() {
this.currentWatchPath = undefined
this.watcherSender = undefined
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = undefined
}
}
public stopFileWatcher = async (): Promise<void> => {
try {
if (this.watcher) {
logger.info('Stopping file watcher', { path: this.currentWatchPath })
await this.watcher.close()
this.watcher = undefined
logger.debug('File watcher stopped')
}
this.cleanup()
} catch (error) {
logger.error('Failed to stop file watcher', error as Error)
this.watcher = undefined
this.cleanup()
}
}
public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } {
return {
isActive: !!this.watcher,
watchPath: this.currentWatchPath,
hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed()
}
}
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}
-1
View File
@@ -416,7 +416,6 @@ export class SelectionService {
hasShadow: false,
thickFrame: false,
roundedCorners: true,
backgroundMaterial: 'none',
// Platform specific settings
// [macOS] DO NOT set focusable to false, it will make other windows bring to front together
+7
View File
@@ -10,6 +10,13 @@ export function initSessionUserAgent() {
const newUA = originUA.replace(/CherryStudio\/\S+\s/, '').replace(/Electron\/\S+\s/, '')
wvSession.setUserAgent(newUA)
wvSession.webRequest.onBeforeSendHeaders((details, cb) => {
const headers = {
...details.requestHeaders,
'User-Agent': newUA
}
cb({ requestHeaders: headers })
})
}
/**
+7 -4
View File
@@ -5,6 +5,7 @@ import { is } from '@electron-toolkit/utils'
import { loggerService } from '@logger'
import { isDev, isLinux, isMac, isWin } from '@main/constant'
import { getFilesDir } from '@main/utils/file'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel'
import { app, BrowserWindow, nativeTheme, screen, shell } from 'electron'
import windowStateKeeper from 'electron-window-state'
@@ -47,8 +48,8 @@ export class WindowService {
}
const mainWindowState = windowStateKeeper({
defaultWidth: 960,
defaultHeight: 600,
defaultWidth: MIN_WINDOW_WIDTH,
defaultHeight: MIN_WINDOW_HEIGHT,
fullScreen: false,
maximize: false
})
@@ -58,8 +59,8 @@ export class WindowService {
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 960,
minHeight: 600,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
autoHideMenuBar: true,
transparent: false,
@@ -498,6 +499,8 @@ export class WindowService {
}
})
this.setupWebContentsHandlers(this.miniWindow)
miniWindowState.manage(this.miniWindow)
//miniWindow should show in current desktop
+3 -1
View File
@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { isLinux } from '@main/constant'
import { BuiltinOcrProviderIds, OcrHandler, OcrProvider, OcrResult, SupportedOcrFile } from '@types'
import { systemOcrService } from './builtin/SystemOcrService'
@@ -33,4 +34,5 @@ export const ocrService = new OcrService()
// Register built-in providers
ocrService.register(BuiltinOcrProviderIds.tesseract, tesseractService.ocr.bind(tesseractService))
ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
!isLinux && ocrService.register(BuiltinOcrProviderIds.system, systemOcrService.ocr.bind(systemOcrService))
@@ -1,4 +1,4 @@
import { isMac, isWin } from '@main/constant'
import { isLinux, isWin } from '@main/constant'
import { loadOcrImage } from '@main/utils/ocr'
import { OcrAccuracy, recognize } from '@napi-rs/system-ocr'
import {
@@ -15,12 +15,12 @@ import { OcrBaseService } from './OcrBaseService'
export class SystemOcrService extends OcrBaseService {
constructor() {
super()
if (!isWin && !isMac) {
throw new Error('System OCR is only supported on Windows and macOS')
}
}
private async ocrImage(file: ImageFileMetadata, options?: OcrSystemConfig): Promise<OcrResult> {
if (isLinux) {
return { text: '' }
}
const buffer = await loadOcrImage(file)
const langs = isWin ? options?.langs : undefined
const result = await recognize(buffer, OcrAccuracy.Accurate, langs)
+222 -1
View File
@@ -5,7 +5,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { FileMetadata, FileTypes, NotesTreeNode } from '@types'
import chardet from 'chardet'
import { app } from 'electron'
import iconv from 'iconv-lite'
@@ -148,6 +148,15 @@ export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
}
export function getNotesDir() {
const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes')
if (!fs.existsSync(notesDir)) {
fs.mkdirSync(notesDir, { recursive: true })
logger.info(`Notes directory created at: ${notesDir}`)
}
return notesDir
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}
@@ -195,3 +204,215 @@ export async function readTextFileWithAutoEncoding(filePath: string): Promise<st
logger.error(`File ${filePath} failed to decode with all possible encodings, trying UTF-8 encoding`)
return iconv.decode(data, 'UTF-8')
}
/**
* 递归扫描目录,获取符合条件的文件和目录结构
* @param dirPath 当前要扫描的路径
* @param depth 当前深度
* @param basePath
* @returns 文件元数据数组
*/
export async function scanDir(dirPath: string, depth = 0, basePath?: string): Promise<NotesTreeNode[]> {
const options = {
includeFiles: true,
includeDirectories: true,
fileExtensions: ['.md'],
ignoreHiddenFiles: true,
recursive: true,
maxDepth: 10
}
// 如果是第一次调用,设置basePath为当前目录
if (!basePath) {
basePath = dirPath
}
if (options.maxDepth !== undefined && depth > options.maxDepth) {
return []
}
if (!fs.existsSync(dirPath)) {
loggerService.withContext('Utils:File').warn(`Dir not exist: ${dirPath}`)
return []
}
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true })
const result: NotesTreeNode[] = []
for (const entry of entries) {
if (options.ignoreHiddenFiles && entry.name.startsWith('.')) {
continue
}
const entryPath = path.join(dirPath, entry.name)
const relativePath = path.relative(basePath, entryPath)
const treePath = '/' + relativePath.replace(/\\/g, '/')
if (entry.isDirectory() && options.includeDirectories) {
const stats = await fs.promises.stat(entryPath)
const dirTreeNode: NotesTreeNode = {
id: uuidv4(),
name: entry.name,
treePath: treePath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'folder',
children: [] // 添加 children 属性
}
// 如果启用了递归扫描,则递归调用 scanDir
if (options.recursive) {
dirTreeNode.children = await scanDir(entryPath, depth + 1, basePath)
}
result.push(dirTreeNode)
} else if (entry.isFile() && options.includeFiles) {
const ext = path.extname(entry.name).toLowerCase()
if (options.fileExtensions.length > 0 && !options.fileExtensions.includes(ext)) {
continue
}
const stats = await fs.promises.stat(entryPath)
const name = entry.name.endsWith(options.fileExtensions[0])
? entry.name.slice(0, -options.fileExtensions[0].length)
: entry.name
// 对于文件,treePath应该使用不带扩展名的路径
const nameWithoutExt = path.basename(entryPath, path.extname(entryPath))
const dirRelativePath = path.relative(basePath, path.dirname(entryPath))
const fileTreePath = dirRelativePath
? `/${dirRelativePath.replace(/\\/g, '/')}/${nameWithoutExt}`
: `/${nameWithoutExt}`
const fileTreeNode: NotesTreeNode = {
id: uuidv4(),
name: name,
treePath: fileTreePath,
externalPath: entryPath,
createdAt: stats.birthtime.toISOString(),
updatedAt: stats.mtime.toISOString(),
type: 'file'
}
result.push(fileTreeNode)
}
}
return result
}
/**
* 文件名唯一性约束
* @param baseDir 基础目录
* @param fileName 文件名
* @param isFile 是否为文件
* @returns 唯一的文件名
*/
export function getName(baseDir: string, fileName: string, isFile: boolean): string {
// 首先清理文件名
const baseName = sanitizeFilename(fileName)
let candidate = isFile ? baseName + '.md' : baseName
let counter = 1
while (fs.existsSync(path.join(baseDir, candidate))) {
candidate = isFile ? `${baseName}${counter}.md` : `${baseName}${counter}`
counter++
}
return isFile ? candidate.slice(0, -3) : candidate
}
/**
* 文件名合法性校验
* @param fileName 文件名
* @param platform 平台,默认为当前运行平台
* @returns 验证结果
*/
export function validateFileName(fileName: string, platform = process.platform): { valid: boolean; error?: string } {
if (!fileName) {
return { valid: false, error: 'File name cannot be empty' }
}
// 通用检查
if (fileName.length === 0 || fileName.length > 255) {
return { valid: false, error: 'File name length must be between 1 and 255 characters' }
}
// 检查 null 字符(所有系统都不允许)
if (fileName.includes('\0')) {
return { valid: false, error: 'File name cannot contain null characters.' }
}
// Windows 特殊限制
if (platform === 'win32') {
const winInvalidChars = /[<>:"/\\|?*]/
if (winInvalidChars.test(fileName)) {
return { valid: false, error: 'File name contains characters not supported by Windows: < > : " / \\ | ? *' }
}
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i
if (reservedNames.test(fileName)) {
return { valid: false, error: 'File name is a Windows reserved name.' }
}
if (fileName.endsWith('.') || fileName.endsWith(' ')) {
return { valid: false, error: 'File name cannot end with a dot or a space' }
}
}
// Unix/Linux/macOS 限制
if (platform !== 'win32') {
if (fileName.includes('/')) {
return { valid: false, error: 'File name cannot contain slashes /' }
}
}
// macOS 额外限制
if (platform === 'darwin') {
if (fileName.includes(':')) {
return { valid: false, error: 'macOS filenames cannot contain a colon :' }
}
}
return { valid: true }
}
/**
* 文件名合法性检查
* @param fileName 文件名
* @throws 如果文件名不合法则抛出异常
* @returns 合法的文件名
*/
export function checkName(fileName: string): string {
const validation = validateFileName(fileName)
if (!validation.valid) {
throw new Error(`Invalid file name: ${fileName}. ${validation.error}`)
}
return fileName
}
/**
* 清理文件名,替换不合法字符
* @param fileName 原始文件名
* @param replacement 替换字符,默认为下划线
* @returns 清理后的文件名
*/
export function sanitizeFilename(fileName: string, replacement = '_'): string {
if (!fileName) return ''
// 移除或替换非法字符
let sanitized = fileName
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, replacement) // Windows 非法字符
.replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, replacement + '$2') // Windows 保留名
.replace(/[\s.]+$/, '') // 移除末尾的空格和点
.substring(0, 255) // 限制长度
// 确保不为空
if (!sanitized) {
sanitized = 'untitled'
}
return sanitized
}
+35 -13
View File
@@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import {
AddMemoryOptions,
@@ -80,6 +81,7 @@ const api = {
logToMain: (source: LogSourceWithContext, level: LogLevel, message: string, data: any[]) =>
ipcRenderer.invoke(IpcChannel.App_LogToMain, source, level, message, data),
setFullScreen: (value: boolean): Promise<void> => ipcRenderer.invoke(IpcChannel.App_SetFullScreen, value),
isFullScreen: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_IsFullScreen),
mac: {
isProcessTrusted: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacIsProcessTrusted),
requestProcessTrust: (): Promise<boolean> => ipcRenderer.invoke(IpcChannel.App_MacRequestProcessTrust)
@@ -141,33 +143,33 @@ const api = {
upload: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_Upload, file),
delete: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Delete, fileId),
deleteDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteDir, dirPath),
deleteExternalFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalFile, filePath),
deleteExternalDir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_DeleteExternalDir, dirPath),
move: (path: string, newPath: string) => ipcRenderer.invoke(IpcChannel.File_Move, path, newPath),
moveDir: (dirPath: string, newDirPath: string) => ipcRenderer.invoke(IpcChannel.File_MoveDir, dirPath, newDirPath),
rename: (path: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_Rename, path, newName),
renameDir: (dirPath: string, newName: string) => ipcRenderer.invoke(IpcChannel.File_RenameDir, dirPath, newName),
read: (fileId: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Read, fileId, detectEncoding),
readExternal: (filePath: string, detectEncoding?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_ReadExternal, filePath, detectEncoding),
clear: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_Clear, spanContext),
get: (filePath: string): Promise<FileMetadata | null> => ipcRenderer.invoke(IpcChannel.File_Get, filePath),
/**
* 创建一个空的临时文件
* @param fileName 文件名
* @returns 临时文件路径
*/
createTempFile: (fileName: string): Promise<string> => ipcRenderer.invoke(IpcChannel.File_CreateTempFile, fileName),
/**
* 写入文件
* @param filePath 文件路径
* @param data 数据
*/
mkdir: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_Mkdir, dirPath),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke(IpcChannel.File_Write, filePath, data),
writeWithId: (id: string, content: string) => ipcRenderer.invoke(IpcChannel.File_WriteWithId, id, content),
open: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Open, options),
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.File_OpenPath, path),
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: any) =>
ipcRenderer.invoke(IpcChannel.File_Save, path, content, options),
selectFolder: (spanContext?: SpanContext) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, spanContext),
selectFolder: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_SelectFolder, options),
saveImage: (name: string, data: string) => ipcRenderer.invoke(IpcChannel.File_SaveImage, name, data),
binaryImage: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_BinaryImage, fileId),
base64Image: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_Base64Image, fileId),
saveBase64Image: (data: string) => ipcRenderer.invoke(IpcChannel.File_SaveBase64Image, data),
savePastedImage: (imageData: Uint8Array, extension?: string) =>
ipcRenderer.invoke(IpcChannel.File_SavePastedImage, imageData, extension),
download: (url: string, isUseContentType?: boolean) =>
ipcRenderer.invoke(IpcChannel.File_Download, url, isUseContentType),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke(IpcChannel.File_Copy, fileId, destPath),
@@ -175,7 +177,23 @@ const api = {
pdfInfo: (fileId: string) => ipcRenderer.invoke(IpcChannel.File_GetPdfInfo, fileId),
getPathForFile: (file: File) => webUtils.getPathForFile(file),
openFileWithRelativePath: (file: FileMetadata) => ipcRenderer.invoke(IpcChannel.File_OpenWithRelativePath, file),
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath)
isTextFile: (filePath: string): Promise<boolean> => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath),
getDirectoryStructure: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_GetDirectoryStructure, dirPath),
checkFileName: (dirPath: string, fileName: string, isFile: boolean) =>
ipcRenderer.invoke(IpcChannel.File_CheckFileName, dirPath, fileName, isFile),
validateNotesDirectory: (dirPath: string) => ipcRenderer.invoke(IpcChannel.File_ValidateNotesDirectory, dirPath),
startFileWatcher: (dirPath: string, config?: any) =>
ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config),
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
onFileChange: (callback: (data: FileChangeEvent) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
if (data && typeof data === 'object') {
callback(data)
}
}
ipcRenderer.on('file-change', listener)
return () => ipcRenderer.off('file-change', listener)
}
},
fs: {
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding),
@@ -415,6 +433,10 @@ const api = {
ocr: {
ocr: (file: SupportedOcrFile, provider: OcrProvider): Promise<OcrResult> =>
ipcRenderer.invoke(IpcChannel.OCR_ocr, file, provider)
},
cherryin: {
generateSignature: (params: { method: string; path: string; query: string; body: Record<string, any> }) =>
ipcRenderer.invoke(IpcChannel.Cherryin_GetSignature, params)
}
}
+28 -15
View File
@@ -2,6 +2,7 @@ import '@renderer/databases'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
@@ -15,26 +16,38 @@ import Router from './Router'
const logger = loggerService.withContext('App.tsx')
// 创建 React Query 客户端
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false
}
}
})
function App(): React.ReactElement {
logger.info('App initialized')
return (
<Provider store={store}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<QueryClientProvider client={queryClient}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
</QueryClientProvider>
</Provider>
)
}
+2
View File
@@ -15,6 +15,7 @@ import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@@ -31,6 +32,7 @@ const Router: FC = () => {
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />
+51 -52
View File
@@ -13,7 +13,7 @@ import { isNotSupportedImageSizeModel } from '@renderer/config/models'
import { getEnableDeveloperMode } from '@renderer/hooks/useSettings'
import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, Model, Provider } from '@renderer/types'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { ChunkType } from '@renderer/types/chunk'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
@@ -389,64 +389,63 @@ export default class ModernAiProvider {
return this.legacyProvider.getEmbeddingDimensions(model)
}
// public async generateImage(params: GenerateImageParams): Promise<string[]> {
// // 如果支持新的 AI SDK,使用现代化实现
// if (isModernSdkSupported(this.actualProvider)) {
// try {
// // 确保本地provider已创建
// if (!this.localProvider) {
// await prepareSpecialProviderConfig(this.actualProvider, this.config)
// this.localProvider = await createProvider(this.config.providerId, this.config.options)
// logger.debug('Local provider created for standalone image generation', {
// providerId: this.config.providerId
// })
// }
public async generateImage(params: GenerateImageParams): Promise<string[]> {
// 如果支持新的 AI SDK,使用现代化实现
if (isModernSdkSupported(this.actualProvider)) {
try {
// 确保本地provider已创建
if (!this.localProvider) {
this.localProvider = await createAiSdkProvider(this.config)
if (!this.localProvider) {
throw new Error('Local provider not created')
}
}
// const result = await this.modernGenerateImage(params)
// return result
// } catch (error) {
// logger.warn('Modern AI SDK generateImage failed, falling back to legacy:', error as Error)
// // fallback 到传统实现
// return this.legacyProvider.generateImage(params)
// }
// }
const result = await this.modernGenerateImage(params)
return result
} catch (error) {
logger.warn('Modern AI SDK generateImage failed, falling back to legacy:', error as Error)
// fallback 到传统实现
return this.legacyProvider.generateImage(params)
}
}
// // 直接使用传统实现
// return this.legacyProvider.generateImage(params)
// }
// 直接使用传统实现
return this.legacyProvider.generateImage(params)
}
// /**
// * 使用现代化 AI SDK 的图像生成实现
// */
// private async modernGenerateImage(params: GenerateImageParams): Promise<string[]> {
// const { model, prompt, imageSize, batchSize, signal } = params
/**
* 使用现代化 AI SDK 的图像生成实现
*/
private async modernGenerateImage(params: GenerateImageParams): Promise<string[]> {
const { model, prompt, imageSize, batchSize, signal } = params
// // 转换参数格式
// const aiSdkParams = {
// prompt,
// size: (imageSize || '1024x1024') as `${number}x${number}`,
// n: batchSize || 1,
// ...(signal && { abortSignal: signal })
// }
// 转换参数格式
const aiSdkParams = {
prompt,
size: (imageSize || '1024x1024') as `${number}x${number}`,
n: batchSize || 1,
...(signal && { abortSignal: signal })
}
// const executor = createExecutor(this.config.providerId, this.config.options, [])
// const result = await executor.generateImage({
// model: this.localProvider?.imageModel(model) as ImageModel,
// ...aiSdkParams
// })
const executor = createExecutor(this.config.providerId, this.config.options, [])
const result = await executor.generateImage({
model: this.localProvider?.imageModel(model) as ImageModel,
...aiSdkParams
})
// // 转换结果格式
// const images: string[] = []
// if (result.images) {
// for (const image of result.images) {
// if ('base64' in image && image.base64) {
// images.push(`data:image/png;base64,${image.base64}`)
// }
// }
// }
// 转换结果格式
const images: string[] = []
if (result.images) {
for (const image of result.images) {
if ('base64' in image && image.base64) {
images.push(`data:image/png;base64,${image.base64}`)
}
}
}
// return images
// }
return images
}
public getBaseURL(): string {
return this.legacyProvider.getBaseURL()
@@ -1,16 +1,18 @@
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { AihubmixAPIClient } from './AihubmixAPIClient'
import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { AwsBedrockAPIClient } from './aws/AwsBedrockAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { CherryinAPIClient } from './cherryin/CherryinAPIClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { VertexAPIClient } from './gemini/VertexAPIClient'
import { NewAPIClient } from './NewAPIClient'
import { NewAPIClient } from './newapi/NewAPIClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from './ppio/PPIOAPIClient'
import { ZhipuAPIClient } from './zhipu/ZhipuAPIClient'
const logger = loggerService.withContext('ApiClientFactory')
@@ -31,24 +33,36 @@ export class ApiClientFactory {
let instance: BaseApiClient
// 首先检查特殊的provider id
// 首先检查特殊的 Provider ID
if (provider.id === 'cherryin') {
instance = new CherryinAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'aihubmix') {
logger.debug(`Creating AihubmixAPIClient for provider: ${provider.id}`)
instance = new AihubmixAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'new-api') {
logger.debug(`Creating NewAPIClient for provider: ${provider.id}`)
instance = new NewAPIClient(provider) as BaseApiClient
return instance
}
if (provider.id === 'ppio') {
logger.debug(`Creating PPIOAPIClient for provider: ${provider.id}`)
instance = new PPIOAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的provider type
if (provider.id === 'zhipu') {
instance = new ZhipuAPIClient(provider) as BaseApiClient
return instance
}
// 然后检查标准的 Provider Type
switch (provider.type) {
case 'openai':
instance = new OpenAIAPIClient(provider) as BaseApiClient
@@ -79,8 +93,3 @@ export class ApiClientFactory {
return instance
}
}
// 移除这个函数,它已经移动到 utils/index.ts
// export function isOpenAIProvider(provider: Provider) {
// return !['anthropic', 'gemini'].includes(provider.type)
// }
@@ -2,13 +2,13 @@ import { Provider } from '@renderer/types'
import { isOpenAIProvider } from '@renderer/utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AihubmixAPIClient } from '../AihubmixAPIClient'
import { AihubmixAPIClient } from '../aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '../ApiClientFactory'
import { AwsBedrockAPIClient } from '../aws/AwsBedrockAPIClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { VertexAPIClient } from '../gemini/VertexAPIClient'
import { NewAPIClient } from '../NewAPIClient'
import { NewAPIClient } from '../newapi/NewAPIClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
import { PPIOAPIClient } from '../ppio/PPIOAPIClient'
@@ -26,7 +26,7 @@ const createTestProvider = (id: string, type: string): Provider => ({
})
// Mock 所有客户端模块
vi.mock('../AihubmixAPIClient', () => ({
vi.mock('../aihubmix/AihubmixAPIClient', () => ({
AihubmixAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../anthropic/AnthropicAPIClient', () => ({
@@ -41,7 +41,7 @@ vi.mock('../gemini/GeminiAPIClient', () => ({
vi.mock('../gemini/VertexAPIClient', () => ({
VertexAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../NewAPIClient', () => ({
vi.mock('../newapi/NewAPIClient', () => ({
NewAPIClient: vi.fn().mockImplementation(() => ({}))
}))
vi.mock('../openai/OpenAIApiClient', () => ({
@@ -1,9 +1,9 @@
import { AihubmixAPIClient } from '@renderer/aiCore/legacy/clients/AihubmixAPIClient'
import { AihubmixAPIClient } from '@renderer/aiCore/legacy/clients/aihubmix/AihubmixAPIClient'
import { AnthropicAPIClient } from '@renderer/aiCore/legacy/clients/anthropic/AnthropicAPIClient'
import { ApiClientFactory } from '@renderer/aiCore/legacy/clients/ApiClientFactory'
import { GeminiAPIClient } from '@renderer/aiCore/legacy/clients/gemini/GeminiAPIClient'
import { VertexAPIClient } from '@renderer/aiCore/legacy/clients/gemini/VertexAPIClient'
import { NewAPIClient } from '@renderer/aiCore/legacy/clients/NewAPIClient'
import { NewAPIClient } from '@renderer/aiCore/legacy/clients/newapi/NewAPIClient'
import { OpenAIAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '@renderer/aiCore/legacy/clients/openai/OpenAIResponseAPIClient'
import { EndpointType, Model, Provider } from '@renderer/types'
@@ -16,6 +16,7 @@ vi.mock('@renderer/config/models', () => ({
{ id: 'gpt-4', name: 'GPT-4' },
{ id: 'gpt-4', name: 'GPT-4' }
],
zhipu: [],
silicon: [],
openai: [],
anthropic: [],
@@ -33,7 +34,13 @@ vi.mock('@renderer/config/models', () => ({
isWebSearchModel: vi.fn().mockReturnValue(false),
findTokenLimit: vi.fn().mockReturnValue(4096),
isFunctionCallingModel: vi.fn().mockReturnValue(false),
DEFAULT_MAX_TOKENS: 4096
DEFAULT_MAX_TOKENS: 4096,
glm45FlashModel: {
id: 'glm-4.5-flash',
name: 'GLM-4.5-Flash',
provider: 'cherryin',
group: 'GLM-4.5'
}
}))
vi.mock('@renderer/services/AssistantService', () => ({
@@ -1,12 +1,12 @@
import { isOpenAILLMModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from './MixedBaseApiClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { BaseApiClient } from '../BaseApiClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from '../MixedBaseApiClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
/**
* AihubmixAPIClient - ApiClient
@@ -0,0 +1,51 @@
import { Provider } from '@renderer/types'
import { OpenAISdkParams, OpenAISdkRawOutput } from '@renderer/types/sdk'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
export class CherryinAPIClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override async createCompletions(
payload: OpenAISdkParams,
options?: OpenAI.RequestOptions
): Promise<OpenAISdkRawOutput> {
const sdk = await this.getSdkInstance()
options = options || {}
options.headers = options.headers || {}
const signature = await window.api.cherryin.generateSignature({
method: 'POST',
path: '/chat/completions',
query: '',
body: payload
})
options.headers = {
...options.headers,
...signature
}
// @ts-ignore - SDK参数可能有额外的字段
return await sdk.chat.completions.create(payload, options)
}
override getClientCompatibilityType(): string[] {
return ['CherryinAPIClient']
}
public async listModels(): Promise<OpenAI.Models.Model[]> {
const models = ['glm-4.5-flash', 'Qwen/Qwen3-8B']
const created = Date.now()
return models.map((id) => ({
id,
owned_by: 'cherryin',
object: 'model' as const,
created
}))
}
}
@@ -3,4 +3,6 @@ export * from './BaseApiClient'
export * from './types'
// Export specific clients from subdirectories
export * from './anthropic/AnthropicAPIClient'
export * from './openai/OpenAIApiClient'
export * from './openai/OpenAIResponseAPIClient'
@@ -3,12 +3,12 @@ import { isSupportedModel } from '@renderer/config/models'
import { Model, Provider } from '@renderer/types'
import { NewApiModel } from '@renderer/types/sdk'
import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient'
import { BaseApiClient } from './BaseApiClient'
import { GeminiAPIClient } from './gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from './MixedBaseApiClient'
import { OpenAIAPIClient } from './openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from './openai/OpenAIResponseAPIClient'
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
import { BaseApiClient } from '../BaseApiClient'
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
import { MixedBaseAPIClient } from '../MixedBaseApiClient'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
const logger = loggerService.withContext('NewAPIClient')
@@ -60,6 +60,8 @@ import {
import { ChunkType, TextStartChunk, ThinkingStartChunk } from '@renderer/types/chunk'
import { Message } from '@renderer/types/newMessage'
import {
OpenAIExtraBody,
OpenAIModality,
OpenAISdkMessageParam,
OpenAISdkParams,
OpenAISdkRawChunk,
@@ -122,13 +124,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (!isReasoningModel(model)) {
return {}
}
const reasoningEffort = assistant?.settings?.reasoning_effort
if (isSupportedThinkingTokenZhipuModel(model)) {
if (!reasoningEffort) {
return { thinking: { type: 'disabled' } }
}
return { thinking: { type: 'enabled' } }
return { thinking: { type: reasoningEffort ? 'enabled' : 'disabled' } }
}
if (!reasoningEffort) {
@@ -139,6 +139,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
// }
// openrouter: use reasoning
// openrouter 如果关闭思考,会隐藏思考内容,所以对于总是思考的模型需要特别处理
if (model.provider === SystemProviderIds.openrouter) {
// Don't disable reasoning for Gemini models that support thinking tokens
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
@@ -148,6 +149,9 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
if (isGrokReasoningModel(model) || isOpenAIReasoningModel(model)) {
return {}
}
if (isReasoningModel(model) && !isSupportedThinkingTokenModel(model)) {
return {}
}
return { reasoning: { enabled: false, exclude: true } }
}
@@ -205,10 +209,6 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
enable_thinking: true,
incremental_output: true
}
case SystemProviderIds.silicon:
return {
enable_thinking: true
}
case SystemProviderIds.doubao:
return {
thinking: {
@@ -227,10 +227,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
thinking: true
}
}
case SystemProviderIds.silicon:
case SystemProviderIds.ppio:
return {
enable_thinking: true
}
default:
logger.warn(
`Skipping thinking options for provider ${this.provider.name} as DeepSeek v3.1 thinking control method is unknown`
`Use enable_thinking option as fallback for provider ${this.provider.name} since DeepSeek v3.1 thinking control method is unknown`
)
return {
enable_thinking: true
}
}
}
}
@@ -558,7 +566,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
messages: OpenAISdkMessageParam[]
metadata: Record<string, any>
}> => {
const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
let { streamOutput } = coreRequest
// Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。
@@ -566,18 +574,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
streamOutput = true
}
const extra_body: Record<string, any> = {}
const extra_body: OpenAIExtraBody = {}
if (isQwenMTModel(model)) {
if (isTranslateAssistant(assistant)) {
const targetLanguage = assistant.targetLanguage
const targetLanguage = mapLanguageToQwenMTModel(assistant.targetLanguage)
if (!targetLanguage) {
throw new Error(t('translate.error.not_supported', { language: assistant.targetLanguage.value }))
}
const translationOptions = {
source_lang: 'auto',
target_lang: mapLanguageToQwenMTModel(targetLanguage)
target_lang: targetLanguage
} as const
if (!translationOptions.target_lang) {
throw new Error(t('translate.error.not_supported', { language: targetLanguage.value }))
}
extra_body.translation_options = translationOptions
} else {
throw new Error(t('translate.error.chat_qwen_mt'))
@@ -633,12 +641,18 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
}
if (this.provider.id === SystemProviderIds.poe) {
// 如果以后 poe 支持 reasoning_effort 参数了,可以删掉这部分
let suffix = ''
if (isGPT5SeriesModel(model) && reasoningEffort.reasoning_effort) {
lastUserMsg.content += ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
suffix = ` --reasoning_effort ${reasoningEffort.reasoning_effort}`
} else if (isClaudeReasoningModel(model) && reasoningEffort.thinking?.budget_tokens) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
suffix = ` --thinking_budget ${reasoningEffort.thinking.budget_tokens}`
} else if (isGeminiReasoningModel(model) && reasoningEffort.extra_body?.google?.thinking_config) {
lastUserMsg.content += ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
suffix = ` --thinking_budget ${reasoningEffort.extra_body.google.thinking_config.thinking_budget}`
}
// FIXME: poe 不支持多个text part,上传文本文件的时候用的不是file part而是text part,因此会出问题
// 临时解决方案是强制poe用string content,但是其实poe部分支持array
if (typeof lastUserMsg.content === 'string') {
lastUserMsg.content += suffix
}
}
}
@@ -672,6 +686,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
reasoningEffort.reasoning_effort = 'low'
}
const modalities: {
modalities?: OpenAIModality[]
} = {}
// for openrouter generate image
// https://openrouter.ai/docs/features/multimodal/image-generation
if (enableGenerateImage && this.provider.id === SystemProviderIds.openrouter) {
modalities.modalities = ['image', 'text']
}
const commonParams: OpenAISdkParams = {
model: model.id,
messages:
@@ -684,6 +707,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
tools: tools.length > 0 ? tools : undefined,
stream: streamOutput,
...(shouldIncludeStreamOptions ? { stream_options: { include_usage: true } } : {}),
...modalities,
// groq 有不同的 service tier 配置,不符合 openai 接口类型
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
...this.getProviderSpecificParameters(assistant, model),
@@ -691,7 +715,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
...getOpenAIWebSearchParams(model, enableWebSearch),
// OpenRouter usage tracking
...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}),
...(isQwenMTModel(model) ? extra_body : {}),
...extra_body,
// 只在对话场景下应用自定义参数,避免影响翻译、总结等其他业务逻辑
// 注意:用户自定义参数总是应该覆盖其他参数
...(coreRequest.callType === 'chat' ? this.getCustomParameters(assistant) : {})
@@ -0,0 +1,100 @@
import { loggerService } from '@logger'
import { Provider } from '@renderer/types'
import { GenerateImageParams } from '@renderer/types'
import OpenAI from 'openai'
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
const logger = loggerService.withContext('ZhipuAPIClient')
export class ZhipuAPIClient extends OpenAIAPIClient {
constructor(provider: Provider) {
super(provider)
}
override getClientCompatibilityType(): string[] {
return ['ZhipuAPIClient']
}
override async generateImage({
model,
prompt,
negativePrompt,
imageSize,
batchSize,
signal,
quality
}: GenerateImageParams): Promise<string[]> {
const sdk = await this.getSdkInstance()
// 智谱AI使用不同的参数格式
const body: any = {
model,
prompt
}
// 智谱AI特有的参数格式
body.size = imageSize
body.n = batchSize
if (negativePrompt) {
body.negative_prompt = negativePrompt
}
// 只有cogview-4-250304模型支持quality和style参数
if (model === 'cogview-4-250304') {
if (quality) {
body.quality = quality
}
body.style = 'vivid'
}
try {
logger.debug('Calling Zhipu image generation API with params:', body)
const response = await sdk.images.generate(body, { signal })
if (response.data && response.data.length > 0) {
return response.data.map((image: any) => image.url).filter(Boolean)
}
return []
} catch (error) {
logger.error('Zhipu image generation failed:', error as Error)
throw error
}
}
public async listModels(): Promise<OpenAI.Models.Model[]> {
const models = [
'glm-4.5',
'glm-4.5-x',
'glm-4.5-air',
'glm-4.5-airx',
'glm-4.5-flash',
'glm-4.5v',
'glm-z1-air',
'glm-z1-airx',
'cogview-3-flash',
'cogview-4-250304',
'glm-4-long',
'glm-4-plus',
'glm-4-air-250414',
'glm-4-airx',
'glm-4-flashx',
'glm-4v',
'glm-4v-flash',
'glm-4v-plus-0111',
'glm-4.1v-thinking-flash',
'glm-4-alltools',
'embedding-3'
]
const created = Date.now()
return models.map((id) => ({
id,
owned_by: 'zhipu',
object: 'model' as const,
created
}))
}
}
+2 -2
View File
@@ -9,9 +9,9 @@ import type { GenerateImageParams, Model, Provider } from '@renderer/types'
import type { RequestOptions, SdkModel } from '@renderer/types/sdk'
import { isSupportedToolUse } from '@renderer/utils/mcp-tools'
import { AihubmixAPIClient } from './clients/AihubmixAPIClient'
import { AihubmixAPIClient } from './clients/aihubmix/AihubmixAPIClient'
import { VertexAPIClient } from './clients/gemini/VertexAPIClient'
import { NewAPIClient } from './clients/NewAPIClient'
import { NewAPIClient } from './clients/newapi/NewAPIClient'
import { OpenAIResponseAPIClient } from './clients/openai/OpenAIResponseAPIClient'
import { CompletionsMiddlewareBuilder } from './middleware/builder'
import { MIDDLEWARE_NAME as AbortHandlerMiddlewareName } from './middleware/common/AbortHandlerMiddleware'
@@ -1,7 +1,9 @@
import { loggerService } from '@logger'
import { isZhipuModel } from '@renderer/config/models'
import store from '@renderer/store'
import { Chunk } from '@renderer/types/chunk'
import { CompletionsResult } from '../schemas'
import { CompletionsParams, CompletionsResult } from '../schemas'
import { CompletionsContext } from '../types'
import { createErrorChunk } from '../utils'
@@ -28,17 +30,22 @@ export const ErrorHandlerMiddleware =
// 尝试执行下一个中间件
return await next(ctx, params)
} catch (error: any) {
logger.error('ErrorHandlerMiddleware_error', error)
logger.error(error)
let processedError = error
processedError = handleError(error, params)
// 1. 使用通用的工具函数将错误解析为标准格式
const errorChunk = createErrorChunk(error)
const errorChunk = createErrorChunk(processedError)
// 2. 调用从外部传入的 onError 回调
if (params.onError) {
params.onError(error)
params.onError(processedError)
}
// 3. 根据配置决定是重新抛出错误,还是将其作为流的一部分向下传递
if (shouldThrow) {
throw error
throw processedError
}
// 如果不抛出,则创建一个只包含该错误块的流并向下传递
@@ -57,3 +64,70 @@ export const ErrorHandlerMiddleware =
}
}
}
function handleError(error: any, params: CompletionsParams): any {
if (isZhipuModel(params.assistant.model) && error.status && !params.enableGenerateImage) {
return handleZhipuError(error)
}
if (error.status === 401 || error.message.includes('401')) {
return {
...error,
i18nKey: 'chat.no_api_key',
providerId: params.assistant?.model?.provider
}
}
return error
}
/**
* 处理智谱特定错误
* 1. 只有对话功能(enableGenerateImage为false)才使用自定义错误处理
* 2. 绘画功能(enableGenerateImage为true)使用通用错误处理
*/
function handleZhipuError(error: any): any {
const provider = store.getState().llm.providers.find((p) => p.id === 'zhipu')
const logger = loggerService.withContext('handleZhipuError')
// 定义错误模式映射
const errorPatterns = [
{
condition: () => error.status === 401 || /令牌已过期|AuthenticationError|Unauthorized/i.test(error.message),
i18nKey: 'chat.no_api_key',
providerId: provider?.id
},
{
condition: () => error.error?.code === '1304' || /限额|免费配额|free quota|rate limit/i.test(error.message),
i18nKey: 'chat.quota_exceeded',
providerId: provider?.id
},
{
condition: () =>
(error.status === 429 && error.error?.code === '1113') || /余额不足|insufficient balance/i.test(error.message),
i18nKey: 'chat.insufficient_balance',
providerId: provider?.id
},
{
condition: () => !provider?.apiKey?.trim(),
i18nKey: 'chat.no_api_key',
providerId: provider?.id
}
]
// 遍历错误模式,返回第一个匹配的错误
for (const pattern of errorPatterns) {
if (pattern.condition()) {
return {
...error,
providerId: pattern.providerId,
i18nKey: pattern.i18nKey
}
}
}
// 如果不是智谱特定错误,返回原始错误
logger.debug('🔧 不是智谱特定错误,返回原始错误')
return error
}
@@ -8,7 +8,6 @@ import {
isGenerateImageModel,
isOpenRouterBuiltInWebSearchModel,
isReasoningModel,
isSupportedDisableGenerationModel,
isSupportedReasoningEffortModel,
isSupportedThinkingTokenModel,
isWebSearchModel
@@ -74,9 +73,7 @@ export async function buildStreamTextParams(
const enableUrlContext = assistant.enableUrlContext || false
const enableGenerateImage =
isGenerateImageModel(model) &&
(isSupportedDisableGenerationModel(model) ? assistant.enableGenerateImage || false : true)
const enableGenerateImage = !!(isGenerateImageModel(model) && assistant.enableGenerateImage)
const tools = setupToolsConfig(mcpTools)
-1
View File
@@ -102,7 +102,6 @@ export function buildProviderOptions(
break
case 'deepseek':
case 'openai-compatible':
case 'openai-responses':
// 对于其他 provider,使用通用的构建逻辑
providerSpecificOptions = {
...buildGenericProviderOptions(assistant, model, capabilities),
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="40" height="40" viewBox="0 0 40 40"><g><g><rect x="0" y="0" width="40" height="40" rx="10" fill="#FFFFFF" fill-opacity="1"/></g><g><g><path d="M3.7180590000000002,31.9163C3.24403,31.9163,2.9002264,31.4649,3.0262264,31.0079L9.07884,9.056280000000001C9.337579999999999,8.117891,10.43555,7.703758,11.24956,8.237532L19.2136,13.459869999999999C19.6909,13.77281,20.3081,13.77329,20.7859,13.46111L28.7837,8.234667C29.5984,7.702255,30.6956,8.11792,30.953,9.056519999999999L36.974,31.0089C37.0993,31.4656,36.7556,31.9163,36.2819,31.9163L28.5948,31.9163C29.9941,30.2961,30.7641,28.2266,30.7641,26.0857L30.7641,25.8358C30.7641,23.7417,30.0023,21.719,28.6209,20.1451L27.6344,15.19322C27.5764,14.90227,27.321,14.69275,27.0243,14.69275C26.8898,14.69275,26.7588,14.7364,26.6511,14.81715L22.9316,17.60681C22.6667,17.80548,22.3241,17.868740000000003,22.0057,17.77777C20.6944,17.403100000000002,19.3043,17.403100000000002,17.9929,17.77777C17.674599999999998,17.868740000000003,17.332,17.80548,17.0671,17.60681L13.3459,14.815909999999999C13.2393,14.73596,13.1096,14.69275,12.97639,14.69275C12.67948,14.69275,12.42483,14.90461,12.37083,15.196570000000001L11.41369,20.371000000000002C10.01719,21.7911,9.2346,23.703,9.2346,25.6947L9.2346,26.168C9.2346,28.2566,9.98171,30.2762,11.3409,31.8619L11.38755,31.9163L3.7180590000000002,31.9163Z" fill-rule="evenodd" fill="#29E154" fill-opacity="1"/></g><g><path d="M16.05224895477295,27.610614743041992L18.20519895477295,27.610614743041992L18.20519895477295,22.587064743041992L16.37845295477295,22.587064743041992L16.05224895477295,27.610614743041992ZM23.94638895477295,27.610614743041992L21.79344895477295,27.610614743041992L21.79344895477295,22.587064743041992L23.62018895477295,22.587064743041992L23.94638895477295,27.610614743041992Z" fill="#000000" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,59 @@
.command-list-popover {
// Base styles are handled inline for theme support
// Arrow styles based on placement
&[data-placement^='bottom'] {
transform-origin: top center;
animation: slideDownAndFadeIn 0.15s ease-out;
}
&[data-placement^='top'] {
transform-origin: bottom center;
animation: slideUpAndFadeIn 0.15s ease-out;
}
&[data-placement*='start'] {
transform-origin: left center;
}
&[data-placement*='end'] {
transform-origin: right center;
}
}
@keyframes slideDownAndFadeIn {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes slideUpAndFadeIn {
0% {
opacity: 0;
transform: translateY(8px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Ensure smooth scrolling in virtual list
.command-list-popover .dynamic-virtual-list {
scroll-behavior: smooth;
}
// Better focus indicators
.command-list-popover [data-index] {
position: relative;
&:focus-visible {
outline: 2px solid var(--color-primary, #1677ff);
outline-offset: -2px;
}
}
@@ -35,6 +35,8 @@
--color-error: #ff4d50;
--color-link: #338cff;
--color-code-background: #323232;
--color-inline-code-background: #323232;
--color-inline-code-text: rgb(218, 97, 92);
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333;
@@ -115,6 +117,8 @@
--color-error: #ff4d50;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-inline-code-background: rgba(0, 0, 0, 0.06);
--color-inline-code-text: rgba(235, 87, 87);
--color-hover: var(--color-white-mute);
--color-active: var(--color-white-soft);
--color-frame-border: #ddd;
@@ -5,6 +5,7 @@
@use './scrollbar.scss';
@use './container.scss';
@use './animation.scss';
@use './richtext.scss';
@import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css';
@import '../fonts/country-flag-fonts/flag.css';
+10 -1
View File
@@ -202,7 +202,7 @@
img {
max-width: 100%;
height: auto;
margin: 10px 0;
margin: 1em 0;
}
a,
@@ -321,6 +321,10 @@ emoji-picker {
--border-size: 0;
}
.block-wrapper + .block-wrapper {
margin-top: 1em;
}
.katex,
mjx-container {
display: inline-block;
@@ -367,4 +371,9 @@ mjx-container {
white-space: pre-wrap;
}
}
.cm-announced {
position: absolute;
display: none;
}
}
@@ -0,0 +1,508 @@
.tiptap {
// 预留5px给scrollbar
padding: 12px 55px 12px 60px;
outline: none;
min-height: 120px;
overflow-wrap: break-word;
word-break: break-word;
&:focus {
outline: none;
}
:first-child {
margin-top: 0;
}
h1:first-child,
h2:first-child,
h3:first-child,
h4:first-child,
h5:first-child,
h6:first-child {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1.5rem 0 1rem 0;
line-height: 1.1;
text-wrap: pretty;
font-weight: 600;
code {
font-size: inherit;
font-weight: inherit;
}
}
h1 {
margin-top: 0;
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
p {
margin: 1.1rem 0 0.5rem 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
width: 100%;
line-height: 1.6;
hyphens: auto;
&:has(+ ul) {
margin-bottom: 0;
}
}
a {
color: var(--color-link);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
blockquote {
border-left: 4px solid var(--color-primary);
margin: 1.5rem 0;
padding-left: 1rem;
}
code {
background-color: var(--color-inline-code-background);
border-radius: 0.4rem;
color: var(--color-inline-code-text);
font-size: 0.85rem;
padding: 0.25em 0.3em;
font-family: var(--code-font-family);
}
pre {
background: var(--color-code-background);
border-radius: 0.5rem;
color: var(--color-text);
font-family: var(--code-font-family);
margin: 1.5rem 0;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border-soft);
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
border: none;
}
}
hr {
border: none;
border-top: 1px solid var(--color-gray-2);
margin: 2rem 0;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
strong,
strong * {
font-weight: 600;
}
.placeholder {
position: relative;
&:before {
content: attr(data-placeholder);
position: absolute;
color: var(--color-text-secondary);
opacity: 0.6;
pointer-events: none;
font-style: italic;
left: 0;
right: 0;
}
/* Ensure drag handles and plus buttons remain interactive */
.drag-handle,
.plus-button {
pointer-events: auto;
}
}
/* Show placeholder only when focused or when it's the only empty node */
.placeholder.has-focus:before {
opacity: 0.8;
}
img {
max-width: 800px;
width: 100%;
height: auto;
}
table {
border-collapse: collapse;
margin: 0;
/* Allow action endpoints (rendered as decorations) to slightly overflow table edges */
overflow: visible;
table-layout: fixed;
width: 100%;
td,
th {
border: 1px solid var(--color-border-soft);
box-sizing: border-box;
display: table-cell;
min-width: 120px;
padding: 6px 8px;
position: relative;
vertical-align: top;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
> * {
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
th,
th * {
background-color: var(--color-gray-3);
font-weight: bold;
text-align: left;
}
.selectedCell {
position: relative; // 确保伪元素定位
}
.selectedCell::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border: 0 solid var(--color-primary);
border-radius: 0;
}
.selectedCell.selection-top::after {
border-top-width: 2px;
}
.selectedCell.selection-bottom::after {
border-bottom-width: 2px;
}
.selectedCell.selection-left::after {
border-left-width: 2px;
}
.selectedCell.selection-right::after {
border-right-width: 2px;
}
.column-resize-handle {
background-color: var(--color-primary);
bottom: -2px;
pointer-events: none;
position: absolute;
right: -2px;
top: 0;
width: 4px;
}
&:has(.selectedCell) {
caret-color: transparent !important;
user-select: none !important;
*::selection {
background: transparent !important;
}
.column-resize-handle {
display: none;
}
}
// Position row action buttons relative to first column cells
tbody tr td:first-child,
tbody tr th:first-child {
position: relative;
}
// Position column action buttons relative to first row cells
tbody tr:first-child td,
tbody tr:first-child th {
position: relative;
}
}
.tableWrapper {
position: relative;
margin: 1rem 0;
display: grid;
grid-template-columns: 1fr 25px;
grid-template-rows: 1fr 25px;
grid-template-areas:
'table column-btn'
'row-btn corner';
gap: 5px;
.table-container {
grid-area: table;
overflow-x: auto;
overflow-y: visible;
&::-webkit-scrollbar {
cursor: default;
}
&::-webkit-scrollbar:horizontal {
cursor: default;
}
&::-webkit-scrollbar-thumb {
cursor: default;
}
&::-webkit-scrollbar-track {
cursor: default;
}
table {
width: max-content;
min-width: 100%;
}
}
.add-row-button,
.add-column-button {
border: 1px solid var(--color-border);
background: var(--color-bg-base);
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
color: var(--color-text);
z-index: 20;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
pointer-events: auto;
&:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&:active {
transform: scale(0.98);
}
&::before {
content: '+';
font-weight: bold;
}
}
.add-row-button {
grid-area: row-btn;
}
.add-column-button {
grid-area: column-btn;
}
&:hover,
&:has(.add-row-button:hover),
&:has(.add-column-button:hover) {
.add-row-button,
.add-column-button {
display: flex;
}
}
/* Do not show in readonly even on hover */
&.is-readonly,
&.is-readonly:hover {
.add-row-button,
.add-column-button {
display: none !important;
}
}
.add-row-button:hover,
.add-column-button:hover {
display: flex !important;
}
/* Row/Column action triggers (visible on cell selection) */
.row-action-trigger,
.column-action-trigger {
position: absolute;
height: 20px;
border-radius: 8px;
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
display: none;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
z-index: 30;
pointer-events: auto;
}
.row-action-trigger::before,
.column-action-trigger::before {
content: '•••';
}
}
&.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
// Reduce spacing for nested lists
ul,
ol {
margin: 0.5rem 0.5rem 0.5rem 0.2rem;
}
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: center;
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
display: flex;
align-items: center;
}
> div {
flex: 1 1 auto;
p {
margin: 0;
}
}
}
/* Checked task item appearance */
li[data-checked='true'] {
> div {
color: var(--color-text-2);
text-decoration: line-through;
}
}
input[type='checkbox'] {
cursor: pointer;
}
/* Use primary color for checked checkbox */
input[type='checkbox']:checked {
accent-color: var(--color-primary);
background-color: var(--color-primary);
border-color: var(--color-primary);
}
ul[data-type='taskList'] {
margin: 0;
}
}
/* Math block */
.block-math-inner {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2rem;
}
/* Bottom spacer to create viewport padding */
&::after {
content: '';
display: block;
height: 50px;
pointer-events: none;
}
}
// Code block wrapper and header styles
.code-block-wrapper {
position: relative;
.code-block-header {
display: flex;
align-items: center;
gap: 6px;
position: absolute;
top: 4px;
right: 6px;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .code-block-header {
opacity: 1;
}
}
@@ -145,9 +145,10 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
language="html"
editable={true}
onSave={onSave}
style={{ height: '100%' }}
expanded
unwrapped={false}
height="100%"
expanded={false}
wrapped
style={{ minHeight: 0 }}
options={{
stream: true, // FIXME: 避免多余空行
lineNumbers: true,
@@ -388,12 +389,8 @@ const CodeSection = styled.div`
width: 100%;
overflow: hidden;
position: relative;
.monaco-editor,
.cm-editor,
.cm-scroller {
height: 100% !important;
}
display: grid;
grid-template-rows: 1fr auto;
`
const PreviewSection = styled.div`
@@ -100,7 +100,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
}, [hasSpecialView, viewMode])
const [expandOverride, setExpandOverride] = useState(!codeCollapsible)
const [unwrapOverride, setUnwrapOverride] = useState(!codeWrappable)
const [wrapOverride, setWrapOverride] = useState(codeWrappable)
// 重置用户操作
useEffect(() => {
@@ -109,11 +109,11 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 重置用户操作
useEffect(() => {
setUnwrapOverride(!codeWrappable)
setWrapOverride(codeWrappable)
}, [codeWrappable])
const shouldExpand = useMemo(() => !codeCollapsible || expandOverride, [codeCollapsible, expandOverride])
const shouldUnwrap = useMemo(() => !codeWrappable || unwrapOverride, [codeWrappable, unwrapOverride])
const shouldWrap = useMemo(() => codeWrappable && wrapOverride, [codeWrappable, wrapOverride])
const [sourceScrollHeight, setSourceScrollHeight] = useState(0)
const expandable = useMemo(() => {
@@ -225,9 +225,9 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
// 源代码视图的自动换行按钮
useWrapTool({
enabled: !isInSpecialView,
unwrapped: shouldUnwrap,
wrapped: shouldWrap,
wrappable: codeWrappable,
toggle: useCallback(() => setUnwrapOverride((prev) => !prev), []),
toggle: useCallback(() => setWrapOverride((prev) => !prev), []),
setTools
})
@@ -249,21 +249,22 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
language={language}
onSave={onSave}
onHeightChange={handleHeightChange}
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
options={{ stream: true }}
expanded={shouldExpand}
unwrapped={shouldUnwrap}
wrapped={shouldWrap}
/>
) : (
<CodeViewer
className="source-view"
language={language}
expanded={shouldExpand}
unwrapped={shouldUnwrap}
wrapped={shouldWrap}
onHeightChange={handleHeightChange}>
{children}
</CodeViewer>
),
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldUnwrap]
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
)
// 特殊视图组件映射
@@ -344,7 +345,7 @@ const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
}
`
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
const CodeHeader = styled.div<{ $isInSpecialView?: boolean }>`
display: flex;
align-items: center;
color: var(--color-text);
@@ -370,7 +371,11 @@ const SplitViewWrapper = styled.div<{ $isSpecialView: boolean; $isSplitView: boo
&:not(:has(+ [class*='Container'])) {
// 特殊视图的 header 会隐藏,所以全都使用圆角
border-radius: ${(props) => (props.$isSpecialView ? '8px' : '0 0 8px 8px')};
overflow: hidden;
// FIXME: 滚动条边缘会溢出,可以考虑增加 padding,但是要保证代码主题颜色铺满容器。
// overflow: hidden;
.code-viewer {
border-radius: inherit;
}
}
// 在 split 模式下添加中间分隔线
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getNormalizedExtension } from '../utils'
const mocks = vi.hoisted(() => ({
getExtensionByLanguage: vi.fn()
}))
vi.mock('@renderer/utils/code-language', () => ({
getExtensionByLanguage: mocks.getExtensionByLanguage
}))
describe('getNormalizedExtension', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return custom mapping for custom language', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
await expect(getNormalizedExtension('SVG')).resolves.toBe('xml')
})
it('should prefer custom mapping when both custom and linguist exist', async () => {
mocks.getExtensionByLanguage.mockReturnValue('.svg')
await expect(getNormalizedExtension('svg')).resolves.toBe('xml')
})
it('should return linguist mapping when available (strip leading dot)', async () => {
mocks.getExtensionByLanguage.mockReturnValue('.ts')
await expect(getNormalizedExtension('TypeScript')).resolves.toBe('ts')
})
it('should return extension when input already looks like extension (leading dot)', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('.json')).resolves.toBe('json')
})
it('should return language as-is when no rules matched', async () => {
mocks.getExtensionByLanguage.mockReturnValue(undefined)
await expect(getNormalizedExtension('unknownLanguage')).resolves.toBe('unknownLanguage')
})
})
@@ -8,7 +8,9 @@ import { getNormalizedExtension } from './utils'
const logger = loggerService.withContext('CodeEditorHooks')
// 语言对应的 linter 加载器
/** 语言对应的 linter 加载器
* key: 语言文件扩展名(不包含 `.`)
*/
const linterLoaders: Record<string, () => Promise<any>> = {
json: async () => {
const jsonParseLinter = await import('@codemirror/lang-json').then((mod) => mod.jsonParseLinter)
@@ -64,13 +66,15 @@ async function loadLanguageExtension(language: string): Promise<Extension | null
* 加载 linter 扩展
*/
async function loadLinterExtension(language: string): Promise<Extension | null> {
const loader = linterLoaders[language]
const fileExt = await getNormalizedExtension(language)
const loader = linterLoaders[fileExt]
if (!loader) return null
try {
return await loader()
} catch (error) {
logger.debug(`Failed to load linter for ${language}`, error as Error)
logger.debug(`Failed to load linter for ${language} (${fileExt})`, error as Error)
return null
}
}
@@ -1,4 +1,3 @@
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror'
@@ -15,39 +14,87 @@ export interface CodeEditorHandles {
save?: () => void
}
interface CodeEditorProps {
export interface CodeEditorProps {
ref?: React.RefObject<CodeEditorHandles | null>
/** Value used in controlled mode, e.g., code blocks. */
value: string
/** Placeholder when the editor content is empty. */
placeholder?: string | HTMLElement
/**
* Code language string.
* - Case-insensitive.
* - Supports common names: javascript, json, python, etc.
* - Supports aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
* - Supports file extensions: .cpp/cpp, .js/js, .py/py, etc.
*/
language: string
/** Fired when ref.save() is called or the save shortcut is triggered. */
onSave?: (newContent: string) => void
/** Fired when the editor content changes. */
onChange?: (newContent: string) => void
/** Fired when the editor loses focus. */
onBlur?: (newContent: string) => void
/** Fired when the editor height changes. */
onHeightChange?: (scrollHeight: number) => void
/**
* Fixed editor height, not exceeding maxHeight.
* Only works when expanded is false.
*/
height?: string
minHeight?: string
/**
* Maximum editor height.
* Only works when expanded is false.
*/
maxHeight?: string
/** Minimum editor height. */
minHeight?: string
/** Font size that overrides the app setting. */
fontSize?: string
/** 用于覆写编辑器的某些设置 */
/** Editor options that extend BasicSetupOptions. */
options?: {
stream?: boolean // 用于流式响应场景,默认 false
/**
* Whether to enable special treatment for stream response.
* @default false
*/
stream?: boolean
/**
* Whether to enable linting.
* @default false
*/
lint?: boolean
/**
* Whether to enable keymap.
* @default false
*/
keymap?: boolean
} & BasicSetupOptions
/** 用于追加 extensions */
/** Additional extensions for CodeMirror. */
extensions?: Extension[]
/** 用于覆写编辑器的样式,会直接传给 CodeMirror style 属性 */
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
style?: React.CSSProperties
/** CSS class name appended to the default `code-editor` class. */
className?: string
/**
* Whether the editor is editable.
* @default true
*/
editable?: boolean
/**
* Whether the editor is expanded.
* If true, the height and maxHeight props are ignored.
* @default true
*/
expanded?: boolean
unwrapped?: boolean
/**
* Whether the code lines are wrapped.
* @default true
*/
wrapped?: boolean
}
/**
* 源代码编辑器,基于 CodeMirror,封装了 ReactCodeMirror
*
* 目前必须和 CodeToolbar 配合使用。
* A code editor component based on CodeMirror.
* This is a wrapper of ReactCodeMirror.
*/
const CodeEditor = ({
ref,
@@ -59,8 +106,8 @@ const CodeEditor = ({
onBlur,
onHeightChange,
height,
minHeight,
maxHeight,
minHeight,
fontSize,
options,
extensions,
@@ -68,7 +115,7 @@ const CodeEditor = ({
className,
editable = true,
expanded = true,
unwrapped = false
wrapped = true
}: CodeEditorProps) => {
const { fontSize: _fontSize, codeShowLineNumbers: _lineNumbers, codeEditor } = useSettings()
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
@@ -121,12 +168,12 @@ const CodeEditor = ({
return [
...(extensions ?? []),
...langExtensions,
...(unwrapped ? [] : [EditorView.lineWrapping]),
...(wrapped ? [EditorView.lineWrapping] : []),
saveKeymapExtension,
blurExtension,
heightListenerExtension
].flat()
}, [extensions, langExtensions, unwrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
}, [extensions, langExtensions, wrapped, saveKeymapExtension, blurExtension, heightListenerExtension])
useImperativeHandle(ref, () => ({
save: handleSave
@@ -138,9 +185,9 @@ const CodeEditor = ({
value={initialContent.current}
placeholder={placeholder}
width="100%"
height={height}
height={expanded ? undefined : height}
maxHeight={expanded ? undefined : maxHeight}
minHeight={minHeight}
maxHeight={expanded ? 'none' : (maxHeight ?? `${MAX_COLLAPSED_CODE_HEIGHT}px`)}
editable={editable}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
@@ -13,6 +13,7 @@ const _customLanguageExtensions: Record<string, string> = {
* 获取语言的扩展名,用于 @uiw/codemirror-extensions-langs
* - 先搜索自定义扩展名
* - 再搜索 github linguist 扩展名
* - 最后假定名称已经是扩展名
* @param language 语言名称
* @returns 扩展名(不包含 `.`
*/
@@ -29,6 +30,11 @@ export async function getNormalizedExtension(language: string) {
return linguistExt.slice(1)
}
// 如果语言名称像扩展名
if (language.startsWith('.') && language.length > 1) {
return language.slice(1)
}
// 回退到语言名称
return language
}
@@ -50,7 +50,7 @@ describe('useWrapTool', () => {
const createMockProps = (overrides: Partial<Parameters<typeof useWrapTool>[0]> = {}) => {
const defaultProps = {
enabled: true,
unwrapped: false,
wrapped: true,
wrappable: true,
toggle: vi.fn(),
setTools: vi.fn()
@@ -90,8 +90,8 @@ describe('useWrapTool', () => {
expect(mockRegisterTool).not.toHaveBeenCalled()
})
it('should re-register tool when unwrapped changes', () => {
const props = createMockProps({ unwrapped: false })
it('should re-register tool when wrapped changes', () => {
const props = createMockProps({ wrapped: true })
const { rerender } = renderHook((hookProps) => useWrapTool(hookProps), {
initialProps: props
})
@@ -100,8 +100,8 @@ describe('useWrapTool', () => {
const firstCall = mockRegisterTool.mock.calls[0][0]
expect(firstCall.tooltip).toBe('code_block.wrap.off')
// Change unwrapped to true and rerender
const newProps = { ...props, unwrapped: true }
// Change wrapped to false and rerender
const newProps = { ...props, wrapped: false }
rerender(newProps)
expect(mockRegisterTool).toHaveBeenCalledTimes(2)
@@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'
interface UseWrapToolProps {
enabled?: boolean
unwrapped?: boolean
wrapped?: boolean
wrappable?: boolean
toggle: () => void
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
}
export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
export const useWrapTool = ({ enabled, wrapped, wrappable, toggle, setTools }: UseWrapToolProps) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useToolManager(setTools)
@@ -23,13 +23,13 @@ export const useWrapTool = ({ enabled, unwrapped, wrappable, toggle, setTools }:
if (enabled) {
registerTool({
...TOOL_SPECS.wrap,
icon: unwrapped ? <WrapIcon className="tool-icon" /> : <UnWrapIcon className="tool-icon" />,
tooltip: unwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
icon: wrapped ? <UnWrapIcon className="tool-icon" /> : <WrapIcon className="tool-icon" />,
tooltip: wrapped ? t('code_block.wrap.off') : t('code_block.wrap.on'),
visible: () => wrappable ?? false,
onClick: handleToggle
})
}
return () => removeTool(TOOL_SPECS.wrap.id)
}, [enabled, handleToggle, registerTool, removeTool, t, unwrapped, wrappable])
}, [enabled, handleToggle, registerTool, removeTool, t, wrapped, wrappable])
}
+10 -5
View File
@@ -14,9 +14,10 @@ interface CodeViewerProps {
language: string
children: string
expanded?: boolean
unwrapped?: boolean
wrapped?: boolean
onHeightChange?: (scrollHeight: number) => void
className?: string
height?: string | number
}
/**
@@ -25,7 +26,7 @@ interface CodeViewerProps {
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
* - 并发安全
*/
const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, className }: CodeViewerProps) => {
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
const { codeShowLineNumbers, fontSize } = useSettings()
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
const shikiThemeRef = useRef<HTMLDivElement>(null)
@@ -104,18 +105,20 @@ const CodeViewer = ({ children, language, expanded, unwrapped, onHeightChange, c
}, [rawLines.length, onHeightChange])
return (
<div ref={shikiThemeRef}>
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
<ScrollContainer
ref={scrollerRef}
className="shiki-scroller"
$wrap={!unwrapped}
$wrap={wrapped}
$expanded={expanded}
$lineHeight={estimateSize()}
$height={height}
style={
{
'--gutter-width': `${gutterDigits}ch`,
fontSize: `${fontSize - 1}px`,
maxHeight: expanded ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
height: height,
overflowY: expanded ? 'hidden' : 'auto'
} as React.CSSProperties
}>
@@ -225,6 +228,7 @@ const ScrollContainer = styled.div<{
$wrap?: boolean
$expanded?: boolean
$lineHeight?: number
$height?: string | number
}>`
display: block;
overflow-x: auto;
@@ -257,6 +261,7 @@ const ScrollContainer = styled.div<{
.line-content {
flex: 1;
padding-right: 1em;
white-space: pre;
* {
white-space: ${(props) => (props.$wrap ? 'pre-wrap' : 'pre')};
overflow-wrap: ${(props) => (props.$wrap ? 'break-word' : 'normal')};
+33 -12
View File
@@ -18,6 +18,15 @@ interface Props {
filter: NodeFilter
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
/**
* 是否显示“包含用户问题”切换按钮(默认为 true)。
* 在富文本编辑器场景通常不需要该按钮。
*/
showUserToggle?: boolean
/**
* 搜索条定位方式
*/
positionMode?: 'fixed' | 'absolute' | 'sticky'
}
enum SearchCompletedState {
@@ -125,7 +134,10 @@ const findRangesInTarget = (
// eslint-disable-next-line @eslint-react/no-forward-ref
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
(
{ searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' },
ref
) => {
const target: HTMLElement | null = (() => {
if (searchTarget instanceof HTMLElement) {
return searchTarget
@@ -335,9 +347,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
}
return (
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
<Container
ref={containerRef}
style={enableContentSearch ? {} : { display: 'none' }}
$overlayPosition={positionMode === 'absolute' ? 'absolute' : 'static'}>
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer>
<SearchBarContainer $position={positionMode}>
<InputWrapper>
<Input
ref={searchInputRef}
@@ -347,11 +362,13 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
style={{ lineHeight: '20px' }}
/>
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
{showUserToggle && (
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
@@ -400,17 +417,21 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
ContentSearch.displayName = 'ContentSearch'
const Container = styled.div`
const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>`
display: flex;
flex-direction: row;
z-index: 2;
position: ${({ $overlayPosition }) => $overlayPosition};
top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
z-index: 999;
`
const SearchBarContainer = styled.div`
const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>`
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.2s ease;
position: fixed;
position: ${({ $position }) => $position};
top: 15px;
left: 20px;
right: 20px;
@@ -0,0 +1,81 @@
import { getProviderLabel } from '@renderer/i18n/label'
import NavigationService from '@renderer/services/NavigationService'
import { Model } from '@renderer/types'
import { ArrowUpRight } from 'lucide-react'
import { FC, MouseEvent } from 'react'
import styled from 'styled-components'
import IndicatorLight from './IndicatorLight'
import SelectModelPopup from './Popups/SelectModelPopup'
import CustomTag from './Tags/CustomTag'
interface Props {
model: Model
showLabel?: boolean
}
export const FreeTrialModelTag: FC<Props> = ({ model, showLabel = true }) => {
if (model.provider !== 'cherryin') {
return null
}
let providerId
if (model.id === 'glm-4.5-flash') {
providerId = 'zhipu'
}
if (model.id === 'Qwen/Qwen3-8B') {
providerId = 'silicon'
}
const onSelectProvider = () => {
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
}
const onNavigateProvider = (e: MouseEvent) => {
e.stopPropagation()
SelectModelPopup.hide()
NavigationService.navigate!(`/settings/provider?id=${providerId}`)
}
if (!showLabel) {
return (
<Container>
<CustomTag
color="var(--color-link)"
size={11}
onClick={onNavigateProvider}
style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{getProviderLabel(providerId)}
<ArrowUpRight size={12} />
</CustomTag>
</Container>
)
}
return (
<Container>
<IndicatorLight size={6} color="var(--color-primary)" animation={false} shadow={false} />
<PoweredBy>Powered by </PoweredBy>
<LinkText onClick={onSelectProvider}>{getProviderLabel(providerId)}</LinkText>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
`
const PoweredBy = styled.span`
font-size: 12px;
color: var(--color-text-2);
`
const LinkText = styled.a`
font-size: 12px;
color: var(--color-link);
`
@@ -239,6 +239,18 @@ export function BochaLogo(props: SVGProps<SVGSVGElement>) {
)
}
export function ZhipuLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M 11.699219 1.800781 C 12.300781 1.984375 12.832031 2.203125 13.375 2.515625 C 13.523438 2.597656 13.671875 2.679688 13.820312 2.765625 C 14.058594 2.902344 14.058594 2.902344 14.296875 3.039062 C 14.632812 3.226562 14.96875 3.417969 15.304688 3.605469 C 15.476562 3.707031 15.652344 3.804688 15.824219 3.902344 C 16.671875 4.382812 17.523438 4.859375 18.375 5.335938 C 18.804688 5.574219 19.234375 5.816406 19.660156 6.054688 C 20.257812 6.386719 20.855469 6.71875 21.449219 7.050781 C 21.460938 8.449219 21.46875 9.851562 21.472656 11.25 C 21.476562 11.902344 21.480469 12.550781 21.484375 13.203125 C 21.488281 13.828125 21.492188 14.457031 21.492188 15.082031 C 21.492188 15.324219 21.496094 15.5625 21.496094 15.800781 C 21.5 16.136719 21.5 16.472656 21.5 16.808594 C 21.503906 17.09375 21.503906 17.09375 21.503906 17.386719 C 21.449219 17.851562 21.449219 17.851562 21.242188 18.125 C 20.917969 18.359375 20.585938 18.558594 20.238281 18.757812 C 20.085938 18.84375 19.929688 18.933594 19.769531 19.027344 C 19.605469 19.121094 19.4375 19.214844 19.265625 19.3125 C 19.003906 19.460938 18.742188 19.613281 18.480469 19.761719 C 18.203125 19.917969 17.929688 20.074219 17.65625 20.234375 C 16.929688 20.648438 16.203125 21.066406 15.476562 21.484375 C 15.140625 21.675781 14.804688 21.867188 14.46875 22.0625 C 14.238281 22.195312 14.238281 22.195312 14.003906 22.328125 C 13.863281 22.410156 13.71875 22.492188 13.574219 22.574219 C 13.265625 22.761719 12.984375 22.957031 12.695312 23.171875 C 12.179688 23.46875 11.972656 23.390625 11.398438 23.25 C 10.996094 23.058594 10.996094 23.058594 10.585938 22.816406 C 10.429688 22.726562 10.277344 22.636719 10.117188 22.542969 C 9.871094 22.398438 9.871094 22.398438 9.617188 22.246094 C 9.445312 22.144531 9.273438 22.042969 9.101562 21.945312 C 8.746094 21.734375 8.386719 21.523438 8.03125 21.316406 C 7.359375 20.917969 6.683594 20.523438 6.007812 20.128906 C 5.703125 19.953125 5.402344 19.773438 5.101562 19.597656 C 4.34375 19.15625 3.578125 18.722656 2.800781 18.308594 C 2.550781 18.148438 2.550781 18.148438 2.398438 17.851562 C 2.378906 17.519531 2.367188 17.183594 2.363281 16.851562 C 2.359375 16.75 2.355469 16.648438 2.355469 16.542969 C 2.335938 15.597656 2.324219 14.652344 2.316406 13.707031 C 2.3125 13.070312 2.304688 12.4375 2.289062 11.800781 C 2.277344 11.1875 2.269531 10.574219 2.265625 9.960938 C 2.265625 9.726562 2.257812 9.492188 2.253906 9.257812 C 2.203125 7.4375 2.203125 7.4375 2.675781 6.871094 C 3.023438 6.632812 3.363281 6.464844 3.75 6.300781 C 3.914062 6.203125 4.078125 6.109375 4.246094 6.007812 C 4.402344 5.925781 4.554688 5.839844 4.710938 5.753906 C 4.839844 5.683594 4.839844 5.683594 4.972656 5.613281 C 5.152344 5.515625 5.332031 5.417969 5.515625 5.316406 C 5.992188 5.058594 6.472656 4.792969 6.949219 4.53125 C 7.046875 4.480469 7.144531 4.425781 7.242188 4.371094 C 8.195312 3.847656 9.140625 3.320312 10.085938 2.785156 C 10.234375 2.703125 10.378906 2.621094 10.53125 2.535156 C 10.664062 2.460938 10.796875 2.382812 10.933594 2.308594 C 11.109375 2.207031 11.109375 2.207031 11.285156 2.109375 C 11.542969 1.96875 11.542969 1.96875 11.699219 1.800781 Z M 11.851562 4.5 C 11.820312 4.652344 11.789062 4.800781 11.753906 4.957031 C 11.207031 7.453125 10.085938 9.570312 7.941406 11.066406 C 6.816406 11.78125 5.628906 12.113281 4.351562 12.449219 C 4.351562 12.5 4.351562 12.550781 4.351562 12.601562 C 4.582031 12.648438 4.582031 12.648438 4.824219 12.699219 C 6.101562 12.984375 7.183594 13.300781 8.25 14.101562 C 8.363281 14.183594 8.476562 14.265625 8.59375 14.351562 C 10.558594 15.933594 11.488281 18.261719 11.851562 20.699219 C 11.898438 20.699219 11.949219 20.699219 12 20.699219 C 12.03125 20.554688 12.058594 20.410156 12.089844 20.257812 C 12.6875 17.503906 13.816406 15.222656 16.242188 13.65625 C 17.253906 13.054688 18.351562 12.8125 19.5 12.601562 C 19.035156 12.289062 18.695312 12.191406 18.160156 12.046875 C 15.792969 11.332031 14.222656 9.945312 13.050781 7.800781 C 12.527344 6.726562 12.175781 5.679688 12 4.5 C 11.949219 4.5 11.902344 4.5 11.851562 4.5 Z M 11.851562 4.5 "
fill="currentColor"
/>
</svg>
)
}
export function PoeLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
@@ -15,7 +15,7 @@ type Props = {
const ModelSelectButton = ({ model, onSelectModel, modelFilter, noTooltip, tooltipProps }: Props) => {
const onClick = useCallback(async () => {
const selectedModel = await SelectModelPopup.show({ model, modelFilter })
const selectedModel = await SelectModelPopup.show({ model, filter: modelFilter })
if (selectedModel) {
onSelectModel?.(selectedModel)
}
+145
View File
@@ -0,0 +1,145 @@
import CherryLogo from '@renderer/assets/images/banner.png'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
import { Skeleton, Typography } from 'antd'
import { useEffect, useMemo } from 'react'
import styled from 'styled-components'
const { Title, Paragraph } = Typography
type Props = {
link: string
show: boolean
}
export const OGCard = ({ link, show }: Props) => {
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
const hasImage = !!metadata['og:image']
const hostname = useMemo(() => {
try {
return new URL(link).hostname
} catch {
return null
}
}, [link])
useEffect(() => {
// use show to lazy loading
if (show && isLoading) {
parseMetadata()
}
}, [parseMetadata, isLoading, show])
if (isLoading) {
return <CardSkeleton />
}
return (
<PreviewContainer hasImage={hasImage}>
{hasImage && (
<PreviewImageContainer>
<PreviewImage src={metadata['og:image']} alt={metadata['og:imageAlt'] || link} />
</PreviewImageContainer>
)}
{!hasImage && (
<PreviewImageContainer>
<PreviewImage src={CherryLogo} alt={'no image'} />
</PreviewImageContainer>
)}
<PreviewContent>
<StyledHyperLink>
{hostname && <Favicon hostname={hostname} alt={link} />}
<Title
style={{
margin: 0,
fontSize: '14px',
lineHeight: '1.2',
color: 'var(--color-text)'
}}>
{metadata['og:title'] || hostname}
</Title>
</StyledHyperLink>
<Paragraph
title={metadata['og:description'] || link}
ellipsis={{ rows: 2 }}
style={{
fontSize: '12px',
lineHeight: '1.2',
color: 'var(--color-text-secondary)'
}}>
{metadata['og:description'] || link}
</Paragraph>
</PreviewContent>
</PreviewContainer>
)
}
const CardSkeleton = () => {
return (
<SkeletonContainer>
<Skeleton.Image style={{ width: '100%', height: 140 }} active />
<Skeleton
paragraph={{
rows: 1,
style: {
margin: '8px 0'
}
}}
active
/>
</SkeletonContainer>
)
}
const StyledHyperLink = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const PreviewContainer = styled.div<{ hasImage?: boolean }>`
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
width: 380px;
height: 220px;
overflow: hidden;
`
const PreviewImageContainer = styled.div`
width: 100%;
height: 140px;
min-height: 140px;
overflow: hidden;
`
const PreviewContent = styled.div`
padding: 12px 16px;
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
`
const PreviewImage = styled.img`
width: 100%;
height: 140px;
object-fit: cover;
`
const SkeletonContainer = styled.div`
width: 380px;
height: 220px;
padding: 12px 16px;
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
gap: 16px;
`
@@ -0,0 +1,66 @@
import { TopView } from '@renderer/components/TopView'
import { Modal, ModalProps } from 'antd'
import { ReactNode, useState } from 'react'
interface ShowParams extends ModalProps {
content: ReactNode
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ content, resolve, ...rest }) => {
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
GeneralPopup.hide = onCancel
return (
<Modal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
{...rest}>
{content}
</Modal>
)
}
const TopViewKey = 'GeneralPopup'
/** 在这个 Popup 中展示任意内容 */
export default class GeneralPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -0,0 +1,160 @@
import RichEditor from '@renderer/components/RichEditor'
import { RichEditorRef } from '@renderer/components/RichEditor/types'
import { Modal, ModalProps } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { TopView } from '../TopView'
interface ShowParams {
content: string
modalProps?: ModalProps
showTranslate?: boolean
disableCommands?: string[] // 要禁用的命令列表
children?: (props: { onOk?: () => void; onCancel?: () => void }) => React.ReactNode
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({
content,
modalProps,
resolve,
children,
disableCommands = ['image', 'inlineMath'] // 默认禁用 image 命令
}) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const [richContent, setRichContent] = useState(content)
const editorRef = useRef<RichEditorRef>(null)
const isMounted = useRef(true)
useEffect(() => {
return () => {
isMounted.current = false
}
}, [])
const onOk = () => {
const finalContent = editorRef.current?.getMarkdown() || richContent
resolve(finalContent)
setOpen(false)
}
const onCancel = () => {
resolve(null)
setOpen(false)
}
const onClose = () => {
resolve(null)
}
const handleAfterOpenChange = (visible: boolean) => {
if (visible && editorRef.current) {
// Focus the editor after modal opens
setTimeout(() => {
editorRef.current?.focus()
}, 100)
}
}
const handleContentChange = (newContent: string) => {
setRichContent(newContent)
}
const handleMarkdownChange = (newMarkdown: string) => {
// 更新Markdown内容状态
setRichContent(newMarkdown)
}
// 处理命令配置
const handleCommandsReady = (commandAPI: Pick<RichEditorRef, 'unregisterToolbarCommand' | 'unregisterCommand'>) => {
// 禁用指定的命令
if (disableCommands?.length) {
disableCommands.forEach((commandId) => {
commandAPI.unregisterCommand(commandId)
})
}
}
RichEditPopup.hide = onCancel
return (
<Modal
title={t('common.edit')}
width="70vw"
style={{ maxHeight: '80vh' }}
transitionName="animation-move-down"
okText={t('common.save')}
{...modalProps}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
afterOpenChange={handleAfterOpenChange}
maskClosable={false}
keyboard={false}
centered>
<EditorContainer>
<RichEditor
ref={editorRef}
initialContent={content}
placeholder={t('richEditor.placeholder')}
onContentChange={handleContentChange}
onMarkdownChange={handleMarkdownChange}
onCommandsReady={handleCommandsReady}
minHeight={window.innerHeight * 0.7}
isFullWidth={true}
className="rich-edit-popup-editor"
/>
</EditorContainer>
<ChildrenContainer>{children && children({ onOk, onCancel })}</ChildrenContainer>
</Modal>
)
}
const TopViewKey = 'RichEditPopup'
const ChildrenContainer = styled.div`
position: relative;
`
const EditorContainer = styled.div`
position: relative;
.rich-edit-popup-editor {
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
&:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
}
`
export default class RichEditPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
@@ -4,6 +4,7 @@ import { TopView } from '@renderer/components/TopView'
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { Topic } from '@renderer/types'
import { Message } from '@renderer/types/newMessage'
import { NotesTreeNode } from '@renderer/types/note'
import {
analyzeMessageContent,
analyzeTopicContent,
@@ -77,7 +78,10 @@ interface ContentTypeOption {
description: string
}
type ContentSource = { type: 'message'; data: Message } | { type: 'topic'; data: Topic }
type ContentSource =
| { type: 'message'; data: Message }
| { type: 'topic'; data: Topic }
| { type: 'note'; data: NotesTreeNode }
interface ShowParams {
source: ContentSource
@@ -106,10 +110,16 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
const { t } = useTranslation()
const isTopicMode = source?.type === 'topic'
const isNoteMode = source?.type === 'note'
// 异步分析内容统计
useEffect(() => {
const analyze = async () => {
if (isNoteMode) {
setAnalysisLoading(false)
return
}
setAnalysisLoading(true)
setContentStats(null)
try {
@@ -136,11 +146,11 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
}
}
analyze()
}, [source, isTopicMode])
}, [source, isTopicMode, isNoteMode])
// 生成内容类型选项
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
if (!contentStats) return []
if (!contentStats || isNoteMode) return []
return Object.entries(CONTENT_TYPE_CONFIG)
.map(([type, config]) => {
@@ -159,7 +169,7 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
}
})
.filter((option) => option.enabled)
}, [contentStats, t, isTopicMode])
}, [contentStats, t, isTopicMode, isNoteMode])
// 知识库选项
const knowledgeBaseOptions = useMemo(
@@ -175,19 +185,24 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
// 表单状态
const formState = useMemo(() => {
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
const hasContent = contentTypeOptions.length > 0
const selectedCount = contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
const hasContent = isNoteMode || contentTypeOptions.length > 0
const canSubmit = hasValidBase && (isNoteMode || (selectedTypes.length > 0 && hasContent))
const selectedCount = isNoteMode
? 1
: contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
return {
hasValidBase,
hasContent,
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
canSubmit,
selectedCount,
hasNoSelection: selectedTypes.length === 0 && hasContent
hasNoSelection: !isNoteMode && selectedTypes.length === 0 && hasContent
}
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes, isNoteMode])
// 默认选择第一个可用知识库
useEffect(() => {
@@ -201,28 +216,31 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
// 默认选择所有可用内容类型
useEffect(() => {
if (!hasInitialized && contentTypeOptions.length > 0) {
if (!hasInitialized && contentTypeOptions.length > 0 && !isNoteMode) {
setSelectedTypes(contentTypeOptions.map((option) => option.type))
setHasInitialized(true)
}
}, [contentTypeOptions, hasInitialized])
}, [contentTypeOptions, hasInitialized, isNoteMode])
// UI状态
const uiState = useMemo(() => {
if (analysisLoading) {
return { type: 'loading', message: t('chat.save.topic.knowledge.loading') }
}
if (!formState.hasContent) {
if (!formState.hasContent && !isNoteMode) {
return {
type: 'empty',
message: t(isTopicMode ? 'chat.save.topic.knowledge.empty.no_content' : 'chat.save.knowledge.empty.no_content')
}
}
if (bases.length === 0) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
}
return { type: 'form' }
}, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode])
}, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode, isNoteMode])
const handleContentTypeToggle = (type: ContentType) => {
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
@@ -235,18 +253,28 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
let savedCount = 0
try {
const result = isTopicMode
? await processTopicContent(source?.data as Topic, selectedTypes)
: processMessageContent(source?.data as Message, selectedTypes)
if (isNoteMode) {
const note = source.data as NotesTreeNode
const content = await window.api.file.read(note.id + '.md')
logger.debug('Note content:', content)
await addNote(content)
savedCount = 1
} else {
// 原有的消息或主题处理逻辑
const result = isTopicMode
? await processTopicContent(source?.data as Topic, selectedTypes)
: processMessageContent(source?.data as Message, selectedTypes)
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
logger.debug('Processed content:', result)
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
}
}
setOpen(false)
@@ -285,66 +313,81 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
/>
</Form.Item>
<Form.Item
label={t(
isTopicMode ? 'chat.save.topic.knowledge.select.content.label' : 'chat.save.knowledge.select.content.title'
)}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
{!isNoteMode && (
<Form.Item
label={t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.label'
: 'chat.save.knowledge.select.content.title'
)}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
)}
</Form>
<InfoContainer>
{formState.selectedCount > 0 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.selected_tip'
: 'chat.save.knowledge.select.content.tip',
{
count: formState.selectedCount,
...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 })
}
)}
</Text>
)}
{formState.hasNoSelection && (
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
)}
{!formState.hasNoSelection && formState.selectedCount === 0 && (
<Text type="secondary" style={{ fontSize: '12px', opacity: 0 }}>
&nbsp;
</Text>
)}
</InfoContainer>
{!isNoteMode && (
<InfoContainer>
{formState.selectedCount > 0 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.selected_tip'
: 'chat.save.knowledge.select.content.tip',
{
count: formState.selectedCount,
...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 })
}
)}
</Text>
)}
{formState.hasNoSelection && (
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
)}
{!formState.hasNoSelection && formState.selectedCount === 0 && (
<Text type="secondary" style={{ fontSize: '12px', opacity: 0 }}>
&nbsp;
</Text>
)}
</InfoContainer>
)}
</>
)
return (
<Modal
title={title || t(isTopicMode ? 'chat.save.topic.knowledge.title' : 'chat.save.knowledge.title')}
title={
title ||
t(
isNoteMode
? 'notes.export_knowledge'
: isTopicMode
? 'chat.save.topic.knowledge.title'
: 'chat.save.knowledge.title'
)
}
open={open}
onOk={onOk}
onCancel={onCancel}
@@ -389,6 +432,10 @@ export default class SaveToKnowledgePopup {
static showForTopic(topic: Topic, title?: string): Promise<SaveResult | null> {
return this.show({ source: { type: 'topic', data: topic }, title })
}
static showForNote(note: NotesTreeNode, title?: string): Promise<SaveResult | null> {
return this.show({ source: { type: 'note', data: note }, title })
}
}
const EmptyContainer = styled.div`
@@ -0,0 +1,83 @@
import { loggerService } from '@logger'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from '@renderer/components/Tags/Model'
import { ModelTag } from '@renderer/types'
import { Flex } from 'antd'
import React, { startTransition, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('TagFilterSection')
interface TagFilterSectionProps {
availableTags: ModelTag[]
tagSelection: Record<ModelTag, boolean>
onToggleTag: (tag: ModelTag) => void
}
const TagFilterSection: React.FC<TagFilterSectionProps> = ({ availableTags, tagSelection, onToggleTag }) => {
const { t } = useTranslation()
const handleTagClick = useCallback(
(tag: ModelTag) => {
startTransition(() => onToggleTag(tag))
},
[onToggleTag]
)
// 标签组件
const tagComponents = useMemo(
() => ({
vision: VisionTag,
embedding: EmbeddingTag,
reasoning: ReasoningTag,
function_calling: ToolsCallingTag,
web_search: WebSearchTag,
rerank: RerankerTag,
free: FreeTag
}),
[]
)
return (
<FilterContainer>
<Flex wrap="wrap" gap={4}>
<FilterText>{t('models.filter.by_tag')}</FilterText>
{availableTags.map((tag) => {
const TagElement = tagComponents[tag]
if (!TagElement) {
logger.error(`Tag element not found for tag: ${tag}`)
return null
}
return (
<TagElement
key={`tag-${tag}`}
onClick={() => handleTagClick(tag)}
inactive={!tagSelection[tag]}
showLabel
/>
)
})}
</Flex>
</FilterContainer>
)
}
const FilterContainer = styled.div`
padding: 8px;
padding-left: 18px;
`
const FilterText = styled.span`
color: var(--color-text-3);
font-size: 12px;
`
export default TagFilterSection
@@ -0,0 +1,110 @@
import type { ModelTag } from '@renderer/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagFilterSection from '../TagFilterSection'
const mocks = vi.hoisted(() => ({
t: vi.fn((key: string) => key),
createTagComponent: (name: string) => {
// Create a simple button component exposing props for assertions
return ({ onClick, inactive, showLabel }: { onClick?: () => void; inactive?: boolean; showLabel?: boolean }) => {
const React = require('react')
return React.createElement(
'button',
{
type: 'button',
'aria-label': `tag-${name}`,
'data-inactive': String(Boolean(inactive)),
onClick
},
showLabel ? name : ''
)
}
}
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: mocks.t })
}))
vi.mock('@renderer/components/Tags/Model', () => ({
VisionTag: mocks.createTagComponent('vision'),
EmbeddingTag: mocks.createTagComponent('embedding'),
ReasoningTag: mocks.createTagComponent('reasoning'),
ToolsCallingTag: mocks.createTagComponent('function_calling'),
WebSearchTag: mocks.createTagComponent('web_search'),
RerankerTag: mocks.createTagComponent('rerank'),
FreeTag: mocks.createTagComponent('free')
}))
vi.mock('antd', () => ({
Flex: ({ children }: { children: React.ReactNode }) => children
}))
function createSelection(overrides: Partial<Record<ModelTag, boolean>> = {}): Record<ModelTag, boolean> {
const base: Record<ModelTag, boolean> = {
vision: true,
embedding: true,
reasoning: true,
function_calling: true,
web_search: true,
rerank: true,
free: true
}
return { ...base, ...overrides }
}
const allTags: ModelTag[] = ['vision', 'embedding', 'reasoning', 'function_calling', 'web_search', 'rerank', 'free']
describe('TagFilterSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should match snapshot', () => {
const { container } = render(
<TagFilterSection availableTags={allTags} tagSelection={createSelection()} onToggleTag={vi.fn()} />
)
expect(container).toMatchSnapshot()
})
it('should reflect inactive state based on tagSelection', () => {
render(
<TagFilterSection
availableTags={['vision']}
tagSelection={createSelection({ vision: false })}
onToggleTag={vi.fn()}
/>
)
const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
expect(visionBtn).toHaveAttribute('data-inactive', 'true')
})
it('should skip unknown tags', () => {
render(
<TagFilterSection
availableTags={['unknown' as unknown as ModelTag, 'vision']}
tagSelection={createSelection()}
onToggleTag={vi.fn()}
/>
)
expect(screen.queryByRole('button', { name: 'tag-unknown' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'tag-vision' })).toBeInTheDocument()
})
})
describe('functionality', () => {
it('should call onToggleTag when a tag is clicked', () => {
const handleToggle = vi.fn()
render(<TagFilterSection availableTags={allTags} tagSelection={createSelection()} onToggleTag={handleToggle} />)
const visionBtn = screen.getByRole('button', { name: 'tag-vision' })
fireEvent.click(visionBtn)
expect(handleToggle).toHaveBeenCalledTimes(1)
expect(handleToggle).toHaveBeenCalledWith('vision')
})
})
})
@@ -0,0 +1,74 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TagFilterSection > rendering > should match snapshot 1`] = `
.c0 {
padding: 8px;
padding-left: 18px;
}
.c1 {
color: var(--color-text-3);
font-size: 12px;
}
<div>
<div
class="c0"
>
<span
class="c1"
>
models.filter.by_tag
</span>
<button
aria-label="tag-vision"
data-inactive="false"
type="button"
>
vision
</button>
<button
aria-label="tag-embedding"
data-inactive="false"
type="button"
>
embedding
</button>
<button
aria-label="tag-reasoning"
data-inactive="false"
type="button"
>
reasoning
</button>
<button
aria-label="tag-function_calling"
data-inactive="false"
type="button"
>
function_calling
</button>
<button
aria-label="tag-web_search"
data-inactive="false"
type="button"
>
web_search
</button>
<button
aria-label="tag-rerank"
data-inactive="false"
type="button"
>
rerank
</button>
<button
aria-label="tag-free"
data-inactive="false"
type="button"
>
free
</button>
</div>
</div>
`;
@@ -0,0 +1,122 @@
import type { Model } from '@renderer/types'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useModelTagFilter } from '../filters'
const mocks = vi.hoisted(() => ({
isVisionModel: vi.fn(),
isEmbeddingModel: vi.fn(),
isReasoningModel: vi.fn(),
isFunctionCallingModel: vi.fn(),
isWebSearchModel: vi.fn(),
isRerankModel: vi.fn(),
isFreeModel: vi.fn()
}))
vi.mock('@renderer/config/models', () => ({
isEmbeddingModel: mocks.isEmbeddingModel,
isFunctionCallingModel: mocks.isFunctionCallingModel,
isReasoningModel: mocks.isReasoningModel,
isRerankModel: mocks.isRerankModel,
isVisionModel: mocks.isVisionModel,
isWebSearchModel: mocks.isWebSearchModel
}))
vi.mock('@renderer/utils/model', () => ({
isFreeModel: mocks.isFreeModel
}))
function createModel(overrides: Partial<Model> = {}): Model {
return {
id: 'm1',
provider: 'openai',
name: 'Model-1',
group: 'default',
...overrides
}
}
describe('useModelTagFilter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should have all tags unselected initially', () => {
const { result } = renderHook(() => useModelTagFilter())
expect(result.current.tagSelection).toEqual({
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
})
expect(result.current.selectedTags).toEqual([])
})
it('should toggle a tag state', () => {
const { result } = renderHook(() => useModelTagFilter())
act(() => result.current.toggleTag('vision'))
expect(result.current.tagSelection.vision).toBe(true)
expect(result.current.selectedTags).toEqual(['vision'])
act(() => result.current.toggleTag('vision'))
expect(result.current.tagSelection.vision).toBe(false)
expect(result.current.selectedTags).toEqual([])
})
it('should reset all tags to false', () => {
const { result } = renderHook(() => useModelTagFilter())
act(() => result.current.toggleTag('vision'))
act(() => result.current.toggleTag('embedding'))
expect(result.current.selectedTags.sort()).toEqual(['embedding', 'vision'])
act(() => result.current.resetTags())
expect(result.current.selectedTags).toEqual([])
expect(Object.values(result.current.tagSelection).every((v) => v === false)).toBe(true)
})
it('tagFilter returns true when no tags selected', () => {
const { result } = renderHook(() => useModelTagFilter())
const model = createModel()
const passed = result.current.tagFilter(model)
expect(passed).toBe(true)
expect(mocks.isVisionModel).not.toHaveBeenCalled()
})
it('tagFilter uses single selected tag predicate', () => {
const { result } = renderHook(() => useModelTagFilter())
const model = createModel()
mocks.isVisionModel.mockReturnValueOnce(true)
act(() => result.current.toggleTag('vision'))
const ok = result.current.tagFilter(model)
expect(ok).toBe(true)
expect(mocks.isVisionModel).toHaveBeenCalledTimes(1)
expect(mocks.isVisionModel).toHaveBeenCalledWith(model)
})
it('tagFilter requires all selected tags to match (AND logic)', () => {
const { result } = renderHook(() => useModelTagFilter())
const model = createModel()
act(() => result.current.toggleTag('vision'))
act(() => result.current.toggleTag('embedding'))
// 第一次:vision=true, embedding=false => 应为 false
mocks.isVisionModel.mockReturnValueOnce(true)
mocks.isEmbeddingModel.mockReturnValueOnce(false)
expect(result.current.tagFilter(model)).toBe(false)
// 第二次:vision=true, embedding=true => 应为 true
mocks.isVisionModel.mockReturnValueOnce(true)
mocks.isEmbeddingModel.mockReturnValueOnce(true)
expect(result.current.tagFilter(model)).toBe(true)
})
})
@@ -0,0 +1,79 @@
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { Model, ModelTag, objectEntries } from '@renderer/types'
import { isFreeModel } from '@renderer/utils/model'
import { useCallback, useMemo, useState } from 'react'
type ModelPredict = (m: Model) => boolean
const initialTagSelection: Record<ModelTag, boolean> = {
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
}
/**
* 标签筛选 hook,仅关注标签过滤逻辑
*/
export function useModelTagFilter() {
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
() => ({
vision: isVisionModel,
embedding: isEmbeddingModel,
reasoning: isReasoningModel,
function_calling: isFunctionCallingModel,
web_search: isWebSearchModel,
rerank: isRerankModel,
free: isFreeModel
}),
[]
)
const [tagSelection, setTagSelection] = useState<Record<ModelTag, boolean>>(initialTagSelection)
// 已选中的标签
const selectedTags = useMemo(
() =>
objectEntries(tagSelection)
.filter(([, state]) => state)
.map(([tag]) => tag),
[tagSelection]
)
// 切换标签
const toggleTag = useCallback((tag: ModelTag) => {
setTagSelection((prev) => ({ ...prev, [tag]: !prev[tag] }))
}, [])
// 重置标签
const resetTags = useCallback(() => {
setTagSelection(initialTagSelection)
}, [])
// 根据标签过滤模型
const tagFilter = useCallback(
(model: Model) => {
if (selectedTags.length === 0) return true
return selectedTags.map((tag) => filterConfig[tag]).every((predict) => predict(model))
},
[filterConfig, selectedTags]
)
return {
tagSelection,
selectedTags,
tagFilter,
toggleTag,
resetTags
}
}
@@ -1,36 +1,19 @@
import { PushpinOutlined } from '@ant-design/icons'
import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from '@renderer/components/Tags/Model'
import { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import {
getModelLogo,
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/models'
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types'
import { Model, ModelType, objectEntries, Provider } from '@renderer/types'
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
import { getModelTags, isFreeModel } from '@renderer/utils/model'
import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd'
import { getModelTags } from '@renderer/utils/model'
import { Avatar, Divider, Empty, Modal, Tooltip } from 'antd'
import { first, sortBy } from 'lodash'
import { SettingsIcon } from 'lucide-react'
import { Settings2 } from 'lucide-react'
import React, {
ReactNode,
startTransition,
useCallback,
useDeferredValue,
@@ -43,18 +26,20 @@ import React, {
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useModelTagFilter } from './filters'
import SelectModelSearchBar from './searchbar'
import TagFilterSection from './TagFilterSection'
import { FlatListItem, FlatListModel } from './types'
const PAGE_SIZE = 12
const ITEM_HEIGHT = 36
type ModelPredict = (m: Model) => boolean
interface PopupParams {
model?: Model
modelFilter?: (model: Model) => boolean
userFilterDisabled?: boolean
/** Basic model filter */
filter?: (model: Model) => boolean
/** Show tag filter section */
showTagFilter?: boolean
}
interface Props extends PopupParams {
@@ -65,7 +50,7 @@ export type FilterType = Exclude<ModelType, 'text'> | 'free'
// const logger = loggerService.withContext('SelectModelPopup')
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilterDisabled }) => {
const PopupContainer: React.FC<Props> = ({ model, filter: baseFilter, showTagFilter = true, resolve }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@@ -74,11 +59,6 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const allModels: Model[] = useMemo(
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
[modelFilter, providers]
)
// 当前选中的模型ID
const currentModelId = model ? getModelUniqId(model) : ''
@@ -93,95 +73,16 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
})
}, [])
// 管理用户筛选状态
/** 从模型列表获取的需要显示的标签 */
const availableTags = useMemo(
() =>
objectEntries(getModelTags(allModels))
.filter(([, state]) => state)
.map(([tag]) => tag),
[allModels]
)
const { tagSelection, selectedTags, tagFilter, toggleTag } = useModelTagFilter()
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
() => ({
vision: isVisionModel,
embedding: isEmbeddingModel,
reasoning: isReasoningModel,
function_calling: isFunctionCallingModel,
web_search: isWebSearchModel,
rerank: isRerankModel,
free: isFreeModel
}),
[]
)
// 计算要显示的可用标签列表
const availableTags = useMemo(() => {
const models = providers.flatMap((p) => p.models).filter(baseFilter ?? (() => true))
return objectEntries(getModelTags(models))
.filter(([, state]) => state)
.map(([tag]) => tag)
}, [providers, baseFilter])
/** 当前选择的标签,表示是否启用特定tag的筛选 */
const [filterTags, setFilterTags] = useState<Record<ModelTag, boolean>>({
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
})
const selectedFilterTags = useMemo(
() =>
objectEntries(filterTags)
.filter(([, state]) => state)
.map(([tag]) => tag),
[filterTags]
)
const userFilter = useCallback(
(model: Model) => {
return selectedFilterTags
.map((tag) => [tag, filterConfig[tag]] as const)
.reduce((prev, [tag, predict]) => {
return prev && (!filterTags[tag] || predict(model))
}, true)
},
[filterConfig, filterTags, selectedFilterTags]
)
const onClickTag = useCallback((type: ModelTag) => {
startTransition(() => {
setFilterTags((prev) => ({ ...prev, [type]: !prev[type] }))
})
}, [])
// 筛选项列表
const tagsItems: Record<ModelTag, ReactNode> = useMemo(
() => ({
vision: <VisionTag showLabel inactive={!filterTags.vision} onClick={() => onClickTag('vision')} />,
embedding: <EmbeddingTag inactive={!filterTags.embedding} onClick={() => onClickTag('embedding')} />,
reasoning: <ReasoningTag showLabel inactive={!filterTags.reasoning} onClick={() => onClickTag('reasoning')} />,
function_calling: (
<ToolsCallingTag
showLabel
inactive={!filterTags.function_calling}
onClick={() => onClickTag('function_calling')}
/>
),
web_search: <WebSearchTag showLabel inactive={!filterTags.web_search} onClick={() => onClickTag('web_search')} />,
rerank: <RerankerTag inactive={!filterTags.rerank} onClick={() => onClickTag('rerank')} />,
free: <FreeTag inactive={!filterTags.free} onClick={() => onClickTag('free')} />
}),
[
filterTags.embedding,
filterTags.free,
filterTags.function_calling,
filterTags.reasoning,
filterTags.rerank,
filterTags.vision,
filterTags.web_search,
onClickTag
]
)
// 要显示的筛选项
const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems])
// 根据输入的文本筛选模型
const searchFilter = useCallback(
(provider: Provider) => {
@@ -201,6 +102,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
(model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
const modelId = getModelUniqId(model)
const groupName = getFancyProviderName(provider)
const isCherryin = provider.id === 'cherryin'
return {
key: isPinned ? `${modelId}_pinned` : modelId,
@@ -209,11 +111,12 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
<ModelName>
{model.name}
{isPinned && <span style={{ color: 'var(--color-text-3)' }}> | {groupName}</span>}
{isCherryin && <FreeTrialModelTag model={model} showLabel={false} />}
</ModelName>
),
tags: (
<TagsContainer>
<ModelTagsWithLabel model={model} size={11} showLabel={false} showTooltip={false} />
<ModelTagsWithLabel model={model} size={11} showLabel={true} />
</TagsContainer>
),
icon: (
@@ -234,9 +137,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
const items: FlatListItem[] = []
const pinnedModelIds = new Set(pinnedModels)
const finalModelFilter = (model: Model) => {
const _userFilter = userFilterDisabled || userFilter(model)
const _modelFilter = modelFilter === undefined || modelFilter(model)
return _userFilter && _modelFilter
const _tagFilter = !showTagFilter || tagFilter(model)
const _baseFilter = baseFilter === undefined || baseFilter(model)
return _tagFilter && _baseFilter
}
// 添加置顶模型分组(仅在无搜索文本时)
@@ -276,11 +179,10 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
name: getFancyProviderName(p),
actions: (
<Tooltip title={t('navigate.provider_settings')} mouseEnterDelay={0.5} mouseLeaveDelay={0}>
<Button
type="text"
size="small"
shape="circle"
icon={<SettingsIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />}
<Settings2
size={12}
color="var(--color-text)"
className="action-icon"
onClick={(e) => {
e.stopPropagation()
setOpen(false)
@@ -303,9 +205,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
pinnedModels,
searchText.length,
providers,
userFilterDisabled,
userFilter,
modelFilter,
showTagFilter,
tagFilter,
baseFilter,
createModelItem,
t,
searchFilter,
@@ -316,7 +218,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
}, [listItems.length])
// 处理程序化滚动(加载、搜索开始、搜索清空)
// 处理程序化滚动(加载、搜索开始、搜索清空、tag 筛选
useLayoutEffect(() => {
if (loading) return
@@ -327,8 +229,8 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
let targetItemKey: string | undefined
// 启动搜索时,滚动到第一个 item
if (searchText) {
// 启动搜索或 tag 筛选时,滚动到第一个 item
if (searchText || selectedTags.length > 0) {
targetItemKey = modelItems[0]?.key
}
// 初始加载或清空搜索时,滚动到 selected item
@@ -349,7 +251,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
})
}
}
}, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight])
}, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight, selectedTags.length])
const handleItemClick = useCallback(
(item: FlatListItem) => {
@@ -508,9 +410,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 16,
// 需要稳定高度避免布局偏移
height: userFilterDisabled ? undefined : 530
paddingBottom: 16
},
body: {
maxHeight: 'inherit',
@@ -522,14 +422,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
{/* 搜索框 */}
<SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{!userFilterDisabled && (
{showTagFilter && (
<>
<FilterContainer>
<Flex wrap="wrap" gap={4}>
<FilterText>{t('models.filter.by_tag')}</FilterText>
{displayedTags.map((item) => item)}
</Flex>
</FilterContainer>
<TagFilterSection availableTags={availableTags} tagSelection={tagSelection} onToggleTag={toggleTag} />
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
</>
)}
@@ -558,16 +453,6 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilt
)
}
const FilterContainer = styled.div`
padding: 8px;
padding-left: 18px;
`
const FilterText = styled.span`
color: var(--color-text-3);
font-size: 12px;
`
const ListContainer = styled.div`
position: relative;
overflow: hidden;
@@ -576,15 +461,29 @@ const ListContainer = styled.div`
const GroupItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
position: relative;
line-height: 1;
font-size: 12px;
font-weight: normal;
height: ${ITEM_HEIGHT}px;
padding: 5px 12px 5px 18px;
padding: 5px 18px;
color: var(--color-text-3);
z-index: 1;
background: var(--modal-background);
.action-icon {
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1 !important;
}
}
&:hover .action-icon {
opacity: 0.3;
}
`
const ModelItem = styled.div`
@@ -640,13 +539,16 @@ const ModelItemLeft = styled.div`
}
`
const ModelName = styled.span`
const ModelName = styled.div`
display: flex;
flex-direction: row;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin: 0 8px;
min-width: 0;
gap: 5px;
`
const TagsContainer = styled.div`
@@ -18,7 +18,7 @@ const MermaidPreview = ({
enableToolbar = false,
ref
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError, forceRenderKey } = useMermaid()
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true)
@@ -56,7 +56,7 @@ const MermaidPreview = ({
document.body.removeChild(measureEl)
}
},
[diagramId, mermaid]
[diagramId, mermaid, forceRenderKey]
)
// 可见性检测函数
@@ -60,7 +60,8 @@ describe('MermaidPreview', () => {
mocks.useMermaid.mockReturnValue({
mermaid: mockMermaid,
isLoading: false,
error: null
error: null,
forceRenderKey: 0
})
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
@@ -116,7 +117,8 @@ describe('MermaidPreview', () => {
mocks.useMermaid.mockReturnValue({
mermaid: mockMermaid,
isLoading: true,
error: null
error: null,
forceRenderKey: 0
})
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
@@ -145,7 +147,8 @@ describe('MermaidPreview', () => {
mocks.useMermaid.mockReturnValue({
mermaid: mockMermaid,
isLoading: false,
error: mermaidError
error: mermaidError,
forceRenderKey: 0
})
render(<MermaidPreview>{mermaidCode}</MermaidPreview>)
@@ -173,7 +176,8 @@ describe('MermaidPreview', () => {
mocks.useMermaid.mockReturnValue({
mermaid: mockMermaid,
isLoading: false,
error: mermaidError
error: mermaidError,
forceRenderKey: 0
})
mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
@@ -0,0 +1,227 @@
import '@renderer/assets/styles/CommandListPopover.scss'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { useTheme } from '@renderer/context/ThemeProvider'
import type { SuggestionProps } from '@tiptap/suggestion'
import { Typography } from 'antd'
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Command } from './command'
const { Text } = Typography
export interface CommandListPopoverProps extends SuggestionProps<Command> {
ref?: React.RefObject<CommandListPopoverRef | null>
}
export interface CommandListPopoverRef extends SuggestionProps<Command> {
updateSelectedIndex: (index: number) => void
selectCurrent: () => void
onKeyDown: (event: KeyboardEvent) => boolean
}
const CommandListPopover = ({
ref,
...props
}: SuggestionProps<Command> & { ref?: React.RefObject<CommandListPopoverRef | null> }) => {
const { items, command } = props
const [internalSelectedIndex, setInternalSelectedIndex] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
const virtualListRef = useRef<DynamicVirtualListRef>(null)
const shouldAutoScrollRef = useRef<boolean>(true)
const { t } = useTranslation()
// Helper function to get translated text with fallback
const getTranslatedCommand = useCallback(
(item: Command, field: 'title' | 'description') => {
const key = `richEditor.commands.${item.id}.${field}`
const translated = t(key)
return translated === key ? item[field] : translated
},
[t]
)
// Reset selected index when items change
useEffect(() => {
shouldAutoScrollRef.current = true
setInternalSelectedIndex(0)
}, [items])
// Auto scroll to selected item using virtual list
useEffect(() => {
if (virtualListRef.current && items.length > 0 && shouldAutoScrollRef.current) {
virtualListRef.current.scrollToIndex(internalSelectedIndex, {
align: 'auto'
})
}
}, [internalSelectedIndex, items.length])
const selectItem = useCallback(
(index: number) => {
const item = props.items[index]
if (item) {
command({ id: item.id, label: item.title })
}
},
[props.items, command]
)
// Handle keyboard navigation
const handleKeyDown = useCallback(
(event: KeyboardEvent): boolean => {
if (!items.length) return false
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
shouldAutoScrollRef.current = true
setInternalSelectedIndex((prev) => (prev === 0 ? items.length - 1 : prev - 1))
return true
case 'ArrowDown':
event.preventDefault()
shouldAutoScrollRef.current = true
setInternalSelectedIndex((prev) => (prev === items.length - 1 ? 0 : prev + 1))
return true
case 'Enter':
event.preventDefault()
if (items[internalSelectedIndex]) {
selectItem(internalSelectedIndex)
}
return true
case 'Escape':
event.preventDefault()
return true
default:
return false
}
},
[items, internalSelectedIndex, selectItem]
)
// Expose methods via ref
useImperativeHandle(
ref,
() => ({
...props,
updateSelectedIndex: (index: number) => {
shouldAutoScrollRef.current = true
setInternalSelectedIndex(index)
},
selectCurrent: () => selectItem(internalSelectedIndex),
onKeyDown: handleKeyDown
}),
[handleKeyDown, props, internalSelectedIndex, selectItem]
)
// Get theme from context
const { theme } = useTheme()
// Get background and selected colors that work with both light and dark themes
const colors = useMemo(() => {
const isDark = theme === 'dark'
return {
background: isDark ? 'var(--color-background-soft, #222222)' : 'white',
border: isDark ? 'var(--color-border, #ffffff19)' : '#e1e5e9',
selectedBackground: isDark ? 'var(--color-hover, rgba(40, 40, 40, 1))' : '#f0f0f0',
boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.1)'
}
}, [theme])
// Handle mouse enter for hover effect
const handleItemMouseEnter = useCallback((index: number) => {
shouldAutoScrollRef.current = false
setInternalSelectedIndex(index)
}, [])
// Estimate size for virtual list items
const estimateSize = useCallback(() => 50, []) // Estimated height per item
// Render virtual list item
const renderVirtualItem = useCallback(
(item: Command, index: number) => {
return (
<div
key={item.id}
data-index={index}
style={{
padding: '10px 16px',
cursor: 'pointer',
backgroundColor: index === internalSelectedIndex ? colors.selectedBackground : 'transparent',
border: 'none',
borderRadius: '4px',
margin: '2px',
minHeight: '46px', // Ensure consistent height for virtual list
display: 'flex',
alignItems: 'center'
}}
onClick={() => selectItem(index)}
onMouseEnter={() => handleItemMouseEnter(index)}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', width: '100%' }}>
<div
style={{
width: '20px',
height: '20px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<item.icon size={16} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ fontSize: '14px', display: 'block', lineHeight: '20px' }}>
{getTranslatedCommand(item, 'title')}
</Text>
<Text type="secondary" style={{ fontSize: '12px', lineHeight: '16px' }}>
{getTranslatedCommand(item, 'description')}
</Text>
</div>
</div>
</div>
)
},
[internalSelectedIndex, colors.selectedBackground, selectItem, handleItemMouseEnter, getTranslatedCommand]
)
const style: React.CSSProperties = {
background: colors.background,
border: `1px solid ${colors.border}`,
borderRadius: '6px',
boxShadow: colors.boxShadow,
maxHeight: '280px',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}
return (
<div ref={listRef} style={style} className="command-list-popover">
{items.length === 0 ? (
<div style={{ padding: '12px', color: '#999', textAlign: 'center', fontSize: '14px' }}>
{t('richEditor.commands.noCommandsFound')}
</div>
) : (
<DynamicVirtualList
ref={virtualListRef}
list={items}
estimateSize={estimateSize}
size="100%"
children={renderVirtualItem}
scrollerStyle={{
overflow: 'auto'
}}
/>
)}
</div>
)
}
CommandListPopover.displayName = 'CommandListPopover'
export default CommandListPopover
@@ -0,0 +1,158 @@
import type { Editor } from '@tiptap/core'
import type { TableOfContentDataItem } from '@tiptap/extension-table-of-contents'
import { TextSelection } from '@tiptap/pm/state'
import React, { useEffect, useState } from 'react'
import { TableOfContentsWrapper, ToCDock } from './styles'
interface ToCItemProps {
item: TableOfContentDataItem
onItemClick: (e: React.MouseEvent, id: string) => void
}
export const ToCItem: React.FC<ToCItemProps> = ({ item, onItemClick }) => {
// Fix: Always show active state when selected by algorithm, regardless of scroll position
const isActive = item.isActive
const isScrolledOver = item.isScrolledOver
const className = `toc-item ${isActive ? 'is-active' : ''} ${isScrolledOver ? 'is-scrolled-over' : ''}`
return (
<div
className={className}
style={
{
'--level': item.level
} as React.CSSProperties
}>
<a href={`#${item.id}`} onClick={(e) => onItemClick(e, item.id)} data-item-index={item.itemIndex}>
{item.textContent}
</a>
</div>
)
}
interface ToCProps {
items?: TableOfContentDataItem[]
editor?: Editor | null
scrollContainerRef?: React.RefObject<HTMLDivElement | null>
}
export const ToC: React.FC<ToCProps> = ({ items = [], editor, scrollContainerRef }) => {
// Filter to only show first 3 levels (H1-H3) to avoid overcrowding
const filteredItems = items.filter((item) => item.level <= 3)
const [maxDisplayItems, setMaxDisplayItems] = useState(30)
// Dynamic calculation based on container height
useEffect(() => {
const calculateMaxItems = () => {
if (!scrollContainerRef?.current) return
const containerHeight = scrollContainerRef.current.clientHeight
// Each button: 4px height + 4px gap = 8px total
// Reserve 40px for padding
const availableHeight = containerHeight - 40
const itemHeight = 8 // 4px button + 4px gap
const calculatedMax = Math.floor(availableHeight / itemHeight)
setMaxDisplayItems(Math.max(10, Math.min(calculatedMax, 50))) // Min 10, max 50
}
calculateMaxItems()
// Recalculate on resize
const resizeObserver = new ResizeObserver(calculateMaxItems)
if (scrollContainerRef?.current) {
resizeObserver.observe(scrollContainerRef.current)
}
return () => resizeObserver.disconnect()
}, [scrollContainerRef, filteredItems.length])
// Smart sampling: if too many items, sample evenly to maintain scroll highlighting
const displayItems =
filteredItems.length <= maxDisplayItems
? filteredItems
: (() => {
const step = filteredItems.length / maxDisplayItems
const sampled: TableOfContentDataItem[] = []
for (let i = 0; i < maxDisplayItems; i++) {
const index = Math.floor(i * step)
sampled.push(filteredItems[index])
}
return sampled
})()
if (displayItems.length === 0) {
return null
}
const onItemClick = (e: React.MouseEvent, id: string) => {
e.preventDefault()
if (editor && scrollContainerRef?.current) {
const element = editor.view.dom.querySelector(`[data-toc-id="${id}"]`) as HTMLElement
if (element) {
const container = scrollContainerRef.current
const pos = editor.view.posAtDOM(element, 0)
const tr = editor.view.state.tr
tr.setSelection(new TextSelection(tr.doc.resolve(pos)))
editor.view.dispatch(tr)
editor.view.focus()
if (history.pushState) {
history.pushState(null, '', `#${id}`)
}
// Calculate correct scroll position to put element at top of viewport
const elementTop = element.getBoundingClientRect().top
const containerTop = container.getBoundingClientRect().top
const targetScrollTop = container.scrollTop + (elementTop - containerTop)
// Smooth scroll to target position
container.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
})
// Force TableOfContents extension to recalculate highlighting after scroll
setTimeout(() => {
const scrollEvent = new Event('scroll', { bubbles: true })
container.dispatchEvent(scrollEvent)
}, 300) // Wait for smooth scroll to complete
}
}
}
return (
<ToCDock>
<div className="toc-rail" data-item-count={displayItems.length}>
{displayItems.map((item) => (
<button
type="button"
key={`rail-${item.id}`}
className={`toc-rail-button level-${item.level} ${item.isActive ? 'active' : ''} ${item.isScrolledOver ? 'scrolled-over' : ''}`}
title={item.textContent}
onClick={(e) => onItemClick(e, item.id)}
/>
))}
</div>
{/* floating panel */}
<div className="toc-panel">
<TableOfContentsWrapper>
<div className="table-of-contents">
{filteredItems.map((item) => (
<ToCItem onItemClick={onItemClick} key={item.id} item={item} />
))}
</div>
</TableOfContentsWrapper>
</div>
</ToCDock>
)
}
export default React.memo(ToC)
@@ -0,0 +1,648 @@
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'
import { loggerService } from '@logger'
import type { Editor } from '@tiptap/core'
import type { MentionNodeAttrs } from '@tiptap/extension-mention'
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
import type { SuggestionOptions } from '@tiptap/suggestion'
import type { LucideIcon } from 'lucide-react'
import {
Bold,
Calculator,
CheckCircle,
Code,
FileCode,
Heading1,
Heading2,
Heading3,
Image,
Italic,
Link,
List,
ListOrdered,
Minus,
Omega,
Quote,
Redo,
Strikethrough,
Table,
Type,
Underline,
Undo,
X
} from 'lucide-react'
import CommandListPopover from './CommandListPopover'
const logger = loggerService.withContext('RichEditor.Command')
export interface Command {
id: string
title: string
description: string
category: CommandCategory
icon: LucideIcon
keywords: string[]
handler: (editor: Editor) => void
isAvailable?: (editor: Editor) => boolean
// Toolbar support
showInToolbar?: boolean
toolbarGroup?: 'text' | 'formatting' | 'blocks' | 'media' | 'structure' | 'history'
formattingCommand?: string // Maps to FormattingCommand for state checking
}
export enum CommandCategory {
TEXT = 'text',
LISTS = 'lists',
BLOCKS = 'blocks',
MEDIA = 'media',
STRUCTURE = 'structure',
SPECIAL = 'special'
}
export interface CommandSuggestion {
query: string
range: any
clientRect?: () => DOMRect | null
}
// Internal dynamic command registry
const commandRegistry = new Map<string, Command>()
export function registerCommand(cmd: Command): void {
commandRegistry.set(cmd.id, cmd)
}
export function unregisterCommand(id: string): void {
commandRegistry.delete(id)
}
export function getCommand(id: string): Command | undefined {
return commandRegistry.get(id)
}
export function getAllCommands(): Command[] {
return Array.from(commandRegistry.values())
}
export function getToolbarCommands(): Command[] {
return getAllCommands().filter((cmd) => cmd.showInToolbar)
}
export function getCommandsByGroup(group: string): Command[] {
return getAllCommands().filter((cmd) => cmd.toolbarGroup === group)
}
// Dynamic toolbar management
export function registerToolbarCommand(cmd: Command): void {
if (!cmd.showInToolbar) {
cmd.showInToolbar = true
}
registerCommand(cmd)
}
export function unregisterToolbarCommand(id: string): void {
const cmd = getCommand(id)
if (cmd) {
cmd.showInToolbar = false
// Keep command for slash menu, just hide from toolbar
}
}
export function setCommandAvailability(id: string, isAvailable: (editor: Editor) => boolean): void {
const cmd = getCommand(id)
if (cmd) {
cmd.isAvailable = isAvailable
}
}
// Convenience functions for common scenarios
export function disableCommandsWhen(commandIds: string[], condition: (editor: Editor) => boolean): void {
commandIds.forEach((id) => {
setCommandAvailability(id, (editor) => !condition(editor))
})
}
export function hideToolbarCommandsWhen(commandIds: string[], condition: () => boolean): void {
if (condition()) {
commandIds.forEach((id) => unregisterToolbarCommand(id))
} else {
commandIds.forEach((id) => {
const cmd = getCommand(id)
if (cmd) {
cmd.showInToolbar = true
}
})
}
}
// Default command definitions
const DEFAULT_COMMANDS: Command[] = [
{
id: 'bold',
title: 'Bold',
description: 'Make text bold',
category: CommandCategory.TEXT,
icon: Bold,
keywords: ['bold', 'strong', 'b'],
handler: (editor: Editor) => {
editor.chain().focus().toggleBold().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'bold'
},
{
id: 'italic',
title: 'Italic',
description: 'Make text italic',
category: CommandCategory.TEXT,
icon: Italic,
keywords: ['italic', 'emphasis', 'i'],
handler: (editor: Editor) => {
editor.chain().focus().toggleItalic().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'italic'
},
{
id: 'underline',
title: 'Underline',
description: 'Underline text',
category: CommandCategory.TEXT,
icon: Underline,
keywords: ['underline', 'u'],
handler: (editor: Editor) => {
editor.chain().focus().toggleUnderline().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'underline'
},
{
id: 'strike',
title: 'Strikethrough',
description: 'Strike through text',
category: CommandCategory.TEXT,
icon: Strikethrough,
keywords: ['strikethrough', 'strike', 's'],
handler: (editor: Editor) => {
editor.chain().focus().toggleStrike().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'strike'
},
{
id: 'inlineCode',
title: 'Inline Code',
description: 'Add inline code',
category: CommandCategory.SPECIAL,
icon: Code,
keywords: ['code', 'inline', 'monospace'],
handler: (editor: Editor) => {
editor.chain().focus().toggleCode().run()
},
showInToolbar: true,
toolbarGroup: 'formatting',
formattingCommand: 'code'
},
{
id: 'paragraph',
title: 'Text',
description: 'Start writing with plain text',
category: CommandCategory.TEXT,
icon: Type,
keywords: ['text', 'paragraph', 'p'],
handler: (editor: Editor) => {
editor.chain().focus().setParagraph().run()
},
showInToolbar: true,
toolbarGroup: 'text',
formattingCommand: 'paragraph'
},
{
id: 'heading1',
title: 'Heading 1',
description: 'Big section heading',
category: CommandCategory.TEXT,
icon: Heading1,
keywords: ['heading', 'h1', 'title', 'big'],
handler: (editor: Editor) => {
editor.chain().focus().toggleHeading({ level: 1 }).run()
},
showInToolbar: true,
toolbarGroup: 'text',
formattingCommand: 'heading1'
},
{
id: 'heading2',
title: 'Heading 2',
description: 'Medium section heading',
category: CommandCategory.TEXT,
icon: Heading2,
keywords: ['heading', 'h2', 'subtitle', 'medium'],
handler: (editor: Editor) => {
editor.chain().focus().toggleHeading({ level: 2 }).run()
},
showInToolbar: true,
toolbarGroup: 'text',
formattingCommand: 'heading2'
},
{
id: 'heading3',
title: 'Heading 3',
description: 'Small section heading',
category: CommandCategory.TEXT,
icon: Heading3,
keywords: ['heading', 'h3', 'small'],
handler: (editor: Editor) => {
editor.chain().focus().toggleHeading({ level: 3 }).run()
},
showInToolbar: true,
toolbarGroup: 'text',
formattingCommand: 'heading3'
},
{
id: 'bulletList',
title: 'Bulleted list',
description: 'Create a simple bulleted list',
category: CommandCategory.LISTS,
icon: List,
keywords: ['bullet', 'list', 'ul', 'unordered'],
handler: (editor: Editor) => {
editor.chain().focus().toggleBulletList().run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'bulletList'
},
{
id: 'orderedList',
title: 'Numbered list',
description: 'Create a list with numbering',
category: CommandCategory.LISTS,
icon: ListOrdered,
keywords: ['number', 'list', 'ol', 'ordered'],
handler: (editor: Editor) => {
editor.chain().focus().toggleOrderedList().run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'orderedList'
},
{
id: 'codeBlock',
title: 'Code',
description: 'Capture a code snippet',
category: CommandCategory.BLOCKS,
icon: FileCode,
keywords: ['code', 'block', 'snippet', 'programming'],
handler: (editor: Editor) => {
editor.chain().focus().toggleCodeBlock().run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'codeBlock'
},
{
id: 'blockquote',
title: 'Quote',
description: 'Capture a quote',
category: CommandCategory.BLOCKS,
icon: Quote,
keywords: ['quote', 'blockquote', 'citation'],
handler: (editor: Editor) => {
editor.chain().focus().toggleBlockquote().run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'blockquote'
},
{
id: 'divider',
title: 'Divider',
description: 'Add a horizontal line',
category: CommandCategory.STRUCTURE,
icon: Minus,
keywords: ['divider', 'hr', 'line', 'separator'],
handler: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
{
id: 'image',
title: 'Image',
description: 'Insert an image',
category: CommandCategory.MEDIA,
icon: Image,
keywords: ['image', 'img', 'picture', 'photo'],
handler: (editor: Editor) => {
editor.chain().focus().insertImagePlaceholder().run()
},
showInToolbar: true,
toolbarGroup: 'media',
formattingCommand: 'image'
},
{
id: 'link',
title: 'Link',
description: 'Add a link',
category: CommandCategory.SPECIAL,
icon: Link,
keywords: ['link', 'url', 'href'],
handler: (editor: Editor) => {
editor.chain().focus().setEnhancedLink({ href: '' }).run()
},
showInToolbar: true,
toolbarGroup: 'media',
formattingCommand: 'link'
},
{
id: 'table',
title: 'Table',
description: 'Insert a table',
category: CommandCategory.STRUCTURE,
icon: Table,
keywords: ['table', 'grid', 'rows', 'columns'],
handler: (editor: Editor) => {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
},
showInToolbar: true,
toolbarGroup: 'structure',
formattingCommand: 'table'
},
// Additional commands for slash menu only
{
id: 'taskList',
title: 'Task List',
description: 'Create a checklist',
category: CommandCategory.LISTS,
icon: CheckCircle,
keywords: ['task', 'todo', 'checklist', 'checkbox'],
handler: (editor: Editor) => {
editor.chain().focus().toggleTaskList().run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'taskList'
},
{
id: 'hardBreak',
title: 'Line Break',
description: 'Insert a line break',
category: CommandCategory.STRUCTURE,
icon: X,
keywords: ['break', 'br', 'newline'],
handler: (editor: Editor) => {
editor.chain().focus().setHardBreak().run()
}
},
{
id: 'inlineMath',
title: 'Inline Equation',
description: 'Insert inline equation',
category: CommandCategory.BLOCKS,
icon: Omega,
keywords: ['inline', 'math', 'formula', 'equation', 'latex'],
handler: (editor: Editor) => {
editor.chain().focus().insertMathPlaceholder({ mathType: 'inline' }).run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'inlineMath'
},
{
id: 'blockMath',
title: 'Math Formula',
description: 'Insert mathematical formula',
category: CommandCategory.BLOCKS,
icon: Calculator,
keywords: ['math', 'formula', 'equation', 'latex'],
handler: (editor: Editor) => {
editor.chain().focus().insertMathPlaceholder({ mathType: 'block' }).run()
},
showInToolbar: true,
toolbarGroup: 'blocks',
formattingCommand: 'blockMath'
},
// History commands
{
id: 'undo',
title: 'Undo',
description: 'Undo last action',
category: CommandCategory.SPECIAL,
icon: Undo,
keywords: ['undo', 'revert'],
handler: (editor: Editor) => {
editor.chain().focus().undo().run()
},
showInToolbar: true,
toolbarGroup: 'history',
formattingCommand: 'undo'
},
{
id: 'redo',
title: 'Redo',
description: 'Redo last action',
category: CommandCategory.SPECIAL,
icon: Redo,
keywords: ['redo', 'repeat'],
handler: (editor: Editor) => {
editor.chain().focus().redo().run()
},
showInToolbar: true,
toolbarGroup: 'history',
formattingCommand: 'redo'
}
]
export interface CommandFilterOptions {
query?: string
category?: CommandCategory
maxResults?: number
}
// Filter commands based on search query and category
export function filterCommands(options: CommandFilterOptions = {}): Command[] {
const { query = '', category } = options
let filtered = getAllCommands()
// Filter by category if specified
if (category) {
filtered = filtered.filter((cmd) => cmd.category === category)
}
// Filter by search query
if (query) {
const searchTerm = query.toLowerCase().trim()
filtered = filtered.filter((cmd) => {
const searchableText = [cmd.title, cmd.description, ...cmd.keywords].join(' ').toLowerCase()
return searchableText.includes(searchTerm)
})
// Sort by relevance (exact matches first, then title matches, then keyword matches)
filtered.sort((a, b) => {
const aTitle = a.title.toLowerCase()
const bTitle = b.title.toLowerCase()
const aExactMatch = aTitle === searchTerm
const bExactMatch = bTitle === searchTerm
const aTitleMatch = aTitle.includes(searchTerm)
const bTitleMatch = bTitle.includes(searchTerm)
if (aExactMatch && !bExactMatch) return -1
if (bExactMatch && !aExactMatch) return 1
if (aTitleMatch && !bTitleMatch) return -1
if (bTitleMatch && !aTitleMatch) return 1
return a.title.localeCompare(b.title)
})
}
return filtered
}
const updatePosition = (editor: Editor, element: HTMLElement) => {
const virtualElement = {
getBoundingClientRect: () => posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
}
computePosition(virtualElement, element, {
placement: 'bottom-start',
strategy: 'fixed',
middleware: [
offset(4), // Add small offset from trigger
flip({
fallbackPlacements: ['top-start', 'bottom-end', 'top-end', 'bottom-start'],
padding: 8 // Ensure some padding from viewport edges
}),
shift({
padding: 8 // Prevent overflow on sides
}),
size({
apply({ availableWidth, availableHeight, elements }) {
// Ensure the popover doesn't exceed viewport bounds
const maxHeight = Math.min(400, availableHeight - 16) // 16px total padding
const maxWidth = Math.min(320, availableWidth - 16)
Object.assign(elements.floating.style, {
maxHeight: `${maxHeight}px`,
maxWidth: `${maxWidth}px`,
minWidth: '240px'
})
}
})
]
})
.then(({ x, y, strategy, placement }) => {
Object.assign(element.style, {
position: strategy,
left: `${x}px`,
top: `${y}px`,
width: 'max-content'
})
// Add data attribute to track current placement for styling
element.setAttribute('data-placement', placement)
})
.catch((error) => {
logger.error('Error positioning command list:', error)
})
}
// Register default commands into the dynamic registry
DEFAULT_COMMANDS.forEach(registerCommand)
// TipTap suggestion configuration
export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs>, 'editor'> = {
char: '/',
startOfLine: true,
items: ({ query }: { query: string }) => {
try {
return filterCommands({ query })
} catch (error) {
logger.error('Error filtering commands:', error as Error)
return []
}
},
command: ({ editor, range, props }) => {
editor.chain().focus().deleteRange(range).run()
// Find the original command by id
if (props.id) {
const command = getCommand(props.id)
if (command) {
command.handler(editor)
}
}
},
render: () => {
let component: ReactRenderer<any, any>
let cleanup: (() => void) | undefined
return {
onStart: (props) => {
if (!props?.items || !props?.clientRect) {
logger.warn('Invalid props in command suggestion onStart')
return
}
component = new ReactRenderer(CommandListPopover, {
props,
editor: props.editor
})
const element = component.element as HTMLElement
// element.style.position = 'absolute'
element.style.zIndex = '1001'
document.body.appendChild(element)
// Set up auto-updating position that responds to scroll and resize
const virtualElement = {
getBoundingClientRect: () =>
posToDOMRect(props.editor.view, props.editor.state.selection.from, props.editor.state.selection.to)
}
cleanup = autoUpdate(virtualElement, element, () => {
updatePosition(props.editor, element)
})
// Initial position update
updatePosition(props.editor, element)
},
onUpdate: (props) => {
if (!props?.items || !props.clientRect) return
component.updateProps(props)
// Update position when items change (might affect size)
if (component.element) {
setTimeout(() => {
updatePosition(props.editor, component.element as HTMLElement)
}, 0)
}
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
if (cleanup) cleanup()
component.destroy()
return true
}
return component.ref?.onKeyDown(props.event)
},
onExit: () => {
if (cleanup) cleanup()
const element = component.element as HTMLElement
element.remove()
component.destroy()
}
}
}
}
@@ -0,0 +1,84 @@
import { Menu } from 'antd'
import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
export interface ActionMenuItem {
key: string
label: React.ReactNode
icon?: React.ReactNode
danger?: boolean
onClick: () => void
}
export interface ActionMenuProps {
show: boolean
position: { x: number; y: number }
items: ActionMenuItem[]
onClose: () => void
minWidth?: number
}
export const ActionMenu: FC<ActionMenuProps> = ({ show, position, items, onClose, minWidth = 168 }) => {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!show) return
const onDocMouseDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose()
}
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', onDocMouseDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onDocMouseDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [show, onClose])
const menuItems = useMemo(
() =>
items.map((it) => ({
key: it.key,
label: it.label,
icon: it.icon,
danger: it.danger
})),
[items]
)
const onMenuClick = useCallback(
({ key }: { key: string }) => {
const found = items.find((i) => i.key === key)
if (found) found.onClick()
onClose()
},
[items, onClose]
)
if (!show) return null
const node = (
<div
ref={ref}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 2000,
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 6,
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
overflow: 'hidden',
minWidth
}}>
<Menu selectable={false} items={menuItems} onClick={onMenuClick} style={{ border: 'none' }} />
</div>
)
return createPortal(node, document.body)
}
@@ -0,0 +1,206 @@
import { InboxOutlined, LinkOutlined, LoadingOutlined, UploadOutlined } from '@ant-design/icons'
import { Button, Flex, Input, message, Modal, Spin, Tabs, Upload } from 'antd'
const { Dragger } = Upload
import type { RcFile } from 'antd/es/upload'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ImageUploaderProps {
/** Callback when image is selected/uploaded */
onImageSelect: (imageUrl: string) => void
/** Whether the uploader is visible */
visible: boolean
/** Callback when uploader should be closed */
onClose: () => void
}
const TabContent = styled.div`
padding: 24px 0;
display: flex;
flex-direction: column;
`
const UrlInput = styled(Input)`
.ant-input {
padding: 12px 16px
font-size: 14px
border-radius: 4px
border: 1px solid #dadce0
transition: all 0.2s ease
background: #ffffff
&:hover {
border-color: #4285f4
}
&:focus {
border-color: #4285f4
box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3)
}
}
`
// Function to convert file to base64 URL
const convertFileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to convert file to base64'))
}
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({ onImageSelect, visible, onClose }) => {
const { t } = useTranslation()
const [urlInput, setUrlInput] = useState('')
const [loading, setLoading] = useState(false)
const handleFileSelect = async (file: RcFile) => {
try {
setLoading(true)
// Validate file type
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error(t('richEditor.imageUploader.invalidType'))
return false
}
// Validate file size (max 10MB)
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error(t('richEditor.imageUploader.tooLarge'))
return false
}
// Convert to base64 and call callback
const base64Url = await convertFileToBase64(file)
onImageSelect(base64Url)
message.success(t('richEditor.imageUploader.uploadSuccess'))
onClose()
} catch (error) {
message.error(t('richEditor.imageUploader.uploadError'))
} finally {
setLoading(false)
}
return false // Prevent default upload
}
const handleUrlSubmit = () => {
if (!urlInput.trim()) {
message.error(t('richEditor.imageUploader.urlRequired'))
return
}
// Basic URL validation
try {
new URL(urlInput.trim())
onImageSelect(urlInput.trim())
message.success(t('richEditor.imageUploader.embedSuccess'))
setUrlInput('')
onClose()
} catch {
message.error(t('richEditor.imageUploader.invalidUrl'))
}
}
const handleCancel = () => {
setUrlInput('')
onClose()
}
const tabItems = [
{
key: 'upload',
label: (
<div>
<UploadOutlined size={18} style={{ marginRight: 8 }} />
{t('richEditor.imageUploader.upload')}
</div>
),
children: (
<TabContent>
<Dragger
accept="image/*"
showUploadList={false}
beforeUpload={handleFileSelect}
customRequest={() => {}} // Prevent default upload
disabled={loading}>
{loading ? (
<>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
<p className="ant-upload-text">{t('richEditor.imageUploader.uploading')}</p>
<p className="ant-upload-hint">{t('richEditor.imageUploader.processing')}</p>
</>
) : (
<>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{t('richEditor.imageUploader.uploadText')}</p>
<p className="ant-upload-hint">{t('richEditor.imageUploader.uploadHint')}</p>
</>
)}
</Dragger>
</TabContent>
)
},
{
key: 'url',
label: (
<span>
<LinkOutlined style={{ marginRight: 8 }} />
{t('richEditor.imageUploader.embedLink')}
</span>
),
children: (
<TabContent>
<Flex gap={12} justify="center">
<UrlInput
placeholder={t('richEditor.imageUploader.urlPlaceholder')}
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onPressEnter={handleUrlSubmit}
prefix={<LinkOutlined style={{ color: '#999' }} />}
style={{ flex: 1 }}
/>
<Button
onClick={() => setUrlInput('')}
style={{
border: '1px solid #dadce0',
borderRadius: '4px',
color: '#3c4043',
background: '#ffffff'
}}>
{t('common.clear')}
</Button>
<Button type="primary" onClick={handleUrlSubmit} disabled={!urlInput.trim()}>
{t('richEditor.imageUploader.embedImage')}
</Button>
</Flex>
</TabContent>
)
}
]
return (
<Modal
title={t('richEditor.imageUploader.title')}
open={visible}
onCancel={handleCancel}
footer={null}
width={600}
centered>
<Tabs defaultActiveKey="upload" items={tabItems} size="large" />
</Modal>
)
}
@@ -0,0 +1,166 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { Button, Flex, Input } from 'antd'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface LinkEditorProps {
/** Whether the editor is visible */
visible: boolean
/** Position for the popup */
position: { x: number; y: number } | null
/** Link attributes */
link: { href: string; text: string }
/** Callback when the user saves the link */
onSave: (href: string, text: string) => void
/** Callback when the user removes the link */
onRemove: () => void
/** Callback when the editor is closed without saving */
onCancel: () => void
/** Whether to show remove button */
showRemove?: boolean
}
/**
* Inline link editor that appears on hover over links
* Provides input fields for editing link URL and title
*/
const LinkEditor: React.FC<LinkEditorProps> = ({
visible,
position,
link,
onSave,
onRemove,
onCancel,
showRemove = true
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const [href, setHref] = useState<string>(link.href || '')
const [text, setText] = useState<string>(link.text || '')
const containerRef = useRef<HTMLDivElement>(null)
const hrefInputRef = useRef<any>(null)
// Reset values when link changes
useEffect(() => {
if (visible) {
setHref(link.href || '')
setText(link.text || '')
}
}, [visible, link.href, link.text])
// Auto-focus href input when dialog opens
useEffect(() => {
if (visible && hrefInputRef.current) {
setTimeout(() => {
hrefInputRef.current?.focus()
}, 100)
}
}, [visible])
// Handle clicks outside to close
useEffect(() => {
if (!visible) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Don't close if clicking within the editor or on a link
if (containerRef.current?.contains(target) || target.closest('a[href]') || target.closest('[data-link-editor]')) {
return
}
onCancel()
}
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside)
}, 100)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [visible, onCancel])
const handleSave = useCallback(() => {
const trimmedHref = href.trim()
const trimmedText = text.trim()
if (trimmedHref && trimmedText) {
onSave(trimmedHref, trimmedText)
}
}, [href, text, onSave])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
},
[handleSave, onCancel]
)
if (!visible || !position) return null
// Theme-aware styles
const isDark = theme === 'dark'
const styles: React.CSSProperties = {
position: 'fixed',
left: position.x,
top: position.y + 25, // Position slightly below the link
zIndex: 1000,
background: isDark ? 'var(--color-background-soft, #222222)' : 'white',
border: `1px solid ${isDark ? 'var(--color-border, #ffffff19)' : '#d9d9d9'}`,
borderRadius: 8,
boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0,0,0,0.15)',
padding: 12,
width: 320,
maxWidth: '90vw'
}
return (
<div style={styles} ref={containerRef} data-link-editor onKeyDown={handleKeyDown}>
<div style={{ marginBottom: 8 }}>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
{t('richEditor.link.text')}
</label>
<Input
ref={hrefInputRef}
value={text}
placeholder={t('richEditor.link.textPlaceholder')}
onChange={(e) => setText(e.target.value)}
size="small"
/>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
{t('richEditor.link.url')}
</label>
<Input value={href} placeholder="https://example.com" onChange={(e) => setHref(e.target.value)} size="small" />
</div>
<Flex justify="space-between" align="center">
<div>
{showRemove && (
<Button size="small" danger type="text" onClick={onRemove} style={{ padding: '0 8px' }}>
{t('richEditor.link.remove')}
</Button>
)}
</div>
<Flex gap={6}>
<Button size="small" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button type="primary" size="small" onClick={handleSave} disabled={!href.trim() || !text.trim()}>
{t('common.save')}
</Button>
</Flex>
</Flex>
</div>
)
}
export default LinkEditor
@@ -0,0 +1,161 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { Button, Flex, Input } from 'antd'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface MathInputDialogProps {
/** Whether the dialog is visible */
visible: boolean
/** Callback when the user confirms the formula */
onSubmit: (formula: string) => void
/** Callback when the dialog is closed without submitting */
onCancel: () => void
/** Initial LaTeX value */
defaultValue?: string
/** Callback for real-time formula updates */
onFormulaChange?: (formula: string) => void
/** Position relative to target element */
position?: { x: number; y: number; top?: number }
/** Scroll container reference to prevent scrolling */
scrollContainer?: React.RefObject<HTMLDivElement | null>
}
/**
* Simple inline dialog for entering LaTeX formula.
* Renders a small floating box (similar to the screenshot provided by the user)
* with a multi-line input and a confirm button.
*/
const MathInputDialog: React.FC<MathInputDialogProps> = ({
visible,
onSubmit,
onCancel,
defaultValue = '',
onFormulaChange,
position,
scrollContainer
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const [value, setValue] = useState<string>(defaultValue)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (visible) {
setValue(defaultValue)
}
}, [visible, defaultValue])
// Prevent scroll container scrolling when dialog is open
useEffect(() => {
if (visible && scrollContainer?.current) {
const scrollElement = scrollContainer.current
const originalOverflow = scrollElement.style.overflow
const originalScrollbarGutter = scrollElement.style.scrollbarGutter
scrollElement.style.overflow = 'hidden'
scrollElement.style.scrollbarGutter = 'stable'
return () => {
if (scrollElement) {
scrollElement.style.overflow = originalOverflow
scrollElement.style.scrollbarGutter = originalScrollbarGutter
}
}
}
return
}, [visible, scrollContainer])
useEffect(() => {
if (visible && containerRef.current) {
const textarea = containerRef.current.querySelector('textarea') as HTMLTextAreaElement | null
if (textarea) {
textarea.focus()
// Position cursor at the end of the text
const length = textarea.value.length
textarea.setSelectionRange(length, length)
}
}
}, [visible])
if (!visible) return null
const handleSubmit = () => {
const trimmed = value.trim()
if (trimmed) {
onSubmit(trimmed)
}
}
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleSubmit()
}
}
const isDark = theme === 'dark'
const getPositionStyles = (): React.CSSProperties => {
if (position) {
const dialogHeight = 200
const spaceBelow = window.innerHeight - position.y
const spaceAbove = position.y
const showAbove = spaceBelow < dialogHeight + 20 && spaceAbove > dialogHeight + 20
return {
position: 'fixed',
// When showing above, use the element's top position for accurate placement
top: showAbove ? 'auto' : position.y + 10,
bottom: showAbove ? window.innerHeight - (position.top || position.y) + 10 : 'auto',
left: position.x,
transform: 'translateX(-50%)',
zIndex: 1000
}
}
return {
position: 'fixed',
top: '50%',
left: '50%',
zIndex: 1000
}
}
const styles: React.CSSProperties = {
...getPositionStyles(),
background: isDark ? 'var(--color-background-soft, #222222)' : 'white',
border: `1px solid ${isDark ? 'var(--color-border, #ffffff19)' : '#d9d9d9'}`,
borderRadius: 8,
boxShadow: isDark ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0,0,0,0.15)',
padding: 16,
width: 360,
maxWidth: '90vw'
}
return (
<div style={styles} ref={containerRef}>
<Input.TextArea
value={value}
rows={4}
placeholder={t('richEditor.math.placeholder')}
onChange={(e) => {
const newValue = e.target.value
setValue(newValue)
onFormulaChange?.(newValue)
}}
onKeyDown={handleKeyDown}
style={{ marginBottom: 12, fontFamily: 'monospace' }}
/>
<Flex justify="flex-end" gap={8}>
<Button size="small" onClick={onCancel}>
{t('common.cancel')}
</Button>
<Button type="primary" size="small" onClick={handleSubmit}>
{t('common.confirm')}
</Button>
</Flex>
</div>
)
}
export default MathInputDialog
@@ -0,0 +1,79 @@
import type { Plugin } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/react'
import React from 'react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { defaultComputePositionConfig } from '../extensions/plus-button'
import { PlusButtonPlugin, plusButtonPluginDefaultKey, PlusButtonPluginOptions } from '../plugins/plusButtonPlugin'
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
export type PlusButtonProps = Omit<Optional<PlusButtonPluginOptions, 'pluginKey'>, 'element'> & {
className?: string
onNodeChange?: (data: { node: Node | null; editor: Editor; pos: number }) => void
children: ReactNode
}
export const PlusButton: React.FC<PlusButtonProps> = (props: PlusButtonProps) => {
const {
className = 'plus-button',
children,
editor,
pluginKey = plusButtonPluginDefaultKey,
onNodeChange,
onElementClick,
computePositionConfig = defaultComputePositionConfig
} = props
const [element, setElement] = useState<HTMLDivElement | null>(null)
const plugin = useRef<Plugin | null>(null)
useEffect(() => {
let initPlugin: {
plugin: Plugin
unbind: () => void
} | null = null
if (!element) {
return () => {
plugin.current = null
}
}
if (editor.isDestroyed) {
return () => {
plugin.current = null
}
}
if (!plugin.current) {
initPlugin = PlusButtonPlugin({
editor,
element,
pluginKey,
computePositionConfig: {
...defaultComputePositionConfig,
...computePositionConfig
},
onElementClick,
onNodeChange
})
plugin.current = initPlugin.plugin
editor.registerPlugin(plugin.current)
}
return () => {
editor.unregisterPlugin(pluginKey)
plugin.current = null
if (initPlugin) {
initPlugin.unbind()
initPlugin = null
}
}
}, [computePositionConfig, editor, element, onElementClick, onNodeChange, pluginKey])
return (
<div className={className} style={{ visibility: 'hidden', position: 'absolute' }} ref={setElement}>
{children}
</div>
)
}
export default PlusButton
@@ -0,0 +1,111 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
export interface TableAction {
label: string
action: () => void
icon?: string
}
export interface TableActionMenuProps {
show: boolean
position?: { x: number; y: number }
actions: TableAction[]
onClose: () => void
}
export const TableActionMenu: FC<TableActionMenuProps> = ({ show, position, actions, onClose }) => {
const menuRef = useRef<HTMLDivElement>(null)
const [menuPosition, setMenuPosition] = useState(position || { x: 0, y: 0 })
useEffect(() => {
if (show && position) {
setMenuPosition(position)
}
}, [show, position])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose()
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
if (show) {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [show, onClose])
const handleActionClick = useCallback(
(action: TableAction) => {
action.action()
onClose()
},
[onClose]
)
if (!show) return null
const menu = (
<div
ref={menuRef}
className="table-action-menu"
style={{
position: 'fixed',
left: menuPosition.x,
top: menuPosition.y,
zIndex: 1000,
backgroundColor: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
minWidth: '160px',
padding: '4px 0'
}}>
{actions.map((action, index) => (
<button
key={index}
type="button"
className="table-action-item"
onClick={() => handleActionClick(action)}
style={{
width: '100%',
border: 'none',
background: 'none',
padding: '8px 16px',
textAlign: 'left',
cursor: 'pointer',
fontSize: '14px',
color: 'var(--color-text)',
transition: 'background-color 0.2s',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-bg-hover)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}>
{action.icon && <span>{action.icon}</span>}
<span>{action.label}</span>
</button>
))}
</div>
)
return createPortal(menu, document.body)
}
@@ -0,0 +1,240 @@
import { loggerService } from '@logger'
import React, { useCallback, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useMenuActionVisibility } from './hooks/useMenuActionVisibility'
import {
EmptyState,
MenuContainer,
MenuDivider,
MenuGroup,
MenuGroupTitle,
MenuItem,
MenuItemIcon,
MenuItemLabel,
MenuItemShortcut
} from './styles'
import type { ActionGroup, DragContextMenuProps, MenuAction } from './types'
const logger = loggerService.withContext('DragContextMenu')
/**
*
*/
const GROUP_LABELS: Record<ActionGroup, string> = {
transform: 'Transform',
format: 'Format',
block: 'Actions',
insert: 'Insert',
ai: 'AI'
}
/**
*
*/
const GROUP_ORDER: ActionGroup[] = [
'transform' as ActionGroup,
'format' as ActionGroup,
'insert' as ActionGroup,
'block' as ActionGroup,
'ai' as ActionGroup
]
/**
*
*/
const DragContextMenu: React.FC<DragContextMenuProps> = ({
editor,
node,
position,
visible,
menuPosition,
onClose,
customActions = [],
disabledActions = []
}) => {
const menuRef = useRef<HTMLDivElement>(null)
// 获取菜单操作可见性
const { visibleActions } = useMenuActionVisibility({
editor,
node,
position
})
/**
*
*/
const allActions = useMemo(() => {
const actions = [...visibleActions, ...customActions]
// 过滤被禁用的操作
return actions.filter((action) => !disabledActions.includes(action.id))
}, [visibleActions, customActions, disabledActions])
/**
*
*/
const finalActionsByGroup = useMemo(() => {
const grouped: Record<ActionGroup, MenuAction[]> = {
transform: [],
format: [],
block: [],
insert: [],
ai: []
}
allActions.forEach((action) => {
if (grouped[action.group]) {
grouped[action.group].push(action)
}
})
return grouped
}, [allActions])
/**
*
*/
const handleMenuItemClick = useCallback(
async (action: MenuAction) => {
try {
logger.debug('Menu item clicked', {
actionId: action.id,
nodeType: node.type.name,
position
})
// 执行操作
action.execute(editor, node, position)
// 关闭菜单
onClose()
logger.debug('Menu action executed successfully', { actionId: action.id })
} catch (error) {
logger.error('Failed to execute menu action', error as Error, {
actionId: action.id,
nodeType: node.type.name
})
// 即使失败也关闭菜单
onClose()
}
},
[editor, node, position, onClose]
)
/**
*
*/
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
switch (event.key) {
case 'Escape':
event.preventDefault()
onClose()
break
case 'ArrowDown':
event.preventDefault()
// TODO: 实现键盘导航
break
case 'ArrowUp':
event.preventDefault()
// TODO: 实现键盘导航
break
case 'Enter':
event.preventDefault()
// TODO: 执行选中的操作
break
}
},
[onClose]
)
/**
*
*/
const renderMenuGroup = useCallback(
(group: ActionGroup, actions: MenuAction[]) => {
if (actions.length === 0) return null
return (
<MenuGroup key={group}>
<MenuGroupTitle>{GROUP_LABELS[group]}</MenuGroupTitle>
{actions.map((action) => (
<MenuItem
key={action.id}
$danger={action.danger}
onClick={() => handleMenuItemClick(action)}
disabled={!action.isEnabled(editor, node, position)}
title={action.shortcut ? `${action.label} (${action.shortcut})` : action.label}>
{action.icon && <MenuItemIcon>{action.icon}</MenuItemIcon>}
<MenuItemLabel>{action.label}</MenuItemLabel>
{action.shortcut && <MenuItemShortcut>{action.shortcut}</MenuItemShortcut>}
</MenuItem>
))}
</MenuGroup>
)
},
[editor, node, position, handleMenuItemClick]
)
// 如果菜单不可见,不渲染
if (!visible) return null
// 如果没有可用操作,显示空状态
if (allActions.length === 0) {
const emptyMenu = (
<MenuContainer
ref={menuRef}
$visible={visible}
style={{
left: menuPosition.x,
top: menuPosition.y
}}
onKeyDown={handleKeyDown}
tabIndex={-1}>
<EmptyState>No actions available for this block</EmptyState>
</MenuContainer>
)
return createPortal(emptyMenu, document.body)
}
// 渲染完整菜单
const menu = (
<MenuContainer
ref={menuRef}
$visible={visible}
style={{
left: menuPosition.x,
top: menuPosition.y
}}
onKeyDown={handleKeyDown}
tabIndex={-1}
data-testid="drag-context-menu">
{GROUP_ORDER.map((group, index) => {
const actions = finalActionsByGroup[group]
const groupElement = renderMenuGroup(group, actions)
if (!groupElement) return null
return (
<React.Fragment key={group}>
{groupElement}
{/* 在组之间添加分隔线,除了最后一个组 */}
{index < GROUP_ORDER.length - 1 &&
actions.length > 0 &&
GROUP_ORDER.slice(index + 1).some((g) => finalActionsByGroup[g].length > 0) && <MenuDivider />}
</React.Fragment>
)
})}
</MenuContainer>
)
return createPortal(menu, document.body)
}
DragContextMenu.displayName = 'DragContextMenu'
export default DragContextMenu
@@ -0,0 +1,113 @@
import { loggerService } from '@logger'
import type { ActionGroup, MenuAction } from '../types'
const logger = loggerService.withContext('BlockActions')
/**
*
*/
export const blockActions: MenuAction[] = [
{
id: 'block-copy',
label: 'Copy to clipboard',
group: 'block' as ActionGroup,
isEnabled: () => true, // 总是可用
execute: async (editor, node, pos) => {
try {
logger.debug('Copying block', { nodeType: node.type.name, pos })
// 获取节点的文本内容
const text = node.textContent
// 获取节点的 HTML 内容
const htmlContent = editor.getHTML()
// 尝试使用现代剪贴板 API
if (navigator.clipboard && window.ClipboardItem) {
const clipboardItem = new ClipboardItem({
'text/plain': new Blob([text], { type: 'text/plain' }),
'text/html': new Blob([htmlContent], { type: 'text/html' })
})
await navigator.clipboard.write([clipboardItem])
logger.debug('Block copied to clipboard (modern API)')
} else if (navigator.clipboard) {
// 后备方案:只复制文本
await navigator.clipboard.writeText(text)
logger.debug('Block text copied to clipboard')
} else {
// 最后的后备方案:使用传统的复制方法
const textArea = document.createElement('textarea')
textArea.value = text
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
logger.debug('Block copied using legacy method')
}
} catch (error) {
logger.error('Failed to copy block', error as Error)
throw error
}
}
},
{
id: 'block-duplicate',
label: 'Duplicate node',
group: 'block' as ActionGroup,
isEnabled: () => true,
execute: (editor, node, pos) => {
try {
logger.debug('Duplicating block', { nodeType: node.type.name, pos })
// 计算插入位置(当前块之后)
const insertPos = pos + node.nodeSize
// 获取节点的 JSON 表示
const nodeJson = node.toJSON()
// 在当前块后插入相同的节点
editor.chain().focus().insertContentAt(insertPos, nodeJson).run()
logger.debug('Block duplicated successfully')
} catch (error) {
logger.error('Failed to duplicate block', error as Error)
throw error
}
}
},
{
id: 'block-delete',
label: 'Delete',
group: 'block' as ActionGroup,
danger: true,
isEnabled: (editor, node) => {
// 检查是否是文档中唯一的块,如果是则不允许删除
const doc = editor.state.doc
if (doc.childCount <= 1 && node.type.name === 'paragraph' && !node.textContent.trim()) {
return false // 不允许删除唯一的空段落
}
return true
},
execute: (editor, node, pos) => {
try {
logger.debug('Deleting block', { nodeType: node.type.name, pos })
// 计算删除范围
const from = pos
const to = pos + node.nodeSize
// 删除节点
editor.chain().focus().deleteRange({ from, to }).run()
logger.debug('Block deleted successfully')
} catch (error) {
logger.error('Failed to delete block', error as Error)
throw error
}
}
}
]
@@ -0,0 +1,55 @@
import { loggerService } from '@logger'
import type { ActionGroup, MenuAction } from '../types'
const logger = loggerService.withContext('FormattingActions')
/**
*
*/
export const formattingActions: MenuAction[] = [
{
id: 'format-color',
label: 'Color',
group: 'format' as ActionGroup,
isEnabled: () => true, // 颜色选择总是可用
execute: (_editor, node, pos) => {
try {
logger.debug('Color picker action - placeholder', { nodeType: node.type.name, pos })
// TODO: 实现颜色选择器功能
// 这里先提供一个占位实现
} catch (error) {
logger.error('Failed to open color picker', error as Error)
throw error
}
}
},
{
id: 'format-reset',
label: 'Clear Formatting',
group: 'format' as ActionGroup,
isEnabled: (editor) => {
return editor.can().unsetAllMarks()
},
execute: (editor, node, pos) => {
try {
logger.debug('Clearing formatting', { nodeType: node.type.name, pos })
// 选择整个节点内容
const from = pos + 1 // 节点内容开始位置
const to = pos + node.nodeSize - 1 // 节点内容结束位置
// 清除所有格式标记
editor.chain().focus().setTextSelection({ from, to }).unsetAllMarks().run()
logger.debug('Formatting cleared successfully')
} catch (error) {
logger.error('Failed to clear formatting', error as Error)
throw error
}
}
}
// 注意:更多格式化操作可以在后续版本中添加
]
@@ -0,0 +1,53 @@
/**
*
*
*
*/
// 操作定义
export * from './block'
export * from './formatting'
export * from './insert'
export * from './transform'
// 操作注册表
import type { MenuAction } from '../types'
import { blockActions } from './block'
import { formattingActions } from './formatting'
import { insertActions } from './insert'
import { transformActions } from './transform'
/**
*
*/
export const allActions: MenuAction[] = [...transformActions, ...formattingActions, ...blockActions, ...insertActions]
/**
* ID
*/
export function getActionById(id: string): MenuAction | undefined {
return allActions.find((action) => action.id === id)
}
/**
* ID
*/
export const defaultEnabledActions = [
// Transform
'transform-heading-1',
'transform-heading-2',
'transform-heading-3',
'transform-paragraph',
'transform-bullet-list',
'transform-ordered-list',
'transform-blockquote',
'transform-code-block',
// Block operations
'block-duplicate',
'block-copy',
'block-delete',
// Insert
'insert-paragraph-after'
]
@@ -0,0 +1,81 @@
import { loggerService } from '@logger'
import { FileText, Plus } from 'lucide-react'
import React from 'react'
import type { ActionGroup, MenuAction } from '../types'
const logger = loggerService.withContext('InsertActions')
/**
*
*/
export const insertActions: MenuAction[] = [
{
id: 'insert-paragraph-after',
label: 'Add Paragraph Below',
icon: React.createElement(Plus, { size: 16 }),
group: 'insert' as ActionGroup,
shortcut: 'Enter',
isEnabled: () => true,
execute: (editor, node, pos) => {
try {
logger.debug('Inserting paragraph after block', { nodeType: node.type.name, pos })
// 计算插入位置(当前块之后)
const insertPos = pos + node.nodeSize
// 插入新段落
editor
.chain()
.focus()
.insertContentAt(insertPos, '<p></p>')
.focus(insertPos + 1)
.run()
// 延迟触发命令菜单 - 这样用户可以通过 "/" 快速插入其他类型的块
setTimeout(() => {
try {
editor.chain().insertContent('/').run()
logger.debug('Command menu triggered with "/"')
} catch (error) {
logger.warn('Failed to trigger command menu', error as Error)
}
}, 50)
logger.debug('Paragraph inserted successfully')
} catch (error) {
logger.error('Failed to insert paragraph', error as Error)
throw error
}
}
},
{
id: 'insert-paragraph-before',
label: 'Add Paragraph Above',
icon: React.createElement(FileText, { size: 16 }),
group: 'insert' as ActionGroup,
isEnabled: () => true,
execute: (editor, node, pos) => {
try {
logger.debug('Inserting paragraph before block', { nodeType: node.type.name, pos })
// 插入位置就是当前块的开始位置
const insertPos = pos
// 插入新段落
editor
.chain()
.focus()
.insertContentAt(insertPos, '<p></p>')
.focus(insertPos + 1)
.run()
logger.debug('Paragraph inserted before block successfully')
} catch (error) {
logger.error('Failed to insert paragraph before block', error as Error)
throw error
}
}
}
]
@@ -0,0 +1,146 @@
import { loggerService } from '@logger'
import type { ActionGroup, MenuAction } from '../types'
const logger = loggerService.withContext('TransformActions')
/**
*
*/
export const transformActions: MenuAction[] = [
{
id: 'transform-heading-1',
label: 'Heading 1',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 1 })
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to H1', { nodeType: node.type.name, pos })
editor.chain().focus().setHeading({ level: 1 }).run()
} catch (error) {
logger.error('Failed to transform to H1', error as Error)
}
}
},
{
id: 'transform-heading-2',
label: 'Heading 2',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 2 })
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to H2', { nodeType: node.type.name, pos })
editor.chain().focus().setHeading({ level: 2 }).run()
} catch (error) {
logger.error('Failed to transform to H2', error as Error)
}
}
},
{
id: 'transform-heading-3',
label: 'Heading 3',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().setHeading({ level: 3 })
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to H3', { nodeType: node.type.name, pos })
editor.chain().focus().setHeading({ level: 3 }).run()
} catch (error) {
logger.error('Failed to transform to H3', error as Error)
}
}
},
{
id: 'transform-paragraph',
label: 'Text',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return node.type.name === 'heading' && editor.can().setParagraph()
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to paragraph', { nodeType: node.type.name, pos })
editor.chain().focus().setParagraph().run()
} catch (error) {
logger.error('Failed to transform to paragraph', error as Error)
}
}
},
{
id: 'transform-bullet-list',
label: 'Bulleted list',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBulletList()
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to bullet list', { nodeType: node.type.name, pos })
editor.chain().focus().toggleBulletList().run()
} catch (error) {
logger.error('Failed to transform to bullet list', error as Error)
}
}
},
{
id: 'transform-ordered-list',
label: 'Numbered list',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleOrderedList()
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to ordered list', { nodeType: node.type.name, pos })
editor.chain().focus().toggleOrderedList().run()
} catch (error) {
logger.error('Failed to transform to ordered list', error as Error)
}
}
},
{
id: 'transform-blockquote',
label: 'Quote',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleBlockquote()
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to blockquote', { nodeType: node.type.name, pos })
editor.chain().focus().toggleBlockquote().run()
} catch (error) {
logger.error('Failed to transform to blockquote', error as Error)
}
}
},
{
id: 'transform-code-block',
label: 'Code',
group: 'transform' as ActionGroup,
isEnabled: (editor, node) => {
return (node.type.name === 'paragraph' || node.type.name === 'heading') && editor.can().toggleCodeBlock()
},
execute: (editor, node, pos) => {
try {
logger.debug('Transforming to code block', { nodeType: node.type.name, pos })
editor.chain().focus().toggleCodeBlock().run()
} catch (error) {
logger.error('Failed to transform to code block', error as Error)
}
}
}
]
@@ -0,0 +1,279 @@
import { loggerService } from '@logger'
import type { Editor } from '@tiptap/core'
import type { Node } from '@tiptap/pm/model'
import React, { useCallback, useRef, useState } from 'react'
import type { EventHandlers, MenuAction, MenuActionResult, PositionOptions, UseDragContextMenuReturn } from '../types'
const logger = loggerService.withContext('useDragContextMenu')
interface UseDragContextMenuOptions {
/** 编辑器实例 */
editor: Editor
/** 事件处理器 */
eventHandlers?: EventHandlers
/** 位置计算选项 */
positionOptions?: PositionOptions
}
/**
* Hook
*/
export function useDragContextMenu({
editor,
eventHandlers,
positionOptions
}: UseDragContextMenuOptions): UseDragContextMenuReturn {
// 菜单状态
const [isMenuVisible, setIsMenuVisible] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const [currentNode, setCurrentNode] = useState<{ node: Node; position: number } | null>(null)
// 引用
const menuRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
/**
*
*/
const calculateMenuPosition = useCallback(
(clientPos: { x: number; y: number }) => {
const { offset = { x: 10, y: 0 }, boundary, autoAdjust = true } = positionOptions || {}
let x = clientPos.x + offset.x
let y = clientPos.y + offset.y
if (autoAdjust) {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const menuWidth = 280 // 预估菜单宽度
const menuHeight = 400 // 预估菜单最大高度
// 水平位置调整
if (x + menuWidth > viewportWidth) {
x = clientPos.x - menuWidth - offset.x
}
// 垂直位置调整
if (y + menuHeight > viewportHeight) {
y = Math.max(10, viewportHeight - menuHeight - 10)
}
// 边界约束
if (boundary) {
const rect = boundary.getBoundingClientRect()
x = Math.max(rect.left, Math.min(x, rect.right - menuWidth))
y = Math.max(rect.top, Math.min(y, rect.bottom - menuHeight))
}
}
return { x, y }
},
[positionOptions]
)
/**
*
*/
const showMenu = useCallback(
(node: Node, position: number, clientPos: { x: number; y: number }) => {
try {
logger.debug('Showing context menu', {
nodeType: node.type.name,
position,
clientPos
})
// 清除之前的延时隐藏
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = undefined
}
const menuPos = calculateMenuPosition(clientPos)
setCurrentNode({ node, position })
setMenuPosition(menuPos)
setIsMenuVisible(true)
// 触发事件
eventHandlers?.onMenuShow?.(node, position)
} catch (error) {
logger.error('Failed to show menu', error as Error)
eventHandlers?.onError?.(error as Error, 'showMenu')
}
},
[calculateMenuPosition, eventHandlers]
)
/**
*
*/
const hideMenu = useCallback(() => {
try {
logger.debug('Hiding context menu')
setIsMenuVisible(false)
setCurrentNode(null)
// 延时清理位置,以便动画完成
timeoutRef.current = setTimeout(() => {
setMenuPosition({ x: 0, y: 0 })
}, 200)
// 触发事件
eventHandlers?.onMenuHide?.()
} catch (error) {
logger.error('Failed to hide menu', error as Error)
eventHandlers?.onError?.(error as Error, 'hideMenu')
}
}, [eventHandlers])
/**
*
*/
const executeAction = useCallback(
async (action: MenuAction): Promise<MenuActionResult> => {
if (!currentNode) {
const error = new Error('No current node available')
logger.error('Cannot execute action without current node', error)
return { success: false, error: error.message }
}
try {
logger.debug('Executing menu action', {
actionId: action.id,
nodeType: currentNode.node.type.name,
position: currentNode.position
})
// 检查操作是否可用
if (!action.isEnabled(editor, currentNode.node, currentNode.position)) {
const error = 'Action is not enabled for current context'
logger.warn('Action not enabled', { actionId: action.id })
return { success: false, error }
}
// 执行操作
action.execute(editor, currentNode.node, currentNode.position)
const result: MenuActionResult = {
success: true,
shouldCloseMenu: true // 默认执行后关闭菜单
}
// 触发事件
eventHandlers?.onActionExecute?.(action, result)
// 自动关闭菜单
if (result.shouldCloseMenu !== false) {
hideMenu()
}
logger.debug('Action executed successfully', { actionId: action.id })
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Failed to execute action', error as Error, {
actionId: action.id,
nodeType: currentNode.node.type.name
})
const result: MenuActionResult = {
success: false,
error: errorMessage
}
eventHandlers?.onActionExecute?.(action, result)
eventHandlers?.onError?.(error as Error, `executeAction:${action.id}`)
return result
}
},
[currentNode, editor, eventHandlers, hideMenu]
)
/**
*
*/
const handleEditorUpdate = useCallback(() => {
if (isMenuVisible) {
// 检查当前节点是否仍然有效
if (currentNode) {
try {
const doc = editor.state.doc
const pos = currentNode.position
// 检查位置是否仍然有效
if (pos >= 0 && pos < doc.content.size) {
const resolvedPos = doc.resolve(pos)
const nodeAtPos = resolvedPos.nodeAfter || resolvedPos.parent
// 如果节点类型或内容发生变化,隐藏菜单
if (nodeAtPos?.type.name !== currentNode.node.type.name) {
hideMenu()
}
} else {
// 位置无效,隐藏菜单
hideMenu()
}
} catch (error) {
logger.warn('Invalid node position, hiding menu', error as Error)
hideMenu()
}
}
}
}, [isMenuVisible, currentNode, editor, hideMenu])
// 监听编辑器更新
React.useEffect(() => {
if (!editor) return
editor.on('update', handleEditorUpdate)
editor.on('blur', hideMenu)
return () => {
editor.off('update', handleEditorUpdate)
editor.off('blur', hideMenu)
// 清理定时器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [editor, handleEditorUpdate, hideMenu])
// 监听全局点击事件,点击菜单外部时隐藏
React.useEffect(() => {
if (!isMenuVisible) return
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as HTMLElement)) {
hideMenu()
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
hideMenu()
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isMenuVisible, hideMenu])
return {
isMenuVisible,
menuPosition,
currentNode,
showMenu,
hideMenu,
executeAction
}
}
@@ -0,0 +1,110 @@
import { loggerService } from '@logger'
import { useCallback, useMemo } from 'react'
import type { MenuAction, MenuVisibilityOptions, UseMenuActionVisibilityReturn } from '../types'
import { ActionGroup } from '../types'
const logger = loggerService.withContext('useMenuActionVisibility')
/**
* Hook
*/
export function useMenuActionVisibility({
editor,
node,
position,
customRules
}: MenuVisibilityOptions): UseMenuActionVisibilityReturn {
/**
*
*/
const visibleActions = useMemo(() => {
if (!editor || !node) {
return []
}
try {
// 获取所有已注册的操作
const allActions = getRegisteredActions()
// 过滤可用的操作
const filtered = allActions.filter((action) => {
try {
// 基础可用性检查
if (!action.isEnabled(editor, node, position)) {
return false
}
// 自定义规则检查
if (customRules) {
const customResult = customRules.every((rule) => rule(editor, node, position))
if (!customResult) {
return false
}
}
return true
} catch (error) {
logger.warn('Error checking action visibility', error as Error, { actionId: action.id })
return false
}
})
logger.debug('Filtered visible actions', {
total: allActions.length,
visible: filtered.length,
nodeType: node.type.name,
position
})
return filtered
} catch (error) {
logger.error('Failed to calculate visible actions', error as Error)
return []
}
}, [editor, node, position, customRules])
/**
*
*/
const actionsByGroup = useMemo(() => {
const grouped: Record<ActionGroup, MenuAction[]> = {
[ActionGroup.TRANSFORM]: [],
[ActionGroup.FORMAT]: [],
[ActionGroup.BLOCK]: [],
[ActionGroup.INSERT]: [],
[ActionGroup.AI]: []
}
visibleActions.forEach((action) => {
if (grouped[action.group]) {
grouped[action.group].push(action)
}
})
return grouped
}, [visibleActions])
/**
* -
*/
const refreshVisibility = useCallback(() => {
// 这个函数主要用于外部强制刷新,实际的刷新通过依赖项自动处理
logger.debug('Visibility refresh requested')
}, [])
return {
visibleActions,
actionsByGroup,
refreshVisibility
}
}
import { allActions } from '../actions'
/**
*
*/
function getRegisteredActions(): MenuAction[] {
return allActions
}
@@ -0,0 +1,54 @@
/**
* Drag Context Menu -
*
* Notion
* -
* -
* -
* -
*/
// 主要组件
export { default as DragContextMenu } from './DragContextMenu'
// DragContextMenuWrapper 已被 TipTap 扩展替代
// Hooks
export { useDragContextMenu } from './hooks/useDragContextMenu'
export { useMenuActionVisibility } from './hooks/useMenuActionVisibility'
// 操作定义
export * from './actions'
export { allActions, defaultEnabledActions, getActionById } from './actions'
// 类型定义
export type * from './types'
// 样式组件
export * from './styles'
/**
*
*/
export const defaultDragContextMenuConfig = {
enabled: true,
defaultActions: [
'transform-heading-1',
'transform-heading-2',
'transform-heading-3',
'transform-paragraph',
'transform-bullet-list',
'transform-ordered-list',
'transform-blockquote',
'transform-code-block',
'block-duplicate',
'block-copy',
'block-delete',
'insert-paragraph-after'
],
groupOrder: ['transform', 'format', 'insert', 'block', 'ai'],
menuStyles: {
maxWidth: 320,
maxHeight: 400,
showShortcuts: true
}
}
@@ -0,0 +1,280 @@
import styled, { css, keyframes } from 'styled-components'
/**
*
*/
// 动画定义
const fadeInUp = keyframes`
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
`
const fadeOut = keyframes`
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
`
/**
*
*/
export const MenuContainer = styled.div<{ $visible: boolean }>`
position: fixed;
z-index: 2000;
background: var(--color-bg-base);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow:
0 6px 16px rgba(0, 0, 0, 0.12),
0 3px 6px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 280px;
max-width: 320px;
max-height: 400px;
${(props) =>
props.$visible
? css`
animation: ${fadeInUp} 0.15s ease-out;
`
: css`
animation: ${fadeOut} 0.15s ease-out;
pointer-events: none;
`}
/* 响应式调整 */
@media (max-width: 480px) {
min-width: 240px;
max-width: 280px;
}
`
/**
*
*/
export const MenuGroupTitle = styled.div`
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: none;
&:not(:first-child) {
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--color-border-secondary);
}
`
/**
*
*/
export const MenuGroup = styled.div`
padding: 4px 0;
&:not(:last-child) {
border-bottom: 1px solid var(--color-border-secondary);
}
`
/**
*
*/
export const MenuItem = styled.button<{ $danger?: boolean }>`
width: 100%;
display: flex;
align-items: center;
padding: 8px 16px;
border: none;
background: transparent;
color: var(--color-text);
font-size: 14px;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease;
gap: 12px;
${(props) =>
props.$danger &&
css`
color: var(--color-error);
&:hover {
background: var(--color-error-bg);
color: var(--color-error);
}
`}
&:hover {
background: var(--color-hover);
}
&:focus {
outline: none;
background: var(--color-primary-bg);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: transparent;
}
}
`
/**
*
*/
export const MenuItemIcon = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
`
/**
*
*/
export const MenuItemLabel = styled.span`
flex: 1;
font-weight: 400;
`
/**
*
*/
export const MenuItemShortcut = styled.span`
font-size: 12px;
color: var(--color-text-3);
font-family: var(--font-mono);
margin-left: auto;
`
/**
*
*/
export const DragHandleContainer = styled.div<{ $visible: boolean }>`
display: flex;
align-items: center;
gap: 0.25rem;
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition: opacity 0.15s ease;
position: absolute;
left: -60px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
padding: 2px;
`
/**
*
*/
const handleButtonBase = css`
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
border: none;
background: var(--color-background);
color: var(--color-text-3);
cursor: pointer;
transition: background 0.15s ease;
padding: 0;
&:hover {
background: var(--color-hover);
}
&:focus {
outline: none;
background: var(--color-primary-bg);
}
`
/**
*
*/
export const PlusButton = styled.button`
${handleButtonBase}
`
/**
*
*/
export const DragHandleButton = styled.div`
${handleButtonBase}
cursor: grab;
&:active {
cursor: grabbing;
}
&[draggable='true'] {
user-select: none;
}
`
/**
*
*/
export const LoadingIndicator = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
color: var(--color-text-3);
font-size: 14px;
`
/**
*
*/
export const ErrorMessage = styled.div`
padding: 12px 16px;
color: var(--color-error);
background: var(--color-error-bg);
border-radius: 4px;
margin: 8px;
font-size: 14px;
text-align: center;
`
/**
*
*/
export const EmptyState = styled.div`
padding: 24px 16px;
text-align: center;
color: var(--color-text-3);
font-size: 14px;
`
/**
* 线
*/
export const MenuDivider = styled.hr`
border: none;
border-top: 1px solid var(--color-border-secondary);
margin: 4px 0;
`
@@ -0,0 +1,224 @@
import type { Editor } from '@tiptap/core'
import type { Node } from '@tiptap/pm/model'
import type { ReactNode } from 'react'
/**
* -
*/
export enum ActionGroup {
TRANSFORM = 'transform', // 节点转换操作
FORMAT = 'format', // 格式化操作
BLOCK = 'block', // 块级操作
INSERT = 'insert', // 插入操作
AI = 'ai' // AI 相关操作 (预留)
}
/**
*
*/
export interface MenuAction {
/** 操作唯一标识 */
id: string
/** 显示标签 */
label: string
/** 图标 */
icon?: ReactNode
/** 操作组 */
group: ActionGroup
/** 快捷键描述 */
shortcut?: string
/** 是否为危险操作 */
danger?: boolean
/** 是否可用 */
isEnabled: (editor: Editor, node: Node, pos: number) => boolean
/** 执行操作 */
execute: (editor: Editor, node: Node, pos: number) => void
/** 自定义类名 */
className?: string
}
/**
*
*/
export interface NodeTransformOptions {
/** 目标节点类型 */
nodeType: string
/** 节点属性 */
attrs?: Record<string, any>
/** 是否保留内容 */
preserveContent?: boolean
}
/**
*
*/
export interface DragContextMenuProps {
/** 编辑器实例 */
editor: Editor
/** 当前节点 */
node: Node
/** 节点在文档中的位置 */
position: number
/** 菜单显示状态 */
visible: boolean
/** 菜单位置 */
menuPosition: { x: number; y: number }
/** 关闭回调 */
onClose: () => void
/** 自定义操作 */
customActions?: MenuAction[]
/** 禁用的操作 ID 列表 */
disabledActions?: string[]
}
/**
*
*/
export interface DragHandleProps {
/** 编辑器实例 */
editor: Editor
/** 当前节点 */
node: Node
/** 节点位置 */
position: number
/** 是否显示 */
visible: boolean
/** 点击回调 */
onClick: () => void
/** 拖拽开始回调 */
onDragStart?: (e: DragEvent) => void
/** 自定义类名 */
className?: string
}
/**
*
*/
export interface MenuVisibilityOptions {
/** 当前编辑器实例 */
editor: Editor
/** 当前节点 */
node: Node
/** 节点位置 */
position: number
/** 自定义可见性规则 */
customRules?: Array<(editor: Editor, node: Node, pos: number) => boolean>
}
/**
*
*/
export interface MenuActionResult {
/** 是否成功执行 */
success: boolean
/** 错误信息 */
error?: string
/** 是否需要关闭菜单 */
shouldCloseMenu?: boolean
}
/**
*
*/
export interface ColorOption {
/** 颜色值 */
color: string
/** 显示名称 */
name: string
/** 是否为默认颜色 */
isDefault?: boolean
}
/**
*
*/
export interface NodeTransformMap {
[key: string]: {
/** 显示名称 */
label: string
/** 图标 */
icon: ReactNode
/** 转换配置 */
transform: NodeTransformOptions
/** 是否可用的检查函数 */
isAvailable?: (editor: Editor, currentNode: Node) => boolean
}
}
/**
*
*/
export interface DragContextMenuConfig {
/** 是否启用 */
enabled: boolean
/** 默认操作列表 */
defaultActions: string[]
/** 自定义操作 */
customActions?: MenuAction[]
/** 操作组排序 */
groupOrder?: ActionGroup[]
/** 颜色选择器配置 */
colorOptions?: ColorOption[]
/** 节点转换映射 */
transformMap?: NodeTransformMap
/** 菜单样式配置 */
menuStyles?: {
maxWidth?: number
maxHeight?: number
showShortcuts?: boolean
}
}
/**
* - useDragContextMenu
*/
export interface UseDragContextMenuReturn {
/** 菜单是否可见 */
isMenuVisible: boolean
/** 菜单位置 */
menuPosition: { x: number; y: number }
/** 当前节点信息 */
currentNode: { node: Node; position: number } | null
/** 显示菜单 */
showMenu: (node: Node, position: number, clientPos: { x: number; y: number }) => void
/** 隐藏菜单 */
hideMenu: () => void
/** 执行操作 */
executeAction: (action: MenuAction) => Promise<MenuActionResult>
}
/**
* - useMenuActionVisibility
*/
export interface UseMenuActionVisibilityReturn {
/** 可见的操作列表 */
visibleActions: MenuAction[]
/** 按组分类的操作 */
actionsByGroup: Record<ActionGroup, MenuAction[]>
/** 刷新可见性 */
refreshVisibility: () => void
}
/**
*
*/
export interface EventHandlers {
onMenuShow?: (node: Node, position: number) => void
onMenuHide?: () => void
onActionExecute?: (action: MenuAction, result: MenuActionResult) => void
onError?: (error: Error, context: string) => void
}
/**
*
*/
export interface PositionOptions {
/** 偏移量 */
offset?: { x: number; y: number }
/** 边界约束 */
boundary?: HTMLElement
/** 对齐方式 */
align?: 'start' | 'center' | 'end'
/** 自动调整位置 */
autoAdjust?: boolean
}
@@ -0,0 +1,47 @@
import { Editor } from '@tiptap/core'
import { NodeViewWrapper } from '@tiptap/react'
import { Image as ImageIcon } from 'lucide-react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import PlaceholderBlock from './PlaceholderBlock'
interface ImagePlaceholderNodeViewProps {
node: any
updateAttributes: (attributes: Record<string, any>) => void
deleteNode: () => void
editor: Editor
}
const ImagePlaceholderNodeView: React.FC<ImagePlaceholderNodeViewProps> = ({ deleteNode, editor }) => {
const { t } = useTranslation()
const handleClick = useCallback(() => {
const event = new CustomEvent('openImageUploader', {
detail: {
onImageSelect: (imageUrl: string) => {
if (imageUrl.trim()) {
deleteNode()
editor.chain().focus().setImage({ src: imageUrl }).run()
} else {
deleteNode()
}
},
onCancel: () => deleteNode()
}
})
window.dispatchEvent(event)
}, [editor, deleteNode])
return (
<NodeViewWrapper className="image-placeholder-wrapper">
<PlaceholderBlock
icon={<ImageIcon size={20} style={{ color: '#656d76' }} />}
message={t('richEditor.image.placeholder')}
onClick={handleClick}
/>
</NodeViewWrapper>
)
}
export default ImagePlaceholderNodeView
@@ -0,0 +1,74 @@
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react'
import { Calculator } from 'lucide-react'
import React, { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import PlaceholderBlock from './PlaceholderBlock'
const MathPlaceholderNodeView: React.FC<NodeViewProps> = ({ node, deleteNode, editor }) => {
const { t } = useTranslation()
const wrapperRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(() => {
let hasCreatedMath = false
const mathType = node.attrs.mathType || 'block'
let position: { x: number; y: number; top: number } | undefined
if (wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect()
position = {
x: rect.left + rect.width / 2,
y: rect.bottom,
top: rect.top
}
}
const event = new CustomEvent('openMathDialog', {
detail: {
defaultValue: '',
position,
onSubmit: (latex: string) => {
// onFormulaChange has already handled the creation/update
// onSubmit just needs to close the dialog
// Only delete if input is empty
if (!latex.trim()) {
deleteNode()
}
},
onCancel: () => deleteNode(),
onFormulaChange: (formula: string) => {
if (formula.trim()) {
if (!hasCreatedMath) {
hasCreatedMath = true
deleteNode()
if (mathType === 'block') {
editor.chain().insertBlockMath({ latex: formula }).run()
} else {
editor.chain().insertInlineMath({ latex: formula }).run()
}
} else {
if (mathType === 'block') {
editor.chain().updateBlockMath({ latex: formula }).run()
} else {
editor.chain().updateInlineMath({ latex: formula }).run()
}
}
}
}
}
})
window.dispatchEvent(event)
}, [node.attrs.mathType, deleteNode, editor])
return (
<NodeViewWrapper className="math-placeholder-wrapper" ref={wrapperRef}>
<PlaceholderBlock
icon={<Calculator size={20} style={{ color: '#656d76' }} />}
message={t('richEditor.math.placeholder')}
onClick={handleClick}
/>
</NodeViewWrapper>
)
}
export default MathPlaceholderNodeView
@@ -0,0 +1,62 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import React from 'react'
interface PlaceholderBlockProps {
/** Icon element to display */
icon: React.ReactNode
/** Localised message */
message: string
/** Click handler */
onClick: () => void
}
/**
* Reusable placeholder block for TipTap NodeViews (math / image etc.)
* Handles dark-mode colours and simple hover feedback.
*/
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ icon, message, onClick }) => {
const { theme } = useTheme()
const isDark = theme === 'dark'
const colors = {
border: isDark ? 'var(--color-border, #ffffff19)' : '#d0d7de',
background: isDark ? 'var(--color-background-soft, #222222)' : 'var(--color-canvas-subtle, #f6f8fa)',
hoverBorder: isDark ? 'var(--color-primary, #2f81f7)' : '#0969da',
hoverBackground: isDark ? 'rgba(56, 139, 253, 0.15)' : 'var(--color-accent-subtle, #ddf4ff)'
}
return (
<div
onClick={onClick}
style={{
border: `2px dashed ${colors.border}`,
borderRadius: 6,
padding: 24,
margin: '8px 0',
textAlign: 'center',
cursor: 'pointer',
background: colors.background,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
minHeight: 80
}}
onMouseEnter={(e) => {
const target = e.currentTarget as HTMLElement
target.style.borderColor = colors.hoverBorder
target.style.backgroundColor = colors.hoverBackground
}}
onMouseLeave={(e) => {
const target = e.currentTarget as HTMLElement
target.style.borderColor = colors.border
target.style.backgroundColor = colors.background
}}>
{icon}
<span style={{ color: '#656d76', fontSize: 14 }}>{message}</span>
</div>
)
}
export default PlaceholderBlock
@@ -0,0 +1,87 @@
import { CopyOutlined } from '@ant-design/icons'
import { DEFAULT_LANGUAGES, getHighlighter, getShiki } from '@renderer/utils/shiki'
import { NodeViewContent, NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer } from '@tiptap/react'
import { Button, Select, Tooltip } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
const CodeBlockNodeView: FC<ReactNodeViewProps> = (props) => {
const { node, updateAttributes } = props
const [languageOptions, setLanguageOptions] = useState<string[]>(DEFAULT_LANGUAGES)
// Detect language from node attrs or fallback
const language = (node.attrs.language as string) || 'text'
// Build language options with 'text' always available
useEffect(() => {
const loadLanguageOptions = async () => {
try {
const shiki = await getShiki()
const highlighter = await getHighlighter()
// Get bundled languages from shiki
const bundledLanguages = Object.keys(shiki.bundledLanguages)
// Combine with loaded languages
const loadedLanguages = highlighter.getLoadedLanguages()
const allLanguages = Array.from(new Set(['text', ...bundledLanguages, ...loadedLanguages]))
setLanguageOptions(allLanguages)
} catch {
setLanguageOptions(DEFAULT_LANGUAGES)
}
}
loadLanguageOptions()
}, [])
// Handle language change
const handleLanguageChange = useCallback(
(value: string) => {
updateAttributes({ language: value })
},
[updateAttributes]
)
// Handle copy code block content
const handleCopy = useCallback(async () => {
const codeText = props.node.textContent || ''
try {
await navigator.clipboard.writeText(codeText)
} catch {
// Clipboard may fail (e.g. non-secure context)
}
}, [props.node.textContent])
return (
<NodeViewWrapper className="code-block-wrapper">
<div className="code-block-header">
<Select
size="small"
className="code-block-language-select"
value={language}
onChange={handleLanguageChange}
options={languageOptions.map((lang) => ({ value: lang, label: lang }))}
style={{ minWidth: 90 }}
/>
<Tooltip title="Copy">
<Button
size="small"
type="text"
icon={<CopyOutlined />}
className="code-block-copy-btn"
onClick={handleCopy}
/>
</Tooltip>
</div>
<pre className={`language-${language}`}>
{/* TipTap will render the editable code content here */}
<NodeViewContent<'code'> as="code" />
</pre>
</NodeViewWrapper>
)
}
export const CodeBlockNodeReactRenderer = ReactNodeViewRenderer(CodeBlockNodeView)
export default CodeBlockNodeView
@@ -0,0 +1,144 @@
import { textblockTypeInputRule } from '@tiptap/core'
import CodeBlock, { type CodeBlockOptions } from '@tiptap/extension-code-block'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { CodeBlockNodeReactRenderer } from './CodeBlockNodeView'
import { ShikiPlugin } from './shikijsPlugin'
export interface CodeBlockShikiOptions extends CodeBlockOptions {
defaultLanguage: string
theme: string
}
export const CodeBlockShiki = CodeBlock.extend<CodeBlockShikiOptions>({
addOptions() {
return {
...this.parent?.(),
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: 'text',
theme: 'one-light',
HTMLAttributes: {
class: 'code-block-shiki'
}
}
},
addInputRules() {
const parent = this.parent?.()
return [
...(parent || []),
// 支持动态语言匹配: ```语言名
textblockTypeInputRule({
find: /^```([a-zA-Z0-9#+\-_.]+)\s/,
type: this.type,
getAttributes: (match) => {
const inputLanguage = match[1]?.toLowerCase().trim()
if (!inputLanguage) return {}
return { language: inputLanguage }
}
}),
// 支持 ~~~ 语法
textblockTypeInputRule({
find: /^~~~([a-zA-Z0-9#+\-_.]+)\s/,
type: this.type,
getAttributes: (match) => {
const inputLanguage = match[1]?.toLowerCase().trim()
if (!inputLanguage) return {}
return { language: inputLanguage }
}
})
]
},
addNodeView() {
return CodeBlockNodeReactRenderer
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.isActive(this.name)) {
return this.editor.commands.insertContent(' ')
}
return false
},
'Shift-Tab': () => {
if (this.editor.isActive(this.name)) {
const { selection } = this.editor.state
const { $from } = selection
const start = $from.start()
const content = $from.parent.textContent
// Find the current line
const beforeCursor = content.slice(0, $from.pos - start - 1)
const lines = beforeCursor.split('\n')
const currentLineIndex = lines.length - 1
const currentLine = lines[currentLineIndex]
// Check if line starts with spaces that can be removed
if (currentLine.startsWith(' ')) {
const lineStart = start + 1 + beforeCursor.length - currentLine.length
return this.editor.commands.deleteRange({
from: lineStart,
to: lineStart + 2
})
}
}
return false
}
}
},
addProseMirrorPlugins() {
const shikiPlugin = ShikiPlugin({
name: this.name,
defaultLanguage: this.options.defaultLanguage,
theme: this.options.theme
})
const codeBlockEventPlugin = new Plugin({
key: new PluginKey('codeBlockEvents'),
props: {
handleKeyDown: (view, event) => {
const { selection } = view.state
const { $from } = selection
// Check if we're inside a code block and handle Enter key
if ($from.parent.type.name === this.name && event.key === 'Enter') {
const content = $from.parent.textContent
const beforeCursor = content.slice(0, $from.pos - $from.start() - 1)
const lines = beforeCursor.split('\n')
const currentLine = lines[lines.length - 1]
// Get indentation from current line
const indent = currentLine.match(/^\s*/)?.[0] || ''
// Insert newline with same indentation
const tr = view.state.tr.insertText('\n' + indent, selection.from, selection.to)
view.dispatch(tr)
return true
}
return false
}
}
})
return [...(this.parent?.() || []), shikiPlugin, codeBlockEventPlugin]
},
addAttributes() {
return {
...this.parent?.(),
theme: {
// 默认沿用扩展级别的 theme
default: this.options.theme,
parseHTML: (element) => element.getAttribute('data-theme'),
renderHTML: (attrs) => (attrs.theme ? { 'data-theme': attrs.theme } : {})
}
}
}
})
export default CodeBlockShiki
@@ -0,0 +1,5 @@
import { CodeBlockShiki } from './code-block-shiki.js'
export * from './code-block-shiki'
export default CodeBlockShiki
@@ -0,0 +1,248 @@
import { loggerService } from '@logger'
// Cache highlighter instance once initialized so that decoration computation can run synchronously.
import type { HighlighterGeneric } from 'shiki/core'
let cachedHighlighter: HighlighterGeneric<any, any> | null = null
import { getHighlighter, loadLanguageIfNeeded, loadThemeIfNeeded } from '@renderer/utils/shiki'
import { findChildren } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey, PluginView } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
const logger = loggerService.withContext('RichEditor:CodeBlockShiki')
// Languages that should skip syntax highlighting entirely
const SKIP_HIGHLIGHTING_LANGUAGES = new Set(['text', 'plain', 'plaintext', 'txt', '', null, undefined])
function getDecorations({
doc,
name,
defaultLanguage,
theme = 'one-light'
}: {
doc: ProsemirrorNode
name: string
defaultLanguage: string | null | undefined
theme?: string
}) {
const highlighter = cachedHighlighter
if (!highlighter) {
return DecorationSet.empty
}
const decorations: Decoration[] = []
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
let from = block.pos + 1
const language = block.node.attrs.language || defaultLanguage || 'text'
const code = block.node.textContent
// Skip completely empty code blocks (no content at all)
if (!code) return
// Skip highlighting for plain text languages
if (SKIP_HIGHLIGHTING_LANGUAGES.has(language)) {
return
}
try {
const loadedLanguages = highlighter.getLoadedLanguages()
if (!loadedLanguages.includes(language)) {
return
}
const tokens = highlighter.codeToTokens(code, {
lang: language,
theme
})
for (const line of tokens.tokens) {
for (const token of line) {
const to = from + token.content.length
if (token.color) {
decorations.push(
Decoration.inline(from, to, {
style: `color: ${token.color}`
})
)
}
from = to
}
// account for the line break character ("\n") that follows each line
from += 1
}
} catch (error) {
logger.warn('Failed to highlight code block:', error as Error)
}
})
return DecorationSet.create(doc, decorations)
}
export function ShikiPlugin({
name,
defaultLanguage,
theme
}: {
name: string
defaultLanguage: string | null | undefined
theme?: string
}) {
const shikiPlugin: Plugin<any> = new Plugin({
key: new PluginKey('shiki'),
state: {
init: (_, { doc }) => {
return getDecorations({
doc,
name,
defaultLanguage,
theme
})
},
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name
const newNodeName = newState.selection.$head.parent.type.name
const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name)
const newNodes = findChildren(newState.doc, (node) => node.type.name === name)
const didChangeSomeCodeBlock =
transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
([oldNodeName, newNodeName].includes(name) ||
// OR transaction adds/removes named node,
newNodes.length !== oldNodes.length ||
// OR transaction has changes that completely encapsulate a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some((step) => {
// @ts-ignore: ProseMirror step types are complex to type properly
return (
// @ts-ignore: ProseMirror step types are complex to type properly
step.from !== undefined &&
// @ts-ignore: ProseMirror step types are complex to type properly
step.to !== undefined &&
oldNodes.some((node) => {
// @ts-ignore: ProseMirror step types are complex to type properly
return (
// @ts-ignore: ProseMirror step types are complex to type properly
node.pos >= step.from &&
// @ts-ignore: ProseMirror step types are complex to type properly
node.pos + node.node.nodeSize <= step.to
)
})
)
}))
if (didChangeSomeCodeBlock || transaction.getMeta('shikiHighlighterReady')) {
return getDecorations({
doc: transaction.doc,
name,
defaultLanguage,
theme
})
}
return decorationSet.map(transaction.mapping, transaction.doc)
}
},
view: (view) => {
class ShikiPluginView implements PluginView {
private highlighter: HighlighterGeneric<any, any> | null = null
constructor() {
this.initDecorations()
}
update() {
this.checkUndecoratedBlocks()
}
destroy() {
this.highlighter = null
cachedHighlighter = null
}
async initDecorations() {
this.highlighter = await getHighlighter()
cachedHighlighter = this.highlighter
const tr = view.state.tr.setMeta('shikiHighlighterReady', true)
view.dispatch(tr)
}
async checkUndecoratedBlocks() {
// If highlighter is not yet initialized, defer processing until it becomes available.
try {
if (!this.highlighter) {
return
}
const codeBlocks = findChildren(view.state.doc, (node) => node.type.name === name)
// Only load themes or languages that the highlighter has not seen yet.
const tasks: Promise<void>[] = []
let didLoadSomething = false
for (const block of codeBlocks) {
// Skip completely empty code blocks in loading check too
if (!block.node.textContent) continue
const { theme: blockTheme, language: blockLanguage } = block.node.attrs
// Skip loading for plain text languages
if (SKIP_HIGHLIGHTING_LANGUAGES.has(blockLanguage)) {
continue
}
if (blockTheme && !this.highlighter.getLoadedThemes().includes(blockTheme)) {
tasks.push(
loadThemeIfNeeded(this.highlighter, blockTheme).then((resolvedTheme) => {
// If a fallback occurred (e.g., to 'one-light'), avoid repeatedly trying the unsupported theme
if (resolvedTheme == blockTheme) {
didLoadSomething = true
}
})
)
}
if (blockLanguage && !this.highlighter.getLoadedLanguages().includes(blockLanguage)) {
tasks.push(
loadLanguageIfNeeded(this.highlighter, blockLanguage).then((resolvedLanguage) => {
// If fallback language differs from requested, mark requested to skip future attempts
if (resolvedLanguage == blockLanguage) {
didLoadSomething = true
} else {
SKIP_HIGHLIGHTING_LANGUAGES.add(blockLanguage)
}
})
)
}
}
await Promise.all(tasks)
if (didLoadSomething) {
const tr = view.state.tr.setMeta('shikiHighlighterReady', true)
view.dispatch(tr)
}
} catch (error) {
logger.error('Error in checkUndecoratedBlocks:', error as Error)
}
}
}
return new ShikiPluginView()
},
props: {
decorations(state) {
return shikiPlugin.getState(state)
}
}
})
return shikiPlugin
}
@@ -0,0 +1,461 @@
// import type { Editor } from '@tiptap/core'
// import type { Node } from '@tiptap/pm/model'
// import { allActions } from '../components/dragContextMenu/actions'
// // 全局状态存储
// let currentEditor: Editor | null = null
// let currentMenuElement: HTMLElement | null = null
// let isMenuVisible = false
/**
*
*/
// function createContextMenu(editor: Editor, node: Node, position: number): HTMLElement {
// const menu = document.createElement('div')
// menu.className = 'drag-context-menu'
// menu.style.cssText = `
// position: fixed;
// background: var(--color-bg-base);
// border: 1px solid var(--color-border);
// border-radius: 8px;
// box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.04);
// padding: 8px 0;
// min-width: 240px;
// max-width: 320px;
// z-index: 2000;
// opacity: 0;
// transform: translateY(-8px);
// transition: all 0.2s ease;
// font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
// `
// // 获取适用于当前节点的操作
// const availableActions = allActions.filter((action) => action.isEnabled(editor, node, position))
// logger.debug('Available actions', {
// total: allActions.length,
// available: availableActions.length,
// nodeType: node.type.name,
// actions: availableActions.map((a) => a.id)
// })
// // 按组分类操作 - 重新组织为符合参考图的结构
// const actionGroups = {
// format: availableActions.filter((a) => a.group === 'format'),
// turnInto: availableActions.filter((a) => a.group === 'transform'),
// actions: availableActions.filter((a) => a.group === 'block' || a.group === 'insert')
// }
// // 渲染操作组 - 使用参考图中的标签
// const groupLabels = {
// format: '', // 格式化组不显示标题
// turnInto: 'Turn Into',
// actions: '' // actions 组不显示标题,直接显示操作
// }
// Object.entries(actionGroups).forEach(([groupKey, actions]) => {
// if (actions.length === 0) return
// const group = groupKey as keyof typeof actionGroups
// // 组标题和分隔线
// if (menu.children.length > 0) {
// const divider = document.createElement('div')
// divider.style.cssText = 'height: 1px; background: var(--color-border-secondary); margin: 8px 0;'
// menu.appendChild(divider)
// }
// // 只为有标题的组显示标题
// if (groupLabels[group]) {
// const groupTitle = document.createElement('div')
// groupTitle.textContent = groupLabels[group]
// groupTitle.style.cssText = `
// padding: 8px 16px 4px;
// font-size: 13px;
// font-weight: 500;
// color: var(--color-text-2);
// margin-bottom: 4px;
// `
// menu.appendChild(groupTitle)
// }
// // 操作项
// actions.forEach((action) => {
// const item = document.createElement('button')
// item.className = 'menu-item'
// item.style.cssText = `
// width: 100%;
// display: flex;
// align-items: center;
// padding: 10px 16px;
// border: none;
// background: transparent;
// color: ${action.danger ? 'var(--color-error)' : 'var(--color-text)'};
// font-size: 14px;
// text-align: left;
// cursor: pointer;
// transition: background-color 0.15s ease;
// gap: 12px;
// border-radius: 6px;
// margin: 0 4px;
// `
// // 图标映射
// const getIconSvg = (actionId: string) => {
// const iconMap: Record<string, string> = {
// 'format-color':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="11.5" r=".5"/><circle cx="12.5" cy="13.5" r=".5"/><circle cx="13.5" cy="17.5" r=".5"/><circle cx="10.5" cy="16.5" r=".5"/><circle cx="15.5" cy="14.5" r=".5"/><circle cx="9.5" cy="12.5" r=".5"/><circle cx="7.5" cy="15.5" r=".5"/><circle cx="11.5" cy="18.5" r=".5"/><circle cx="14.5" cy="20.5" r=".5"/></svg>',
// 'format-reset':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
// 'transform-heading-1':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-heading-2':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-heading-3':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-paragraph':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,7 4,4 20,4 20,7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>',
// 'transform-bullet-list':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
// 'transform-ordered-list':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>',
// 'transform-blockquote':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>',
// 'transform-code-block':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16,18 22,12 16,6"/><polyline points="8,6 2,12 8,18"/></svg>',
// 'block-copy':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
// 'block-duplicate':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M16 12l-4 4-4-4"/></svg>',
// 'block-delete':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'
// }
// return iconMap[actionId] || ''
// }
// // 图标、标签和右箭头(Turn Into 组需要箭头)
// const showArrow = group === 'turnInto'
// const iconSvg = getIconSvg(action.id)
// const content = `
// ${iconSvg ? '<span class="menu-icon" style="width: 20px; height: 20px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;">' + iconSvg + '</span>' : ''}
// <span style="flex: 1; font-weight: 400;">${action.label}</span>
// ${showArrow ? '<span style="width: 16px; height: 16px; flex-shrink: 0; opacity: 0.5;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg></span>' : ''}
// `
// item.innerHTML = content
// // 悬停效果
// item.addEventListener('mouseenter', () => {
// item.style.background = action.danger ? 'var(--color-error-bg)' : 'var(--color-hover)'
// })
// item.addEventListener('mouseleave', () => {
// item.style.background = 'transparent'
// })
// // 点击处理
// item.addEventListener('click', (e) => {
// e.preventDefault()
// e.stopPropagation()
// try {
// action.execute(editor, node, position)
// hideContextMenu()
// logger.debug('Action executed', { actionId: action.id })
// } catch (error) {
// logger.error('Failed to execute action', error as Error)
// hideContextMenu()
// }
// })
// menu.appendChild(item)
// })
// })
// // 如果没有任何操作,添加一个提示
// if (menu.children.length === 0) {
// const emptyItem = document.createElement('div')
// emptyItem.textContent = 'No actions available'
// emptyItem.style.cssText = `
// padding: 12px 16px;
// color: var(--color-text-3);
// font-style: italic;
// text-align: center;
// `
// menu.appendChild(emptyItem)
// }
// logger.debug('Context menu created', {
// childrenCount: menu.children.length,
// hasActions: availableActions.length > 0
// })
// return menu
// }
/**
*
*/
// function showContextMenu(editor: Editor, node: Node, position: number, clientX: number, clientY: number) {
// logger.debug('showContextMenu called', {
// nodeType: node.type.name,
// position,
// clientX,
// clientY
// })
// hideContextMenu() // 先隐藏现有菜单
// currentEditor = editor
// currentMenuElement = createContextMenu(editor, node, position)
// // 添加到 body
// document.body.appendChild(currentMenuElement)
// // 计算位置
// const rect = currentMenuElement.getBoundingClientRect()
// let x = clientX + 10
// let y = clientY
// // 边界检测
// if (x + rect.width > window.innerWidth) {
// x = clientX - rect.width - 10
// }
// if (y + rect.height > window.innerHeight) {
// y = window.innerHeight - rect.height - 10
// }
// currentMenuElement.style.left = `${x}px`
// currentMenuElement.style.top = `${y}px`
// // 显示动画
// requestAnimationFrame(() => {
// if (currentMenuElement) {
// currentMenuElement.style.opacity = '1'
// currentMenuElement.style.transform = 'translateY(0)'
// isMenuVisible = true
// }
// })
// // 全局点击关闭
// const handleClickOutside = (e: MouseEvent) => {
// if (currentMenuElement && !currentMenuElement.contains(e.target as HTMLElement)) {
// hideContextMenu()
// }
// }
// // ESC 键关闭
// const handleEscape = (e: KeyboardEvent) => {
// if (e.key === 'Escape') {
// hideContextMenu()
// }
// }
// setTimeout(() => {
// document.addEventListener('click', handleClickOutside)
// document.addEventListener('keydown', handleEscape)
// }, 0)
// }
/**
*
*/
// function hideContextMenu() {
// if (currentMenuElement && isMenuVisible) {
// currentMenuElement.style.opacity = '0'
// currentMenuElement.style.transform = 'translateY(-10px)'
// setTimeout(() => {
// if (currentMenuElement && document.body.contains(currentMenuElement)) {
// document.body.removeChild(currentMenuElement)
// }
// currentMenuElement = null
// currentEditor = null
// isMenuVisible = false
// }, 150)
// // 移除事件监听器
// document.removeEventListener('click', () => {})
// document.removeEventListener('keydown', () => {})
// }
// }
// /**
// * 拖拽上下文菜单扩展
// */
// export const DragContextMenuExtension = DragHandle.extend({
// name: 'dragContextMenu',
// addOptions() {
// return {
// render: () => {
// // 创建拖拽手柄容器
// const wrapper = document.createElement('div')
// wrapper.className = 'drag-handle-wrapper'
// wrapper.style.cssText = `
// display: flex;
// align-items: center;
// gap: 0.25rem;
// opacity: 0;
// transition: opacity 0.15s ease;
// z-index: 10;
// `
// // 加号按钮 - 使用React组件和ProseMirror plugin
// let plusButtonElement: HTMLElement | null = null
// // 拖拽手柄
// const dragHandle = document.createElement('div')
// dragHandle.className = 'drag-handle'
// dragHandle.innerHTML = `
// <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
// <circle cx="9" cy="12" r="1" fill="currentColor"/>
// <circle cx="15" cy="12" r="1" fill="currentColor"/>
// <circle cx="9" cy="6" r="1" fill="currentColor"/>
// <circle cx="15" cy="6" r="1" fill="currentColor"/>
// <circle cx="9" cy="18" r="1" fill="currentColor"/>
// <circle cx="15" cy="18" r="1" fill="currentColor"/>
// </svg>
// `
// dragHandle.style.cssText = `
// display: flex;
// align-items: center;
// justify-content: center;
// width: 1.5rem;
// height: 1.5rem;
// border-radius: 0.25rem;
// background: var(--color-background);
// color: var(--color-text-3);
// cursor: grab;
// transition: background 0.15s ease;
// pointer-events: auto;
// user-select: none;
// `
// // 悬停效果
// const addHoverEffect = (element: HTMLElement) => {
// element.addEventListener('mouseenter', () => {
// element.style.background = 'var(--color-hover)'
// })
// element.addEventListener('mouseleave', () => {
// element.style.background = 'var(--color-background)'
// })
// }
// addHoverEffect(dragHandle)
// // 显示/隐藏逻辑
// const showControls = () => {
// wrapper.style.opacity = '1'
// }
// const hideControls = () => {
// wrapper.style.opacity = '0'
// }
// // 点击事件 - 显示上下文菜单
// // dragHandle.addEventListener('click', (e) => {
// // e.preventDefault()
// // e.stopPropagation()
// // logger.debug('Drag handle clicked', {
// // currentEditor: !!currentEditor,
// // parentElement: !!wrapper.parentElement,
// // grandParent: !!wrapper.parentElement?.parentElement
// // })
// // // 获取与此拖拽手柄相关的节点
// // if (currentEditor && wrapper.parentElement) {
// // try {
// // // 找到关联的块级元素
// // const blockElement = wrapper.parentElement.parentElement
// // if (blockElement) {
// // logger.debug('Found block element', {
// // tagName: blockElement.tagName,
// // className: blockElement.className
// // })
// // const pos = currentEditor.view.posAtDOM(blockElement, 0)
// // logger.debug('Position from DOM', { pos })
// // // 检查位置是否有效
// // if (pos < 0) {
// // logger.warn('Invalid position from DOM, using selection position')
// // // 使用当前选择位置作为后备
// // const selectionPos = currentEditor.state.selection.from
// // const resolvedPos = currentEditor.state.doc.resolve(selectionPos)
// // const node = resolvedPos.parent
// // showContextMenu(currentEditor, node, selectionPos, e.clientX, e.clientY)
// // return
// // }
// // const resolvedPos = currentEditor.state.doc.resolve(pos)
// // logger.debug('Position info', {
// // pos,
// // depth: resolvedPos.depth,
// // parentType: resolvedPos.parent.type.name
// // })
// // // 找到块级节点
// // let node = resolvedPos.parent
// // let nodePos = pos
// // for (let depth = resolvedPos.depth; depth >= 0; depth--) {
// // const nodeAtDepth = resolvedPos.node(depth)
// // if (nodeAtDepth.isBlock && depth > 0) {
// // node = nodeAtDepth
// // nodePos = resolvedPos.start(depth)
// // break
// // }
// // }
// // logger.debug('Showing context menu', {
// // nodeType: node.type.name,
// // nodePos,
// // clientX: e.clientX,
// // clientY: e.clientY
// // })
// // showContextMenu(currentEditor, node, nodePos, e.clientX, e.clientY)
// // } else {
// // logger.warn('Block element not found')
// // }
// // } catch (error) {
// // logger.error('Failed to show context menu', error as Error)
// // }
// // } else {
// // logger.warn('Missing editor or parent element', {
// // hasEditor: !!currentEditor,
// // hasParent: !!wrapper.parentElement
// // })
// // }
// // })
// // 设置块级悬停监听器
// setTimeout(() => {
// const blockElement = wrapper.parentElement?.parentElement
// if (blockElement) {
// blockElement.addEventListener('mouseenter', showControls)
// blockElement.addEventListener('mouseleave', () => {
// hideControls()
// cleanup()
// })
// }
// }, 0)
// // 只添加 drag handleplus button 会在 showControls 中动态创建
// wrapper.appendChild(dragHandle)
// return wrapper
// }
// // onNodeChange: ({ editor }) => {
// // currentEditor = editor
// // logger.debug('onNodeChange - editor set', { hasEditor: !!editor })
// // }
// }
// }
// })
// export default DragContextMenuExtension
@@ -0,0 +1,124 @@
import { mergeAttributes, Node } from '@tiptap/core'
import Image from '@tiptap/extension-image'
import { ReactNodeViewRenderer } from '@tiptap/react'
import ImagePlaceholderNodeView from '../components/placeholder/ImagePlaceholderNodeView'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imagePlaceholder: {
insertImagePlaceholder: () => ReturnType
}
}
}
// Enhanced Image extension that emits events for image upload
export const EnhancedImage = Image.extend({
addOptions() {
return {
...this.parent?.(),
allowBase64: true,
HTMLAttributes: {
class: 'rich-editor-image'
}
}
},
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
parseHTML: (element) => element.getAttribute('src'),
renderHTML: (attributes) => {
if (!attributes.src) {
return {}
}
return {
src: attributes.src
}
}
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: null
},
height: {
default: null
}
}
},
addCommands() {
return {
...this.parent?.(),
insertImagePlaceholder:
() =>
({ commands }) => {
return commands.insertContent({
type: 'imagePlaceholder',
attrs: {}
})
}
}
},
addExtensions() {
const base = (this.parent?.() as any[]) || []
return [
...base,
Node.create({
name: 'imagePlaceholder',
group: 'block',
content: 'block+',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {}
}
},
parseHTML() {
return [
{
tag: 'div[data-type="image-placeholder"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': 'image-placeholder'
})
]
},
addNodeView() {
return ReactNodeViewRenderer(ImagePlaceholderNodeView)
},
addCommands() {
return {
insertImagePlaceholder:
() =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: {}
})
}
}
}
})
]
}
})
@@ -0,0 +1,405 @@
import { mergeAttributes } from '@tiptap/core'
import Link from '@tiptap/extension-link'
import type { MarkType, ResolvedPos } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
// Plugin to handle hover interactions
const linkHoverPlugin = new PluginKey('linkHover')
// Plugin to auto-update empty href links
const linkAutoUpdatePlugin = new PluginKey('linkAutoUpdate')
// Helper function to get the range of a mark at a given position
function getMarkRange($pos: ResolvedPos, markType: MarkType, attrs?: any): { from: number; to: number } | null {
const { doc } = $pos
let foundRange: { from: number; to: number } | null = null
doc.descendants((node, pos) => {
if (node.isText && node.marks) {
for (const mark of node.marks) {
if (mark.type === markType && (!attrs || Object.keys(attrs).every((key) => mark.attrs[key] === attrs[key]))) {
const from = pos
const to = pos + node.nodeSize
// Check if our target position is within this range
if ($pos.pos >= from && $pos.pos < to) {
foundRange = { from, to }
return false // Stop searching
}
}
}
}
return true // Continue searching
})
return foundRange
}
interface LinkHoverPluginOptions {
onLinkHover?: (
attrs: { href: string; text: string; title?: string },
position: DOMRect,
element: HTMLElement,
linkRange?: { from: number; to: number }
) => void
onLinkHoverEnd?: () => void
editable?: boolean
hoverDelay?: number
}
const createLinkHoverPlugin = (options: LinkHoverPluginOptions) => {
let hoverTimeout: NodeJS.Timeout | null = null
const hoverDelay = options.hoverDelay ?? 500 // Default 500ms delay
const calculateSmartPosition = (rect: DOMRect): DOMRect => {
const viewportHeight = window.innerHeight
const editorOffset = 200 // Approximate height of link editor popup
const isNearBottom = rect.bottom > viewportHeight - editorOffset
if (isNearBottom) {
const adjustedRect = new DOMRect(
rect.left, // x
rect.top - editorOffset, // y (position above the link)
rect.width, // width
rect.height // height
)
return adjustedRect
}
return rect
}
return new Plugin({
key: linkHoverPlugin,
props: {
handleDOMEvents: {
mouseover: (view, event) => {
// Don't process hover if not editable
if (!options.editable) return false
const target = event.target as HTMLElement
const linkElement = target.closest('a[href]') as HTMLAnchorElement
if (linkElement) {
// Clear any existing timeout
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
// Set up delayed hover
hoverTimeout = setTimeout(() => {
const href = linkElement.getAttribute('href') || ''
const text = linkElement.textContent || ''
const title = linkElement.getAttribute('title') || ''
// Use ProseMirror's built-in method to get position from DOM
let linkRange: { from: number; to: number } | undefined
let linkRect = linkElement.getBoundingClientRect()
try {
// Get the mouse position relative to the editor
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (coords) {
const pos = coords.pos
const $pos = view.state.doc.resolve(pos)
// Find the link mark at this position
const linkMark = $pos
.marks()
.find(
(mark) =>
(mark.type.name === 'enhancedLink' || mark.type.name === 'link') && mark.attrs.href === href
)
if (linkMark) {
// Use ProseMirror's mark range finding
const range = getMarkRange($pos, linkMark.type, linkMark.attrs)
if (range) {
linkRange = range
// Check if this is near the end of the document
const docSize = view.state.doc.content.size
const isNearDocEnd = range.to >= docSize - 10 // Within 10 characters of doc end
if (isNearDocEnd) {
// For links near document end, prefer DOM positioning over ProseMirror coords
// as ProseMirror coords can be inaccurate at document boundaries
const newLinkRect = linkElement.getBoundingClientRect()
linkRect = newLinkRect
} else {
// Calculate the position based on the entire link range for other cases
try {
const startCoords = view.coordsAtPos(range.from)
const endCoords = view.coordsAtPos(range.to)
// Use the full range for positioning
linkRect = new DOMRect(
startCoords.left,
startCoords.top,
endCoords.right - startCoords.left,
Math.max(endCoords.bottom - startCoords.top, 16)
)
} catch (coordsError) {
linkRect = linkElement.getBoundingClientRect()
}
}
}
}
}
// Fallback: Use DOM positioning
if (!linkRange) {
const startPos = view.posAtDOM(linkElement, 0)
if (startPos >= 0) {
const $pos = view.state.doc.resolve(startPos)
const linkMark = $pos
.marks()
.find(
(mark) =>
(mark.type.name === 'enhancedLink' || mark.type.name === 'link') && mark.attrs.href === href
)
if (linkMark) {
const range = getMarkRange($pos, linkMark.type, linkMark.attrs)
if (range) {
linkRange = range
}
}
}
}
// Final fallback
if (!linkRange && text) {
const pos = view.posAtDOM(linkElement, 0)
if (pos >= 0) {
linkRange = { from: pos, to: pos + text.length }
}
}
} catch (e) {
// Ultimate fallback
const pos = view.posAtDOM(linkElement, 0)
if (pos >= 0 && text) {
linkRange = { from: pos, to: pos + text.length }
}
}
const smartRect = calculateSmartPosition(linkRect)
options.onLinkHover?.({ href, text, title }, smartRect, linkElement, linkRange)
hoverTimeout = null
}, hoverDelay)
}
return false
},
mouseout: (_, event) => {
const target = event.target as HTMLElement
const linkElement = target.closest('a[href]')
if (linkElement) {
// Clear hover timeout if leaving the link
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = null
}
// Check if we're still within the link or moving to the popup
const relatedTarget = event.relatedTarget as HTMLElement
const isMovingToPopup = relatedTarget?.closest('[data-link-editor]')
const isStillInLink = relatedTarget?.closest('a[href]') === linkElement
if (!isMovingToPopup && !isStillInLink) {
options.onLinkHoverEnd?.()
}
}
return false
}
}
}
})
}
// Plugin to automatically update empty href with text content
const createLinkAutoUpdatePlugin = () => {
return new Plugin({
key: linkAutoUpdatePlugin,
appendTransaction: (transactions, _oldState, newState) => {
let tr = newState.tr
let hasUpdates = false
// Only process if there were actual document changes
const hasDocChanges = transactions.some((transaction) => transaction.docChanged)
if (!hasDocChanges) return null
newState.doc.descendants((node, pos) => {
if (node.isText && node.marks) {
node.marks.forEach((mark) => {
if (mark.type.name === 'enhancedLink') {
const text = node.text || ''
const currentHref = mark.attrs.href || ''
if (text.trim()) {
// Determine what the href should be based on the text
let expectedHref = text.trim()
if (
!expectedHref.startsWith('http://') &&
!expectedHref.startsWith('https://') &&
!expectedHref.startsWith('mailto:') &&
!expectedHref.startsWith('tel:')
) {
// Only add https:// if it looks like a domain (contains a dot)
if (expectedHref.includes('.')) {
expectedHref = `https://${expectedHref}`
}
}
// Only update if the current href doesn't match what it should be
// and if the current href looks like it was auto-generated (either empty or matches a previous text state)
const shouldUpdate =
currentHref !== expectedHref &&
(currentHref === '' || // Empty href
currentHref === text.trim() || // Href matches text without protocol
currentHref === `https://${text.trim()}` || // Href matches text with https
text.trim().startsWith(currentHref.replace(/^https?:\/\//, ''))) // Text is an extension of current href
if (shouldUpdate) {
// Update the mark with the new href
tr = tr.removeMark(pos, pos + node.nodeSize, mark.type)
tr = tr.addMark(pos, pos + node.nodeSize, mark.type.create({ ...mark.attrs, href: expectedHref }))
hasUpdates = true
}
}
}
})
}
})
return hasUpdates ? tr : null
}
})
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
enhancedLink: {
setEnhancedLink: (attributes: { href: string; title?: string }) => ReturnType
toggleEnhancedLink: (attributes: { href: string; title?: string }) => ReturnType
unsetEnhancedLink: () => ReturnType
updateLinkText: (text: string) => ReturnType
}
}
}
export interface EnhancedLinkOptions {
onLinkHover?: (
attrs: { href: string; text: string; title?: string },
position: DOMRect,
element: HTMLElement,
linkRange?: { from: number; to: number }
) => void
onLinkHoverEnd?: () => void
editable?: boolean
hoverDelay?: number
}
export const EnhancedLink = Link.extend<EnhancedLinkOptions>({
name: 'enhancedLink',
addOptions() {
return {
...this.parent?.(),
protocols: ['http', 'https', 'mailto', 'tel'],
openOnClick: true,
onLinkHover: undefined,
onLinkHoverEnd: undefined,
editable: true,
hoverDelay: 500
}
},
addCommands() {
return {
...this.parent?.(),
setEnhancedLink:
(attributes) =>
({ commands }) => {
return commands.setLink(attributes)
},
toggleEnhancedLink:
(attributes) =>
({ commands }) => {
return commands.toggleLink(attributes)
},
unsetEnhancedLink:
() =>
({ commands }) => {
return commands.unsetLink()
},
updateLinkText:
(text: string) =>
({ tr, state, dispatch }) => {
const { selection } = state
const { from, to } = selection
if (dispatch) {
tr.insertText(text, from, to)
}
return true
}
}
},
addProseMirrorPlugins() {
return [
...(this.parent?.() || []),
createLinkHoverPlugin({
onLinkHover: this.options.onLinkHover,
onLinkHoverEnd: this.options.onLinkHoverEnd,
editable: this.options.editable,
hoverDelay: this.options.hoverDelay
}),
createLinkAutoUpdatePlugin()
]
},
renderHTML({ HTMLAttributes }) {
return [
'a',
mergeAttributes(HTMLAttributes, {
class: 'rich-editor-link'
}),
0
]
},
addAttributes() {
return {
href: {
default: null,
parseHTML: (element) => element.getAttribute('href'),
renderHTML: (attributes) => {
if (!attributes.href) {
return {}
}
return {
href: attributes.href
}
}
},
title: {
default: null,
parseHTML: (element) => element.getAttribute('title'),
renderHTML: (attributes) => {
if (!attributes.title) {
return {}
}
return {
title: attributes.title
}
}
}
}
}
})
@@ -0,0 +1,123 @@
import { Extension, InputRule, mergeAttributes, Node } from '@tiptap/core'
import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
import { ReactNodeViewRenderer } from '@tiptap/react'
import MathPlaceholderNodeView from '../components/placeholder/MathPlaceholderNodeView'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
enhancedMath: {
insertMathPlaceholder: (options?: { mathType?: 'block' | 'inline' }) => ReturnType
}
}
}
export const EnhancedMath = Extension.create({
name: 'enhancedMath',
addOptions() {
return {
inlineOptions: undefined,
blockOptions: undefined,
katexOptions: undefined
}
},
addCommands() {
return {
insertMathPlaceholder:
(options: { mathType?: 'block' | 'inline' } = {}) =>
({ commands }) => {
return commands.insertContent({
type: 'mathPlaceholder',
attrs: {
mathType: options.mathType || 'block'
}
})
}
}
},
addExtensions() {
return [
BlockMath.extend({
addInputRules() {
return [
new InputRule({
find: /^\$\$([^$]+)\$\$$/,
handler: ({ state, range, match }) => {
const [, latex] = match
const { tr } = state
const start = range.from
const end = range.to
tr.replaceWith(start, end, this.type.create({ latex }))
}
})
]
}
}).configure({ ...this.options.blockOptions, katexOptions: this.options.katexOptions }),
InlineMath.extend({
addInputRules() {
return [
new InputRule({
find: /(^|[^$])(\$([^$\n]+?)\$)(?!\$)/,
handler: ({ state, range, match }) => {
const latex = match[3]
const { tr } = state
const start = range.from
const end = range.to
tr.replaceWith(start, end, this.type.create({ latex }))
}
})
]
}
}).configure({ ...this.options.inlineOptions, katexOptions: this.options.katexOptions }),
Node.create({
name: 'mathPlaceholder',
group: 'block',
atom: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {}
}
},
addAttributes() {
return {
mathType: {
default: 'block',
parseHTML: (element) => element.getAttribute('data-math-type'),
renderHTML: (attributes) => ({
'data-math-type': attributes.mathType
})
}
}
},
parseHTML() {
return [
{
tag: 'div[data-type="math-placeholder"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': 'math-placeholder'
})
]
},
addNodeView() {
return ReactNodeViewRenderer(MathPlaceholderNodeView)
}
})
]
}
})
@@ -0,0 +1,83 @@
import { Editor, Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Node } from 'prosemirror-model'
export interface PlaceholderOptions {
placeholder: ((props: { editor: Editor; node: Node; pos: number; hasAnchor: boolean }) => string) | string | undefined
showOnlyWhenEditable: boolean
showOnlyCurrent: boolean
includeChildren: boolean
}
export const Placeholder = Extension.create<PlaceholderOptions>({
name: 'placeholder',
addOptions() {
return {
placeholder: 'Write something...',
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false
}
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('placeholder'),
props: {
decorations: ({ doc, selection }) => {
const active = this.editor.isEditable
const { anchor } = selection
const decorations: Decoration[] = []
if (!active && this.options.showOnlyWhenEditable) {
return DecorationSet.empty
}
// Check if we're in the middle of a drag operation
const isDragging = this.editor.view.dragging
doc.descendants((node, pos) => {
const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize
const isEmpty = !node.isLeaf && !node.childCount
// Skip codeBlock nodes as they have their own content management
if (node.type.name === 'codeBlock' || isDragging) {
return false
}
// Only show placeholder on current node (where cursor is) or all nodes based on showOnlyCurrent
if ((hasAnchor || !this.options.showOnlyCurrent) && isEmpty) {
const classes = ['placeholder']
if (hasAnchor) {
classes.push('has-focus')
}
const decoration = Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' '),
'data-placeholder':
typeof this.options.placeholder === 'function'
? this.options.placeholder({
editor: this.editor,
node,
pos,
hasAnchor
})
: this.options.placeholder
})
decorations.push(decoration)
}
return this.options.includeChildren
})
return DecorationSet.create(doc, decorations)
}
}
})
]
}
})
@@ -0,0 +1,94 @@
import { type ComputePositionConfig } from '@floating-ui/dom'
import { Editor, Extension } from '@tiptap/core'
import { Node } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { PlusButtonPlugin } from '../plugins/plusButtonPlugin'
export const defaultComputePositionConfig: ComputePositionConfig = {
placement: 'left-start',
strategy: 'absolute'
}
export interface PlusButtonOptions {
/**
* Renders an element that is positioned with the floating-ui/dom package
*/
render(): HTMLElement
/**
* Configuration for position computation of the drag handle
* using the floating-ui/dom package
*/
computePositionConfig?: ComputePositionConfig
/**
* Returns a node or null when a node is hovered over
*/
onNodeChange?: (options: { node: Node | null; editor: Editor; pos: number }) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
plusButton: {
/**
* Insert a paragraph after the current block
*/
insertParagraphAfter: () => ReturnType
}
}
}
export const PlusButton = Extension.create<PlusButtonOptions>({
name: 'plusButton',
addOptions() {
return {
render() {
const element = document.createElement('div')
return element
}
}
},
addProseMirrorPlugins() {
const element = this.options.render()
return [
PlusButtonPlugin({
computePositionConfig: { ...defaultComputePositionConfig, ...this.options.computePositionConfig },
element,
editor: this.editor,
onNodeChange: this.options.onNodeChange
}).plugin
]
},
addCommands() {
return {
insertParagraphAfter:
() =>
({ state, dispatch, view }) => {
const { $from } = state.selection
const { schema } = state
const endOfBlock = $from.end($from.depth)
const paragraphNode = schema.nodes.paragraph
if (!paragraphNode) return false
let tr = state.tr.insert(endOfBlock, paragraphNode.create())
const insidePos = endOfBlock + 1
tr = tr.setSelection(TextSelection.create(tr.doc, insidePos))
tr = tr.scrollIntoView()
if (dispatch) dispatch(tr)
view?.focus()
return true
}
}
}
})
export default PlusButton
@@ -0,0 +1,54 @@
// ported from https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-drag-handle/src/helpers/findNextElementFromCursor.ts
import type { Editor } from '@tiptap/core'
import type { Node } from '@tiptap/pm/model'
export type FindElementNextToCoords = {
x: number
y: number
direction?: 'left' | 'right'
editor: Editor
}
export const findElementNextToCoords = (options: FindElementNextToCoords) => {
const { x, y, direction, editor } = options
let resultElement: HTMLElement | null = null
let resultNode: Node | null = null
let pos: number | null = null
let currentX = x
while (resultNode === null && currentX < window.innerWidth && currentX > 0) {
const allElements = document.elementsFromPoint(currentX, y)
const prosemirrorIndex = allElements.findIndex((element) => element.classList.contains('ProseMirror'))
const filteredElements = allElements.slice(0, prosemirrorIndex)
if (filteredElements.length > 0) {
const target = filteredElements[0]
resultElement = target as HTMLElement
pos = editor.view.posAtDOM(target, 0)
if (pos >= 0) {
resultNode = editor.state.doc.nodeAt(Math.max(pos - 1, 0))
if (resultNode?.isText) {
resultNode = editor.state.doc.nodeAt(Math.max(pos - 1, 0))
}
if (!resultNode) {
resultNode = editor.state.doc.nodeAt(Math.max(pos, 0))
}
break
}
}
if (direction === 'left') {
currentX -= 1
} else {
currentX += 1
}
}
return { resultElement, resultNode, pos: pos ?? null }
}

Some files were not shown because too many files have changed in this diff Show More