Merge remote-tracking branch 'origin/main' into feat/aisdk-package
@@ -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
|
||||
|
||||
@@ -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};
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 19 KiB |
|
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';
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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')};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
</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 }}>
|
||||
|
||||
</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 handle,plus 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 }
|
||||
}
|
||||