feat: add notes module (#8871)

* feat: integrate rich text editing

- Replaced the TextEditPopup with RichEditPopup for adding and editing notes, enhancing the editing experience with rich text capabilities.
- Updated note previews to display HTML content appropriately, improving usability and visual representation.
- Added a styled component for note previews to enhance user interaction.

* feat(RichEditor): enhance rich text editing capabilities

- Added new command system for rich text editing, allowing users to execute commands like headings, lists, and formatting.
- Integrated drag handle functionality for better content manipulation within the editor.
- Updated toolbar to include additional formatting options such as strikethrough and code blocks.
- Improved markdown and HTML content handling, enabling seamless conversion and previewing.
- Introduced new utility functions for markdown conversion and sanitization.
- Added tests for command list popover and rich editor functionalities to ensure reliability.

* refactor(RichEditor): remove debug log from command suggestion

* feat(RichEditor): add link and unlink functionality

- Introduced link and unlink commands in the RichEditor toolbar, enhancing text formatting capabilities.
- Updated placeholder text for the RichEditor to provide clearer user guidance.
- Refactored styles and removed unused code to streamline the RichEditor component.
- Added internationalization support for new toolbar items and placeholder text in both English and Chinese.

* wip: custom codeblock

* feat: add new dependencies for markdown processing

- Introduced `he` for HTML entity decoding and `striptags` for stripping HTML tags in markdown conversion.
- Updated `package.json` and `yarn.lock` to include new type definitions and library versions.

* feat(RichEditor): enhance image and math input capabilities

- Added ImageUploader component for embedding images with URL support and drag-and-drop functionality.
- Introduced MathInputDialog for entering LaTeX formulas, allowing real-time updates and integration with the editor.
- Enhanced RichEditor toolbar with new commands for image and math insertion.
- Updated styles for better user experience and accessibility.
- Added internationalization support for new features in multiple languages.

* refactor(CodeBlockView): change export to local variable

- Changed the export of CodeHeader to a local variable within CodeBlockView.
- Removed unused export from code-block-shiki index file.

* feat(RichEditor): enhance command management and toolbar functionality

- Added support for disabling specific commands in the RichEditPopup.
- Implemented dynamic command registration and management in the RichEditor, allowing for initial commands to be registered on mount.
- Updated toolbar to dynamically generate items based on command groups, improving organization and accessibility.
- Introduced new command definitions for text formatting, including bold, italic, underline, and strikethrough, with toolbar visibility options.
- Enhanced command handling capabilities, including the ability to unregister commands and set their availability based on editor context.

* refactor(RichEditPopup): remove translation functionality and related components

- Eliminated translation handling logic, including the translate button and associated state management.
- Cleaned up imports and unused variables to streamline the RichEditPopup component.
- Simplified the content change handling by focusing solely on rich content management.

* feat(ImageUploader): enhance image upload functionality and styling

- Added custom image upload button with improved styling and theme support.
- Refactored image display logic to use a more flexible layout.
- Updated file acceptance criteria to restrict uploads to PNG and JPEG formats.
- Simplified the upload process by preventing default behavior and customizing request handling.
- Improved overall component structure and styling for better user experience.

* feat(AssistantPromptSettings): implement throttled update functionality and enhance UI

- Introduced throttling for the update function to improve performance and reduce unnecessary updates.
- Added a save button to the UI for manual saving of changes, enhancing user experience.
- Refactored the component to streamline the handling of emoji selection and markdown changes.
- Updated layout with Flex component for better alignment of buttons in the settings interface.

* feat(RichEditor): integrate internationalization for placeholder text

- Updated the placeholder property to utilize the i18next translation function, enhancing support for multiple languages.
- Improved user experience by providing localized placeholder text in the RichEditor component.

* fix(styles): update list styles for ordered and unordered lists in richtext.scss

- Removed default list style for ordered lists and added decimal style.
- Added disc style for unordered lists to enhance visual consistency.

* fix(styles): improve table cell background handling in richtext.scss

- Added !important to header background color to ensure consistency.
- Set table cell backgrounds to transparent to prevent inheritance issues during drag operations.
- Updated ProseMirror widget styles to maintain transparency for table cells.
- Enhanced overall table styling to improve user experience.

* fix(styles): update padding and overflow handling in RichEditor

- Increased padding in the tiptap class for improved spacing.
- Modified overflow-x property in EditorContent to allow horizontal scrolling, preventing the drag handle from being cut off.
- Ensured proper positioning and visibility of the drag handle with updated styles.
- Adjusted ProseMirror editor content to maintain drag handle positioning.

* refactor(CodeBlockNodeView, shikijsPlugin): improve language handling for code blocks

- Updated language options to ensure 'text' is always available.
- Introduced a set of languages to skip syntax highlighting, enhancing performance and user experience.
- Simplified logic for checking loaded languages, avoiding unnecessary fallbacks for unsupported languages.

* fix(RichEditor): improve link handling and selection behavior

- Enhanced link insertion logic to ensure the entire paragraph is selected when creating a link.
- Added error handling to toggle link state if selection fails.
- Cleaned up code by moving paragraph text retrieval to the appropriate location for better readability.

* fix(styles): update inline code background and text colors in color.scss

- Changed inline code background color to a solid value for better visibility.
- Updated inline code text color to use RGB format for consistency.

* refactor(RichEditor): simplify editable state management and improve UI interactions

- Removed the disabled prop from RichEditor, simplifying the editable state logic.
- Updated the useRichEditor hook to directly manage the editable state based on the editable prop.
- Enhanced the AssistantPromptSettings component by streamlining the RichEditor rendering logic and improving the save button functionality.

* chore(tests): move useRichEditor test suite

* refactor(RichEditor): enhance command handling and UI responsiveness

- Removed the 'unlink' command from the command list and toolbar for a cleaner interface.
- Improved command filtering logic by removing the maxResults limit.
- Updated command positioning to use fixed strategy with enhanced middleware for better responsiveness.
- Integrated a dynamic virtual list for command suggestions, improving performance and user experience.
- Added internationalization support for 'undo' and 'redo' commands in multiple languages.

* fix(styles): adjust strong tag styling in richtext.scss

- Updated the strong tag styling to apply font-weight to all child elements, ensuring consistent text formatting within rich text content.

* fix(RichEditor): prevent codeBlock nodes from being skipped during drag operations

- Updated the placeholder extension to check for drag operations, ensuring that codeBlock nodes are not skipped when dragging is in progress. This improves the user experience by maintaining expected behavior during content manipulation.

* feat(markdown): integrate turndown-plugin-gfm for enhanced markdown support

- Added turndown-plugin-gfm to enable support for tables and additional markdown features.
- Updated the markdown converter to include new rules for underlining and table elements.
- Enhanced HTML sanitization to allow table-related attributes, improving markdown conversion accuracy.

* feat(markdown): add task list support and enhance markdown conversion

- Integrated @rxliuli/markdown-it-task-lists for task list functionality in markdown.
- Updated markdown converter to handle task list syntax, converting it to appropriate HTML structure.
- Enhanced styles for task lists in richtext.scss to improve visual representation.
- Modified useRichEditor to include task list extensions, ensuring proper functionality within the editor.

* fix(styles): update table header styling in richtext.scss

- Modified table header styling to apply background color and font weight to all child elements, ensuring consistent formatting within tables.

* fix(styles): enhance strong tag styling in richtext.scss

- Added styling for the strong tag to ensure consistent font-weight application across all child elements, improving text formatting in rich text content.

* refactor(markdown): remove @rxliuli/markdown-it-task-lists and implement custom task list plugin

- Removed dependency on @rxliuli/markdown-it-task-lists and integrated a custom task list plugin for markdown-it.
- Enhanced markdown conversion to support task lists with improved HTML structure and sanitization.
- Updated tests to validate task list functionality and ensure proper conversion between markdown and HTML.

* refactor(tests): remove redundant task item label test from markdownConverter tests

- Deleted the test case that checked for the absence of label wrapping around task items, as it is no longer relevant with the updated markdown conversion logic.
- Ensured that existing tests continue to validate the preservation of labels in sanitized HTML for task lists.

* feat(extension-table-plus): add new table extension for Tiptap

- Introduced the @cherrystudio/extension-table-plus package, providing a comprehensive table extension for Tiptap.
- Implemented core functionalities including table, table cell, header, and row management.
- Enhanced the editor with a TableKit for easier table manipulation and integration.
- Updated styles for improved table presentation and interaction within the rich text editor.
- Modified useRichEditor to utilize the new TableKit, ensuring seamless integration with existing features.

* chore(package): remove @tiptap/extension-table dependency

- Deleted the @tiptap/extension-table from package.json and yarn.lock as it is no longer needed.
- Updated dependency management to streamline the project and reduce unnecessary packages.

* chore(package): update package.json for @cherrystudio/extension-table-plus

- Changed the description to reflect the forked nature of the extension.
- Downgraded the version to 3.0.10 to align with the new release strategy.
- Updated the homepage URL to point to the new project site.
- Modified the repository URL to reflect the new GitHub location and directory structure.

* chore(package): update @cherrystudio/extension-table-plus version in package.json

- Changed the version of @cherrystudio/extension-table-plus from workspace:* to ^3.0.10 to align with the new release strategy.

* chore(yarn): update @cherrystudio/extension-table-plus version in yarn.lock

- Changed the version of @cherrystudio/extension-table-plus from workspace:* to npm:^3.0.10 to align with the updated package management strategy.

* chore(useRichEditor): clean up comments and improve code clarity

* chore(package): update @cherrystudio/extension-table-plus version to workspace:^ in package.json and yarn.lock

- Changed the version of @cherrystudio/extension-table-plus from ^3.0.10 to workspace:^ to align with the updated package management strategy.

* chore(tsconfig): add path mapping for @cherrystudio/extension-table-plus in tsconfig.web.json

- Updated tsconfig.web.json to include path mapping for the @cherrystudio/extension-table-plus package, enhancing module resolution for TypeScript.

* chore(dependencies): update ESLint and Prettier configurations

- Added ESLint and Prettier as development dependencies in package.json and yarn.lock.
- Updated lint script to format code and fix issues automatically.
- Enhanced type safety by specifying Node type in TableKit extension.

* fix(deleteTableWhenAllCellsSelected): ensure function returns true after cell count check

- Updated the deleteTableWhenAllCellsSelected function to return true after counting selected table cells, improving the logic for table deletion when all cells are selected.

* chore(electron.config): add path mapping for @cherrystudio/extension-table-plus

- Updated electron.vite.config.ts to include path mapping for the @cherrystudio/extension-table-plus package, improving module resolution for Electron builds.

* refactor(table-cell): rename allowNestedTables to allowNestedNodes and update content type

- Changed the TableCell option from allowNestedTables to allowNestedNodes for clarity on nested node support.
- Updated content type in TableCell and TableHeader from 'block+' to 'paragraph+' to better reflect intended structure.
- Adjusted logic in Table to disallow inserting tables inside nested nodes based on the new option.

* fix: math block bug

* feat(richEditor): add inline and block math commands with updated toolbar support

- Introduced 'inlineMath' and 'blockMath' commands for inserting inline and block mathematical formulas.
- Updated the toolbar to include new commands and their respective tooltips.
- Enhanced the math input dialog to handle both inline and block math types.
- Adjusted markdown conversion to support new math syntax for inline and block math.
- Updated localization files to include translations for new commands.

* feat(table-cell): add cell selection styling and decorations

- Implemented a new plugin for cell selection styling in table cells.
- Added logic to create decorations for selected cells, enhancing visual feedback.
- Updated CSS to style selected cells with borders based on selection edges.

* feat(table): enhance table action handling with new row/column action triggers

- Added optional callbacks for row and column action triggers in TableOptions.
- Implemented row and column action buttons in TableView, allowing for dynamic actions on selected rows and columns.
- Introduced utility functions for calculating cell selection bounds and element border widths.
- Updated styles to accommodate new action buttons and ensure proper positioning.
- Integrated action menu in RichEditor for managing table actions, enhancing user interaction.

* feat(table): enhance table action menu and localization support

- Updated TableOptions to include optional position parameters for row and column action callbacks.
- Refactored TableView to utilize new action callbacks for row and column actions, improving interaction.
- Integrated ActionMenu in RichEditor for better management of table actions, replacing the previous event-based approach.
- Added localization strings for new table action commands in multiple languages, enhancing user accessibility.

* feat(richEditor): update table action icons for improved clarity

- Replaced icons for row insertion actions in the table action menu, using ArrowUp for inserting a row before and ArrowDown for inserting a row after.
- Enhanced visual representation of table actions to better align with user expectations.

* chore(package): bump version to 3.0.11 for @cherrystudio/extension-table-plus

* feat(richtext): enhance table cell styling and resize handle functionality

- Added styles for text overflow handling in table cells to improve readability.
- Introduced a column resize handle with specific positioning and visibility rules.
- Updated the RichEditor to support resizable tables, enhancing user interaction with table elements.

* fix: auto scroll to incomplete command list

* fix: cli

* feat: add MdiDragHandle icon and update RichEditor to use it

- Introduced a new MdiDragHandle SVG icon in the SVGIcon component.
- Replaced the MdiLightbulbOn icon with MdiDragHandle in the RichEditor component for improved functionality.

* feat(RichEditor): add onPaste callback for handling paste events

- Introduced an onPaste callback in both RichEditorProps and UseRichEditorOptions interfaces to allow custom handling of paste events.
- Implemented paste event handling in the useRichEditor hook, converting pasted text to HTML and dispatching it to the editor.

* feat(markdownConverter): extend allowed attributes for HTML sanitization

- Added 'width', 'height', and 'loading' to the list of allowed attributes in the sanitizeHtml function to enhance HTML sanitization capabilities.

* refactor(richtext): update paragraph and heading styles for improved layout

- Removed default margins from paragraphs and adjusted margins for headings to enhance spacing.
- Updated font sizes for headings to improve hierarchy and readability.
- Enhanced blockquote styling with a new border color and italic font style.
- Added specific margin rules for the first and last paragraphs to ensure consistent spacing.

* style(richtext): adjust margins for headings and paragraphs

- Updated heading margins from 'em' to 'rem' for consistency.
- Modified paragraph margins to improve spacing and readability.
- Removed redundant margin rules for first and last paragraphs.

* feat(AssistantPromptSettings): implement draft prompt handling for improved token estimation

- Introduced a draftPrompt ref to manage prompt changes before committing.
- Updated token count estimation to use the draft prompt instead of the current prompt.
- Enhanced the onUpdate function to commit the draft prompt when saving changes.
- Modified handleMarkdownChange to update the draft prompt directly.

* refactor(RichEditor): optimize command handling with useCallback

- Refactored the handleCommand function to use useCallback for improved performance.
- Cleaned up the command handling logic for better readability and maintainability.
- Ensured consistent behavior for link handling and other formatting commands.

* style(richtext): reorganize list styles and enhance task item appearance

- Moved list styles for unordered and ordered lists to a new section for better organization.
- Ensured consistent padding and margin for list items.
- Updated task item styles to improve visual clarity, including checked checkbox appearance.
- Adjusted paragraph margins within list items for improved readability.

* feat(richtext): add table of contents support in RichEditor

- Introduced a new table of contents extension to enhance document navigation.
- Updated RichEditor component to conditionally render the table of contents based on the new `showTableOfContents` prop.
- Integrated table of contents functionality within the useRichEditor hook, allowing for dynamic updates based on document structure.
- Styled the table of contents for improved visibility and usability.
- Updated package.json and yarn.lock to include the new @tiptap/extension-table-of-contents dependency.

* feat(richtext): enhance RichEditor with content search functionality

- Added `enableContentSearch` prop to RichEditor for in-editor content search.
- Integrated ContentSearch component, allowing users to search within the editor.
- Introduced `showUserToggle` and `positionMode` props for ContentSearch customization.
- Updated styling for Container and SearchBarContainer to support new positioning options.
- Adjusted RichEditor settings in AssistantPromptSettings to reflect new content search feature.

* fix: renderer

* fix: styles

* fix: code styles

* fix: table save

* styles: a link

* feat: link editor

* perf: don't show when editable equals to false

* chore: remove some log

* feat: link remove

* style: reduce space for nested list

* fix/link

* feat: add PlusButton to RichEditor and adjust padding in richtext styles

* style: increase font size in richtext styles

* feat: add task list functionality to RichEditor with toolbar integration and localization support

* feat: enhance math dialog positioning and toolbar integration in RichEditor

* feat: enhance Table of Contents functionality with dynamic item display and scroll behavior

* feat: enhance markdown rendering by properly escaping HTML entities in code blocks and inline code

* feat: update link handling in RichEditor to use enhancedLink functionality and auto-update href based on text content

* feat: improve link hover functionality in RichEditor by calculating position based on full link range

* refactor: remove unused MdiDragHandle component from SVGIcon

* fix: update markdown conversion tests to ensure proper HTML output for line breaks and code blocks

* feat: enhance RichEditor functionality by adding code block handling for paste events and keyboard shortcuts for indentation

* feat: enhance code block language options in RichEditor by dynamically loading available languages from Shiki

* feat: update math syntax handling in RichEditor and markdown converter to use $$ for block and inline math

* feat: allow mathPlaceholder node to accept block content in EnhancedMath extension

* feat: improve paste handling in RichEditor by conditionally cleaning HTML based on cursor position and paragraph state

* fix: correct HTML cleaning logic in RichEditor to remove only outer paragraph tags during content insertion

* feat: enhance markdown conversion to support LaTeX in table cells and improve escaping logic

* fix: enhance link hover positioning in RichEditor to account for document boundaries and improve accuracy near the end of the document

* feat: add note book feature (#8234)

* feat: add notes feature with sidebar integration

Introduces a new Notes page and integrates it into the sidebar and routing. Updates sidebar icon types, default icons, and migration logic to support the new 'notes' icon. Adds initial types for notes and folders, and provides a basic NotesPage component. Also updates Chinese locale for notes.

* feat: add notes feature with sidebar, editor, and storage

Introduces a full notes management feature, including a sidebar for folders and notes, a markdown editor using Vditor, and persistent storage of the notes tree. Adds new components (NotesNavbar, NotesSidebar), a NotesService utility for CRUD operations, and updates settings and migration logic to support workspace visibility. Also updates Chinese i18n for notes, and refines the notes type definition.

* feat: enhance notes functionality with auto-save and file name synchronization

* feat: add export to Notes feature

Introduced the ability to export messages and topics to the Notes workspace. Updated UI components, i18n strings, settings, migration logic, and export utilities to support the new export option.

* fix: merge main branch error

* fix: build check error

* Update src/renderer/src/utils/export.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/utils/export.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/App.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/pages/home/Tabs/TopicsTab.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/renderer/src/pages/notes/NotesPage.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Revert "Update src/renderer/src/pages/notes/NotesPage.tsx"

This reverts commit a1b9c5a5b0.

* Merge branch 'feat/richeditor' into feat-note

* wip: read markdown

* wip: markdown content save

* fix: content save

* feat: add context menu to notes sidebar and loading state

Implemented a right-click context menu for notes in the sidebar, including options to rename, star, export to knowledge base, and delete notes. Added a loading spinner to NotesPage when loading note content. Updated i18n labels and Chinese translations for new features.

* Enable exporting notes to knowledge base

Added support for exporting individual notes to the knowledge base via a popup. Updated SaveToKnowledgePopup to handle notes, adjusted UI logic for note export, and added relevant i18n strings for export actions and feedback in all supported languages. NotesSidebar now provides an export option in the note context menu.

* Add favorite notes feature to notes sidebar

Introduces the ability to mark notes as favorites and view only starred notes in the sidebar. Updates i18n translations for related labels in all supported languages. Implements UI controls for toggling favorite status and switching between all notes and starred notes view.

* Refactor notes export and update UI labels

Moved NotesService to a shared services directory and updated all imports. Replaced the notes export menu option with a direct 'Save to Notes' action in message and topic menus. Updated i18n labels for 'Save to Notes' in multiple languages and removed the notes export toggle from settings. Cleaned up related migration logic and improved code organization.

* Add drag-and-drop Markdown note upload

Implemented drag-and-drop file upload in NotesSidebar, allowing users to upload Markdown (.md) files directly to notes. Added internationalized messages for upload success, failure, and file type restrictions. Updated NotesService with uploadNote method to handle file storage and tree updates.

* fix: editor init

* Implement drag-and-drop sorting for notes tree

Replaces node moving with a more flexible drag-and-drop sorting mechanism in the notes sidebar. Adds visual indicators for drop positions and updates NotesService with a sortNodes method to handle before, after, and inside placement of nodes. Improves user experience and tree manipulation capabilities.

* fix: some bugs

* fix: remove NotesService class

* Migrate notes tree storage to IndexedDB

Replaced localStorage usage with IndexedDB for storing and retrieving the notes tree structure. Updated NotesService methods and related logic to use the new notes_tree table in Dexie, including making buildNodePath asynchronous. Adjusted translations in NotesSidebar for consistency.

* fix: some bugs

* Merge branch 'feat/richeditor' into feat-note

* feat: enhance RichEditor with table of contents and content search features

Added 'showTableOfContents' and 'enableContentSearch' props to the RichEditor component in NotesPage, improving navigation and search capabilities within notes.

* Add multi-level note sorting functionality

Introduced sorting options for notes by name, update time, and creation time in ascending and descending order. Updated UI and i18n files to support new sorting features, refactored NotesSidebar and NotesPage to handle sorting, and extended NotesService with recursive sorting logic. Added NotesSortType to note types for better type safety.

* perf: reduce rerender

* Add search functionality to notes sidebar

Introduces a search view in the notes sidebar, allowing users to filter notes by keyword. Adds UI elements for toggling search mode and inputting search terms, and updates filtering logic to support both starred and search views.

* Update NotesPage.tsx

* Add header navbar to notes page

Introduced a new HeaderNavbar component for the notes page and integrated it into NotesPage. Adjusted layout styles and reduced NotesSidebar width from 280px to 250px for improved UI consistency.

* Refactor notes state management and add character count i18n

Moved activeNodeId state to Redux store for better state management in NotesPage. Added 'characters' translation key to all supported locales and updated NotesPage to use it. Cleaned exported note content in exportMessageToNotes to remove assistant header.

* Add breadcrumb navigation to notes header

Introduces breadcrumb navigation in the notes header by passing notesTree to HeaderNavbar and implementing path calculation using getNodePathArray. Also exports findNodeInTree and refactors layout for improved UI structure.

* fix: style

* fix: update sorting labels and title capitalization in localization files for multiple languages

* style: adjust margin and padding in TableOfContentsWrapper and ToCDock components

* feat: implement content update handling in RichEditor and enhance mode switching in NotesPage

* refactor: remove redundant content update handling in RichEditor

* Update NotesPage.tsx

* Update NotesPage.tsx

* feat: enhance Table of Contents functionality with dynamic item display and scroll behavior

* fix: update markdown conversion tests to ensure proper HTML output for line breaks and code blocks

* feat: add support for saving pasted images in RichEditor with compression handling and IPC integration

* feat: enhance markdown conversion to support file:// protocol images as HTML img tags

* fix: update RichEditor styles for overflow handling and adjust markdown conversion to preserve <br> tags

* fix: refine RichEditor styles to improve text wrapping and ensure proper display of paragraphs

* Update NotesPage.tsx

* fix: update content structure in TableCell and EnhancedImage to allow for multiple block types

* feat: add methods to set selection to the last row and last column in TableView for improved user experience

* fix: adjust table layout and minimum width in RichEditor styles for better responsiveness and display

* fix: update table layout and scrollbar styles in RichEditor for improved responsiveness and user experience

* fix: improve style

* fix: update content structure in TableCell to support images alongside paragraphs

* fix: enhance layout and styling in NotesPage for improved responsiveness and user experience

* fix: unsaved mention

* Update NotesPage.tsx

* Update NotesPage.tsx

* fix: refine styling in RichEditor and NotesPage for improved layout and responsiveness

* fix: remove extraneous text from Navbar component rendering

* fix: adjust layout and styling in HeaderNavbar for better alignment and responsiveness

* fix: update Scrollbar styling in RichEditor for improved layout

* feat: implement enhanced math command in RichEditor for improved math placeholder insertion

* chore: update @tiptap dependencies to version 3.2.0 and enhance drag handling in RichEditor

* refactor: remove italic font style from rich text and clean up button styles in RichEditor

* refactor: streamline drag handle behavior and adjust styling in RichEditor for improved layout

* style: add code block styling to rich text for consistent font properties

* feat: add tooltips for plus button and drag handle in RichEditor for enhanced user guidance

* fix: update @tiptap/extension-drag-handle to use a patch version for improved functionality in RichEditor

* feat: enhance RichEditor with full width and font family options, and update settings localization

* feat: add drop hint for importing markdown files in NotesSidebar and update localization for multiple languages

* refactor: remove EditorContainer and simplify JSX structure in NotesPage for cleaner layout

* feat: add copy content functionality to HeaderNavbar and update localization for multiple languages

* refactor: simplify NotesPage by integrating NotesEditor component and optimizing state management with useCallback

* wip: open external folder

* feat: enable full width option in AssistantPromptSettings for improved layout

* wip: open external folder

* wip: open external folder

* wip: open external folder

* fix: move node

* wip: fix file rename

* refactor: notebook feature

* fix: improve file and directory deletion and renaming logic

Enhanced error handling and logging for external file and directory deletion in FileStorage. Updated file renaming to consistently append '.md' extension. Improved NotesService and NotesTreeService to use externalPath for renaming and fixed path updates for renamed nodes. Breadcrumbs in HeaderNavbar now reset when no active node is present. File scanning now strips file extension from node names.

* Refactor notes directory handling and state management

Replaces 'folderPath' with 'notesPath' throughout the codebase for clarity and consistency. Adds getNotesDir utility and updates IPC, FileStorage, Redux store, hooks, and UI components to use the new notes directory logic. Improves initialization and workspace setup for notes, ensuring correct directory creation and state synchronization.

* fix

* wip: ensure unique names for notes and folders

Introduces logic to prevent name collisions when creating or uploading notes and folders by checking for existing paths and appending a counter if necessary. Updates related services and UI to handle only one file upload at a time and provide user feedback for invalid actions.

* feat: add file watcher functionality and validate notes directory

Introduces a file watcher using chokidar to monitor changes in the notes directory. Adds IPC channels for starting and stopping the watcher, and validates the selected notes directory to ensure it meets specific criteria. Updates relevant components and services to integrate the new functionality, enhancing the application's responsiveness to file changes.

* fix: add file name validation and uniqueness checks

Introduces file name legality and uniqueness checks to prevent duplicate or invalid file/folder names. Updates IPC, backend, and renderer logic to use these checks for creating, renaming, and uploading notes and folders. Removes legacy unique name logic and refactors related code for consistency.

* fix: file name guard

* fix: rename

* Update NotesSettings.tsx

* Update NotesSettings.tsx

* feat: enhance notes settings and editor functionality

Refactors the notes settings to introduce new display and editor configurations, including options for default view modes and content compression. Updates the Redux store to manage these new settings and modifies relevant components to reflect the changes. Ensures a more intuitive user experience by allowing users to customize their editing environment and display preferences.

* feat: enhance file change handling and directory scanning

Introduces a new FileChangeEvent type to manage file system events more effectively. Updates the FileStorage service to handle directory operations with immediate synchronization and implements a debounce mechanism for file changes. Enhances the scanDir function to support recursive directory scanning with a configurable depth, improving the overall file management capabilities. Additionally, updates the Redux store to track active file paths instead of node IDs, streamlining the note management process.

* feat: enhance directory scanning and note management

Updates the scanDir function to include an optional basePath parameter for improved relative path handling. Modifies the NotesPage and NotesSidebar components to manage selected folder states, allowing for more intuitive folder and note creation. Refactors the createFolder and createNote services to ensure proper tree structure updates based on the selected folder. Additionally, introduces a new utility function to find nodes by external path, enhancing the overall note management experience.

* feat: improve code block highlighting and note saving functionality

Enhances the ShikiPlugin to load themes and languages only when necessary, preventing redundant operations. Introduces error handling for loading processes. In the NotesPage component, implements a debounced save mechanism to optimize note saving, ensuring that changes are saved only after a pause in typing. Additionally, adds logic to compare file content changes before triggering updates, improving the efficiency of file watching and content management.

* Update file.ts

* fix: improve file name validation and sanitization

Refactored file name validation logic to support platform-specific rules and provide detailed error messages. Added a sanitizeFilename utility to clean file names by replacing invalid characters and handling reserved names. Updated getName and checkName functions to use the new validation and sanitization methods.

* fix: remove unused languageMap from useRichEditor hook

Eliminated the languageMap variable from the useRichEditor hook as it was not being utilized, streamlining the code and improving performance. Updated dependencies in the effect hook accordingly.

* refactor: streamline theme and language loading in ShikiPlugin

Removed unnecessary snapshot logic for loaded themes and languages in the ShikiPlugin. Simplified the loading check by introducing a flag to track if any themes or languages were loaded, enhancing performance and reducing complexity.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: suyao <sy20010504@gmail.com>

* feat: add isTextFile method to API for file type checking

Introduced a new method `isTextFile` in the preload API to determine if a given file path corresponds to a text file. This enhancement improves file handling capabilities within the application.

* refactor: remove useRichEditor test file

* fix: add i18n

* Update fr-fr.json

* fix: merge main branch build error

* fix the missing navbar when the navigation bar is on the left

* fix: version

* feat: update NotesEditor and NotesService functionality

- Added a temporary view mode state in NotesEditor for improved UI handling.
- Enhanced sortAllLevels function in NotesService to persist the notes tree after sorting.
- Changed default edit mode in note state from 'realtime' to 'preview' for better user experience.

* fix: prevent expanded CodeEditor in NotesEditor

* feat: implement initial sorting for notes tree

- Added a new ref to track if initial sorting has been applied.
- Introduced a useEffect to apply alphabetical sorting to the notes tree on initial load, ensuring a consistent order for users.
- Included error handling for the sorting process to log any issues encountered.

* refactor: comment out file content comparison logic in NotesPage

- Temporarily disabled the file content comparison logic in the NotesPage component to streamline the file reading process.
- This change is intended for further review and potential optimization of the file handling functionality.

* style: remove overflow-y property from richtext.scss

- Eliminated the overflow-y property to improve the styling of the rich text editor, allowing for better content display and user experience.

---------

Co-authored-by: Pleasure1234 <3196812536@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: one <wangan.cs@gmail.com>
This commit is contained in:
SuYao 2025-08-30 23:09:13 +08:00 committed by GitHub
parent ebe2806467
commit dfb3322b28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
138 changed files with 21661 additions and 241 deletions

View File

@ -0,0 +1,48 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 8e560a4406c5cc616c11bb9fd5455ac0dcf47fa3..c7cd0d65ddc971bff71e89f610de82cfdaa5a8c7 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -413,6 +413,19 @@ var DragHandlePlugin = ({
}
return false;
},
+ scroll(view) {
+ if (!element || locked) {
+ return false;
+ }
+ if (view.hasFocus()) {
+ hideHandle();
+ currentNode = null;
+ currentNodePos = -1;
+ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 });
+ return false;
+ }
+ return false;
+ },
mouseleave(_view, e) {
if (locked) {
return false;
diff --git a/dist/index.js b/dist/index.js
index 39e4c3ef9986cd25544d9d3994cf6a9ada74b145..378d9130abbfdd0e1e4f743b5b537743c9ab07d0 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -387,6 +387,19 @@ var DragHandlePlugin = ({
}
return false;
},
+ scroll(view) {
+ if (!element || locked) {
+ return false;
+ }
+ if (view.hasFocus()) {
+ hideHandle();
+ currentNode = null;
+ currentNodePos = -1;
+ onNodeChange == null ? void 0 : onNodeChange({ editor, node: null, pos: -1 });
+ return false;
+ }
+ return false;
+ },
mouseleave(_view, e) {
if (locked) {
return false;

View File

@ -81,7 +81,8 @@ export default defineConfig({
'@shared': resolve('packages/shared'),
'@logger': resolve('src/renderer/src/services/LoggerService'),
'@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'),
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web')
'@mcp-trace/trace-web': resolve('packages/mcp-trace/trace-web'),
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src')
}
},
optimizeDeps: {

View File

@ -19,7 +19,8 @@
"packages/database",
"packages/mcp-trace/trace-core",
"packages/mcp-trace/trace-node",
"packages/mcp-trace/trace-web"
"packages/mcp-trace/trace-web",
"packages/extension-table-plus"
]
}
},
@ -106,6 +107,7 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@cherrystudio/extension-table-plus": "workspace:^",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -144,9 +146,27 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tiptap/extension-collaboration": "^3.2.0",
"@tiptap/extension-drag-handle": "patch:@tiptap/extension-drag-handle@npm%3A3.2.0#~/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch",
"@tiptap/extension-drag-handle-react": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-list": "^3.2.0",
"@tiptap/extension-mathematics": "^3.2.0",
"@tiptap/extension-mention": "^3.2.0",
"@tiptap/extension-node-range": "^3.2.0",
"@tiptap/extension-table-of-contents": "^3.2.0",
"@tiptap/extension-typography": "^3.2.0",
"@tiptap/extension-underline": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",
"@tiptap/starter-kit": "^3.2.0",
"@tiptap/suggestion": "^3.2.0",
"@tiptap/y-tiptap": "^3.0.0",
"@truto/turndown-plugin-gfm": "^1.0.2",
"@tryfabric/martian": "^1.2.4",
"@types/cli-progress": "^3",
"@types/fs-extra": "^11",
"@types/he": "^1",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/md5": "^2.3.5",
@ -157,6 +177,7 @@
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-transition-group": "^4.4.12",
"@types/tinycolor2": "^1",
"@types/turndown": "^5.0.5",
"@types/word-extractor": "^1",
"@uiw/codemirror-extensions-langs": "^4.25.1",
"@uiw/codemirror-themes-all": "^4.25.1",
@ -175,6 +196,7 @@
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"chardet": "^2.1.0",
"chokidar": "^4.0.3",
"cli-progress": "^3.12.0",
"code-inspector-plugin": "^0.20.14",
"color": "^5.0.0",
@ -184,6 +206,7 @@
"dexie-react-hooks": "^1.1.7",
"diff": "^8.0.2",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.4.0",
"electron-builder": "26.0.15",
@ -205,6 +228,7 @@
"franc-min": "^6.2.0",
"fs-extra": "^11.2.0",
"google-auth-library": "^9.15.1",
"he": "^1.2.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next": "^23.11.5",
@ -262,11 +286,13 @@
"shiki": "^3.12.0",
"strict-url-sanitise": "^0.0.1",
"string-width": "^7.2.0",
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"tsx": "^4.20.3",
"turndown-plugin-gfm": "^1.0.2",
"typescript": "^5.6.2",
"undici": "6.21.2",
"unified": "^11.0.5",
@ -277,6 +303,8 @@
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0",
"word-extractor": "^1.0.4",
"y-protocols": "^1.0.6",
"yjs": "^13.6.27",
"zipread": "^1.3.3",
"zod": "^3.25.74"
},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
# @tiptap/extension-table
[![Version](https://img.shields.io/npm/v/@tiptap/extension-table.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-table)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-table.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-table.svg)](https://www.npmjs.com/package/@tiptap/extension-table)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@ -0,0 +1,93 @@
{
"name": "@cherrystudio/extension-table-plus",
"description": "table extension for tiptap forked from tiptap/extension-table",
"version": "3.0.11",
"homepage": "https://cherry-ai.com",
"keywords": [
"tiptap",
"tiptap extension"
],
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./table": {
"types": {
"import": "./dist/table/index.d.ts",
"require": "./dist/table/index.d.cts"
},
"import": "./dist/table/index.js",
"require": "./dist/table/index.cjs"
},
"./cell": {
"types": {
"import": "./dist/cell/index.d.ts",
"require": "./dist/cell/index.d.cts"
},
"import": "./dist/cell/index.js",
"require": "./dist/cell/index.cjs"
},
"./header": {
"types": {
"import": "./dist/header/index.d.ts",
"require": "./dist/header/index.d.cts"
},
"import": "./dist/header/index.js",
"require": "./dist/header/index.cjs"
},
"./kit": {
"types": {
"import": "./dist/kit/index.d.ts",
"require": "./dist/kit/index.d.cts"
},
"import": "./dist/kit/index.js",
"require": "./dist/kit/index.cjs"
},
"./row": {
"types": {
"import": "./dist/row/index.d.ts",
"require": "./dist/row/index.d.cts"
},
"import": "./dist/row/index.js",
"require": "./dist/row/index.cjs"
}
},
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"src",
"dist"
],
"devDependencies": {
"@tiptap/core": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
"tsdown": "^0.13.3"
},
"peerDependencies": {
"@tiptap/core": "^3.0.9",
"@tiptap/pm": "^3.0.9"
},
"repository": {
"type": "git",
"url": "https://github.com/CherryHQ/cherry-studio",
"directory": "packages/extension-table-plus"
},
"scripts": {
"build": "tsdown",
"lint": "prettier ./src/ --write && eslint --fix ./src/"
},
"packageManager": "yarn@4.9.1"
}

View File

@ -0,0 +1 @@
export * from './table-cell.js'

View File

@ -0,0 +1,150 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import type { Selection } from '@tiptap/pm/state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
export interface TableCellOptions {
/**
* The HTML attributes for a table cell node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Whether nodes can be nested inside a cell.
* @default false
*/
allowNestedNodes: boolean
}
const cellSelectionPluginKey = new PluginKey('cellSelectionStyling')
function isTableNode(node: ProseMirrorNode): boolean {
const spec = node.type.spec as { tableRole?: string } | undefined
return node.type.name === 'table' || spec?.tableRole === 'table'
}
function createCellSelectionDecorationSet(doc: ProseMirrorNode, selection: Selection): DecorationSet {
if (!(selection instanceof CellSelection)) {
return DecorationSet.empty
}
const $anchor = selection.$anchorCell || selection.$anchor
let tableNode: ProseMirrorNode | null = null
let tablePos = -1
for (let depth = $anchor.depth; depth > 0; depth--) {
const nodeAtDepth = $anchor.node(depth) as ProseMirrorNode
if (isTableNode(nodeAtDepth)) {
tableNode = nodeAtDepth
tablePos = $anchor.before(depth)
break
}
}
if (!tableNode) {
return DecorationSet.empty
}
const map = TableMap.get(tableNode)
const tableStart = tablePos + 1
type Rect = { top: number; bottom: number; left: number; right: number }
type Item = { pos: number; node: ProseMirrorNode; rect: Rect }
const items: Item[] = []
let minRow = Number.POSITIVE_INFINITY
let maxRow = Number.NEGATIVE_INFINITY
let minCol = Number.POSITIVE_INFINITY
let maxCol = Number.NEGATIVE_INFINITY
selection.forEachCell((cell, pos) => {
const rect = map.findCell(pos - tableStart)
items.push({ pos, node: cell, rect })
minRow = Math.min(minRow, rect.top)
maxRow = Math.max(maxRow, rect.bottom - 1)
minCol = Math.min(minCol, rect.left)
maxCol = Math.max(maxCol, rect.right - 1)
})
const decorations: Decoration[] = []
for (const { pos, node, rect } of items) {
const classes: string[] = ['selectedCell']
if (rect.top === minRow) classes.push('selection-top')
if (rect.bottom - 1 === maxRow) classes.push('selection-bottom')
if (rect.left === minCol) classes.push('selection-left')
if (rect.right - 1 === maxCol) classes.push('selection-right')
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: classes.join(' ')
})
)
}
return DecorationSet.create(doc, decorations)
}
/**
* This extension allows you to create table cells.
* @see https://www.tiptap.dev/api/nodes/table-cell
*/
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
addOptions() {
return {
HTMLAttributes: {},
allowNestedNodes: false
}
},
content: '(paragraph | image)+',
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null
return value
}
}
}
},
tableRole: 'cell',
isolating: true,
parseHTML() {
return [{ tag: 'td' }]
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addProseMirrorPlugins() {
return [
new Plugin({
key: cellSelectionPluginKey,
props: {
decorations: ({ doc, selection }) => createCellSelectionDecorationSet(doc as ProseMirrorNode, selection)
}
})
]
}
})

View File

@ -0,0 +1 @@
export * from './table-header.js'

View File

@ -0,0 +1,60 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
export interface TableHeaderOptions {
/**
* The HTML attributes for a table header node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table headers.
* @see https://www.tiptap.dev/api/nodes/table-header
*/
export const TableHeader = Node.create<TableHeaderOptions>({
name: 'tableHeader',
addOptions() {
return {
HTMLAttributes: {}
}
},
content: 'paragraph+',
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth')
const value = colwidth ? colwidth.split(',').map((width) => parseInt(width, 10)) : null
return value
}
}
}
},
tableRole: 'header_cell',
isolating: true,
parseHTML() {
return [{ tag: 'th' }]
},
renderHTML({ HTMLAttributes }) {
return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
})

View File

@ -0,0 +1,6 @@
export * from './cell/index.js'
export * from './header/index.js'
export * from './kit/index.js'
export * from './row/index.js'
export * from './table/index.js'
export * from './table/TableView.js'

View File

@ -0,0 +1,64 @@
import { Extension, Node } from '@tiptap/core'
import type { TableCellOptions } from '../cell/index.js'
import { TableCell } from '../cell/index.js'
import type { TableHeaderOptions } from '../header/index.js'
import { TableHeader } from '../header/index.js'
import type { TableRowOptions } from '../row/index.js'
import { TableRow } from '../row/index.js'
import type { TableOptions } from '../table/index.js'
import { Table } from '../table/index.js'
export interface TableKitOptions {
/**
* If set to false, the table extension will not be registered
* @example table: false
*/
table: Partial<TableOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableCell: false
*/
tableCell: Partial<TableCellOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableHeader: false
*/
tableHeader: Partial<TableHeaderOptions> | false
/**
* If set to false, the table extension will not be registered
* @example tableRow: false
*/
tableRow: Partial<TableRowOptions> | false
}
/**
* The table kit is a collection of table editor extensions.
*
* Its a good starting point for building your own table in Tiptap.
*/
export const TableKit = Extension.create<TableKitOptions>({
name: 'tableKit',
addExtensions() {
const extensions: Node[] = []
if (this.options.table !== false) {
extensions.push(Table.configure(this.options.table))
}
if (this.options.tableCell !== false) {
extensions.push(TableCell.configure(this.options.tableCell))
}
if (this.options.tableHeader !== false) {
extensions.push(TableHeader.configure(this.options.tableHeader))
}
if (this.options.tableRow !== false) {
extensions.push(TableRow.configure(this.options.tableRow))
}
return extensions
}
})

View File

@ -0,0 +1 @@
export * from './table-row.js'

View File

@ -0,0 +1,38 @@
import '../types.js'
import { mergeAttributes, Node } from '@tiptap/core'
export interface TableRowOptions {
/**
* The HTML attributes for a table row node.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
}
/**
* This extension allows you to create table rows.
* @see https://www.tiptap.dev/api/nodes/table-row
*/
export const TableRow = Node.create<TableRowOptions>({
name: 'tableRow',
addOptions() {
return {
HTMLAttributes: {}
}
},
content: '(tableCell | tableHeader)*',
tableRole: 'row',
parseHTML() {
return [{ tag: 'tr' }]
},
renderHTML({ HTMLAttributes }) {
return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
}
})

View File

@ -0,0 +1,558 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import { addColumnAfter, addRowAfter, CellSelection, TableMap } from '@tiptap/pm/tables'
import type { EditorView, NodeView, ViewMutationRecord } from '@tiptap/pm/view'
import { getColStyleDeclaration } from './utilities/colStyle.js'
import { getElementBorderWidth } from './utilities/getBorderWidth.js'
import { isCellSelection } from './utilities/isCellSelection.js'
import { getCellSelectionBounds } from './utilities/selectionBounds.js'
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLTableColElement, // <colgroup> has the same prototype as <col>
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number
) {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild
const row = node.firstChild
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : ((colwidth && colwidth[j]) as number | undefined)
const cssWidth = hasWidth ? `${hasWidth}px` : ''
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
if (!nextDOM) {
const colElement = document.createElement('col')
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
colElement.style.setProperty(propertyKey, propertyValue)
colgroup.appendChild(colElement)
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(cellMinWidth, hasWidth)
;(nextDOM as HTMLTableColElement).style.setProperty(propertyKey, propertyValue)
}
nextDOM = nextDOM.nextSibling
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`
table.style.minWidth = ''
} else {
table.style.width = ''
table.style.minWidth = `${totalWidth}px`
}
}
// Callbacks are now handled by a decorations plugin; keep type removed here
type ButtonPosition = { x: number; y: number }
type RowActionCallback = (args: { rowIndex: number; view: EditorView; position?: ButtonPosition }) => void
type ColumnActionCallback = (args: { colIndex: number; view: EditorView; position?: ButtonPosition }) => void
export class TableView implements NodeView {
node: ProseMirrorNode
cellMinWidth: number
dom: HTMLDivElement
table: HTMLTableElement
colgroup: HTMLTableColElement
contentDOM: HTMLTableSectionElement
view: EditorView
addRowButton: HTMLButtonElement
addColumnButton: HTMLButtonElement
tableContainer: HTMLDivElement
// Hover add buttons are kept; overlay endpoints absolute on wrapper
private selectionChangeDisposer?: () => void
private rowEndpoint?: HTMLButtonElement
private colEndpoint?: HTMLButtonElement
private overlayUpdateRafId: number | null = null
private actionCallbacks?: {
onRowActionClick?: RowActionCallback
onColumnActionClick?: ColumnActionCallback
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
view: EditorView,
actionCallbacks?: { onRowActionClick?: RowActionCallback; onColumnActionClick?: ColumnActionCallback }
) {
this.node = node
this.cellMinWidth = cellMinWidth
this.view = view
this.actionCallbacks = actionCallbacks
// selection triggers handled by decorations plugin
// Create the wrapper with grid layout
this.dom = document.createElement('div')
this.dom.className = 'tableWrapper'
// Create table container
this.tableContainer = document.createElement('div')
this.tableContainer.className = 'table-container'
this.table = this.tableContainer.appendChild(document.createElement('table'))
this.colgroup = this.table.appendChild(document.createElement('colgroup'))
updateColumns(node, this.colgroup, this.table, cellMinWidth)
this.contentDOM = this.table.appendChild(document.createElement('tbody'))
this.addRowButton = document.createElement('button')
this.addColumnButton = document.createElement('button')
this.createHoverButtons()
this.dom.appendChild(this.tableContainer)
this.dom.appendChild(this.addColumnButton)
this.dom.appendChild(this.addRowButton)
this.syncEditableState()
this.setupEventListeners()
// create overlay endpoints
this.rowEndpoint = document.createElement('button')
this.rowEndpoint.className = 'row-action-trigger'
this.rowEndpoint.type = 'button'
this.rowEndpoint.setAttribute('contenteditable', 'false')
this.rowEndpoint.style.position = 'absolute'
this.rowEndpoint.style.display = 'none'
this.rowEndpoint.tabIndex = -1
this.colEndpoint = document.createElement('button')
this.colEndpoint.className = 'column-action-trigger'
this.colEndpoint.type = 'button'
this.colEndpoint.setAttribute('contenteditable', 'false')
this.colEndpoint.style.position = 'absolute'
this.colEndpoint.style.display = 'none'
this.colEndpoint.tabIndex = -1
this.dom.appendChild(this.rowEndpoint)
this.dom.appendChild(this.colEndpoint)
this.bindOverlayHandlers()
this.startSelectionWatcher()
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) {
return false
}
this.node = node
updateColumns(node, this.colgroup, this.table, this.cellMinWidth)
// Keep buttons' disabled state in sync during updates
this.syncEditableState()
// Recalculate overlay positions after node/table mutations so triggers follow the updated layout
this.scheduleOverlayUpdate()
return true
}
ignoreMutation(mutation: ViewMutationRecord) {
return (
(mutation.type === 'attributes' && (mutation.target === this.table || this.colgroup.contains(mutation.target))) ||
// Ignore mutations on our action buttons
(mutation.target as Element)?.classList?.contains('row-action-trigger') ||
(mutation.target as Element)?.classList?.contains('column-action-trigger')
)
}
private isEditable(): boolean {
// Rely on DOM attribute to avoid depending on EditorView internals
return this.view.dom.getAttribute('contenteditable') !== 'false'
}
private syncEditableState() {
const editable = this.isEditable()
this.addRowButton.toggleAttribute('disabled', !editable)
this.addColumnButton.toggleAttribute('disabled', !editable)
this.addRowButton.style.display = editable ? '' : 'none'
this.addColumnButton.style.display = editable ? '' : 'none'
this.dom.classList.toggle('is-readonly', !editable)
}
createHoverButtons() {
this.addRowButton.className = 'add-row-button'
this.addRowButton.type = 'button'
this.addRowButton.setAttribute('contenteditable', 'false')
this.addColumnButton.className = 'add-column-button'
this.addColumnButton.type = 'button'
this.addColumnButton.setAttribute('contenteditable', 'false')
}
private addTableRowOrColumn(isRow: boolean) {
if (!this.isEditable()) return
this.view.focus()
// Save current selection info and calculate position in table
const { state } = this.view
const originalSelection = state.selection
// Find which cell we're currently in and the relative position within that cell
let tablePos = -1
let currentCellRow = -1
let currentCellCol = -1
let relativeOffsetInCell = 0
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
const map = TableMap.get(this.node)
// Find which cell contains our selection
const selectionPos = originalSelection.from
for (let row = 0; row < map.height; row++) {
for (let col = 0; col < map.width; col++) {
const cellIndex = row * map.width + col
const cellStart = pos + 1 + map.map[cellIndex]
const cellNode = state.doc.nodeAt(cellStart)
if (cellNode) {
const cellEnd = cellStart + cellNode.nodeSize
if (selectionPos >= cellStart && selectionPos < cellEnd) {
currentCellRow = row
currentCellCol = col
relativeOffsetInCell = selectionPos - cellStart
return false
}
}
}
}
return false
}
return true
})
// Set selection to appropriate position for adding
if (isRow) {
this.setSelectionToLastRow()
} else {
this.setSelectionToLastColumn()
}
setTimeout(() => {
const { state, dispatch } = this.view
const addFunction = isRow ? addRowAfter : addColumnAfter
if (addFunction(state, dispatch)) {
setTimeout(() => {
const newState = this.view.state
// Calculate new position for the same logical cell with same relative offset
if (tablePos >= 0 && currentCellRow >= 0 && currentCellCol >= 0) {
newState.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && pos === tablePos) {
const newMap = TableMap.get(node)
const newCellIndex = currentCellRow * newMap.width + currentCellCol
const newCellStart = pos + 1 + newMap.map[newCellIndex]
const newCellNode = newState.doc.nodeAt(newCellStart)
if (newCellNode) {
// Try to maintain the same relative position within the cell
const newCellEnd = newCellStart + newCellNode.nodeSize
const targetPos = Math.min(newCellStart + relativeOffsetInCell, newCellEnd - 1)
const newSelection = TextSelection.create(newState.doc, targetPos)
const newTr = newState.tr.setSelection(newSelection)
this.view.dispatch(newTr)
}
return false
}
return true
})
}
}, 10)
}
}, 10)
}
setupEventListeners() {
// Add row button click handler
this.addRowButton.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
this.addTableRowOrColumn(true)
})
// Add column button click handler
this.addColumnButton.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
this.addTableRowOrColumn(false)
})
}
private bindOverlayHandlers() {
if (!this.rowEndpoint || !this.colEndpoint) return
this.rowEndpoint.addEventListener('mousedown', (e) => e.preventDefault())
this.colEndpoint.addEventListener('mousedown', (e) => e.preventDefault())
this.rowEndpoint.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) return
this.selectRow(bounds.maxRow)
const rect = this.rowEndpoint!.getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
this.actionCallbacks?.onRowActionClick?.({ rowIndex: bounds.maxRow, view: this.view, position })
this.scheduleOverlayUpdate()
})
this.colEndpoint.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) return
this.selectColumn(bounds.maxCol)
const rect = this.colEndpoint!.getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
this.actionCallbacks?.onColumnActionClick?.({ colIndex: bounds.maxCol, view: this.view, position })
this.scheduleOverlayUpdate()
})
}
private startSelectionWatcher() {
const owner = this.view.dom.ownerDocument || document
const handler = () => this.scheduleOverlayUpdate()
owner.addEventListener('selectionchange', handler)
this.selectionChangeDisposer = () => owner.removeEventListener('selectionchange', handler)
this.scheduleOverlayUpdate()
}
private scheduleOverlayUpdate() {
if (this.overlayUpdateRafId !== null) {
cancelAnimationFrame(this.overlayUpdateRafId)
}
this.overlayUpdateRafId = requestAnimationFrame(() => {
this.overlayUpdateRafId = null
this.updateOverlayPositions()
})
}
private updateOverlayPositions() {
if (!this.rowEndpoint || !this.colEndpoint) return
const bounds = getCellSelectionBounds(this.view, this.node)
if (!bounds) {
this.rowEndpoint.style.display = 'none'
this.colEndpoint.style.display = 'none'
return
}
const { map, tableStart, maxRow, maxCol } = bounds
const getCellDomAndRect = (row: number, col: number) => {
const cellIndex = row * map.width + col
const cellPos = tableStart + map.map[cellIndex]
const cellDom = this.view.nodeDOM(cellPos) as HTMLElement | null
return {
dom: cellDom,
rect: cellDom?.getBoundingClientRect()
}
}
// Position row endpoint (left side)
const bottomLeft = getCellDomAndRect(maxRow, 0)
const topLeft = getCellDomAndRect(0, 0)
if (bottomLeft.dom && bottomLeft.rect && topLeft.rect) {
const midY = (bottomLeft.rect.top + bottomLeft.rect.bottom) / 2
this.rowEndpoint.style.display = 'flex'
const borderWidth = getElementBorderWidth(this.rowEndpoint)
this.rowEndpoint.style.left = `${bottomLeft.rect.left - topLeft.rect.left - this.rowEndpoint.getBoundingClientRect().width / 2 + borderWidth.left / 2}px`
this.rowEndpoint.style.top = `${midY - topLeft.rect.top - this.rowEndpoint.getBoundingClientRect().height / 2}px`
} else {
this.rowEndpoint.style.display = 'none'
}
// Position column endpoint (top side)
const topRight = getCellDomAndRect(0, maxCol)
const topLeftForCol = getCellDomAndRect(0, 0)
if (topRight.dom && topRight.rect && topLeftForCol.rect) {
const midX = topRight.rect.left + topRight.rect.width / 2
const borderWidth = getElementBorderWidth(this.colEndpoint)
this.colEndpoint.style.display = 'flex'
this.colEndpoint.style.left = `${midX - topLeftForCol.rect.left - this.colEndpoint.getBoundingClientRect().width / 2}px`
this.colEndpoint.style.top = `${topRight.rect.top - topLeftForCol.rect.top - this.colEndpoint.getBoundingClientRect().height / 2 + borderWidth.top / 2}px`
} else {
this.colEndpoint.style.display = 'none'
}
}
setSelectionToTable() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const firstCellPos = tablePos + 3
const selection = TextSelection.create(state.doc, firstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
setSelectionToLastRow() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const lastRowIndex = map.height - 1
const lastRowFirstCell = map.map[lastRowIndex * map.width]
const lastRowFirstCellPos = tablePos + 1 + lastRowFirstCell
const selection = TextSelection.create(state.doc, lastRowFirstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
setSelectionToLastColumn() {
const { state } = this.view
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const lastColumnIndex = map.width - 1
const lastColumnFirstCell = map.map[lastColumnIndex]
const lastColumnFirstCellPos = tablePos + 1 + lastColumnFirstCell
const selection = TextSelection.create(state.doc, lastColumnFirstCellPos)
const tr = state.tr.setSelection(selection)
this.view.dispatch(tr)
}
}
// selection triggers moved to decorations plugin
hasTableCellSelection(): boolean {
const selection = this.view.state.selection
return isCellSelection(selection)
}
selectRow(rowIndex: number) {
const { state, dispatch } = this.view
// Find the table position
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const firstCellInRow = map.map[rowIndex * map.width]
const lastCellInRow = map.map[rowIndex * map.width + map.width - 1]
const firstCellPos = tablePos + 1 + firstCellInRow
const lastCellPos = tablePos + 1 + lastCellInRow
const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos)
const tr = state.tr.setSelection(selection)
dispatch(tr)
}
}
selectColumn(colIndex: number) {
const { state, dispatch } = this.view
// Find the table position
let tablePos = -1
state.doc.descendants((node: ProseMirrorNode, pos: number) => {
if (node.type.name === 'table' && node === this.node) {
tablePos = pos
return false
}
return true
})
if (tablePos >= 0) {
const map = TableMap.get(this.node)
const firstCellInCol = map.map[colIndex]
const lastCellInCol = map.map[(map.height - 1) * map.width + colIndex]
const firstCellPos = tablePos + 1 + firstCellInCol
const lastCellPos = tablePos + 1 + lastCellInCol
const selection = CellSelection.create(state.doc, firstCellPos, lastCellPos)
const tr = state.tr.setSelection(selection)
dispatch(tr)
}
}
destroy() {
this.addRowButton?.remove()
this.addColumnButton?.remove()
if (this.rowEndpoint) this.rowEndpoint.remove()
if (this.colEndpoint) this.colEndpoint.remove()
if (this.selectionChangeDisposer) this.selectionChangeDisposer()
if (this.overlayUpdateRafId !== null) cancelAnimationFrame(this.overlayUpdateRafId)
}
}

View File

@ -0,0 +1,3 @@
export * from './table.js'
export * from './utilities/createColGroup.js'
export * from './utilities/createTable.js'

View File

@ -0,0 +1,486 @@
import '../types.js'
import { callOrReturn, getExtensionField, mergeAttributes, Node } from '@tiptap/core'
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { TextSelection } from '@tiptap/pm/state'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from '@tiptap/pm/tables'
import { type EditorView, type NodeView } from '@tiptap/pm/view'
import { TableView } from './TableView.js'
import { createColGroup } from './utilities/createColGroup.js'
import { createTable } from './utilities/createTable.js'
import { deleteTableWhenAllCellsSelected } from './utilities/deleteTableWhenAllCellsSelected.js'
export interface TableOptions {
/**
* HTML attributes for the table element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Enables the resizing of tables.
* @default false
* @example true
*/
resizable: boolean
/**
* The width of the resize handle.
* @default 5
* @example 10
*/
handleWidth: number
/**
* The minimum width of a cell.
* @default 25
* @example 50
*/
cellMinWidth: number
/**
* The node view to render the table.
* @default TableView
*/
View: (new (node: ProseMirrorNode, cellMinWidth: number, view: EditorView) => NodeView) | null
/**
* Enables the resizing of the last column.
* @default true
* @example false
*/
lastColumnResizable: boolean
/**
* Allow table node selection.
* @default false
* @example true
*/
allowTableNodeSelection: boolean
/**
* Optional callbacks for row/column action triggers
*/
onRowActionClick?: (args: { rowIndex: number; view: EditorView; position?: { x: number; y: number } }) => void
onColumnActionClick?: (args: { colIndex: number; view: EditorView; position?: { x: number; y: number } }) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
table: {
/**
* Insert a table
* @param options The table attributes
* @returns True if the command was successful, otherwise false
* @example editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
*/
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType
/**
* Add a column before the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnBefore()
*/
addColumnBefore: () => ReturnType
/**
* Add a column after the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.addColumnAfter()
*/
addColumnAfter: () => ReturnType
/**
* Delete the current column
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteColumn()
*/
deleteColumn: () => ReturnType
/**
* Add a row before the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowBefore()
*/
addRowBefore: () => ReturnType
/**
* Add a row after the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.addRowAfter()
*/
addRowAfter: () => ReturnType
/**
* Delete the current row
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteRow()
*/
deleteRow: () => ReturnType
/**
* Delete the current table
* @returns True if the command was successful, otherwise false
* @example editor.commands.deleteTable()
*/
deleteTable: () => ReturnType
/**
* Merge the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeCells()
*/
mergeCells: () => ReturnType
/**
* Split the currently selected cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.splitCell()
*/
splitCell: () => ReturnType
/**
* Toggle the header column
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderColumn()
*/
toggleHeaderColumn: () => ReturnType
/**
* Toggle the header row
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderRow()
*/
toggleHeaderRow: () => ReturnType
/**
* Toggle the header cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.toggleHeaderCell()
*/
toggleHeaderCell: () => ReturnType
/**
* Merge or split the currently selected cells
* @returns True if the command was successful, otherwise false
* @example editor.commands.mergeOrSplit()
*/
mergeOrSplit: () => ReturnType
/**
* Set a cell attribute
* @param name The attribute name
* @param value The attribute value
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellAttribute('align', 'right')
*/
setCellAttribute: (name: string, value: any) => ReturnType
/**
* Moves the selection to the next cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToNextCell()
*/
goToNextCell: () => ReturnType
/**
* Moves the selection to the previous cell
* @returns True if the command was successful, otherwise false
* @example editor.commands.goToPreviousCell()
*/
goToPreviousCell: () => ReturnType
/**
* Try to fix the table structure if necessary
* @returns True if the command was successful, otherwise false
* @example editor.commands.fixTables()
*/
fixTables: () => ReturnType
/**
* Set a cell selection inside the current table
* @param position The cell position
* @returns True if the command was successful, otherwise false
* @example editor.commands.setCellSelection({ anchorCell: 1, headCell: 2 })
*/
setCellSelection: (position: { anchorCell: number; headCell?: number }) => ReturnType
}
}
}
/**
* This extension allows you to create tables.
* @see https://www.tiptap.dev/api/nodes/table
*/
export const Table = Node.create<TableOptions>({
name: 'table',
// @ts-ignore - TODO: fix
addOptions() {
return {
HTMLAttributes: {},
resizable: false,
handleWidth: 5,
cellMinWidth: 25,
// TODO: fix
View: TableView,
lastColumnResizable: true,
allowTableNodeSelection: false
}
},
content: 'tableRow+',
tableRole: 'table',
isolating: true,
group: 'block',
parseHTML() {
return [{ tag: 'table' }]
},
renderHTML({ node, HTMLAttributes }) {
const { colgroup, tableWidth, tableMinWidth } = createColGroup(node, this.options.cellMinWidth)
const table: DOMOutputSpec = [
'table',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: tableWidth ? `width: ${tableWidth}` : `min-width: ${tableMinWidth}`
}),
colgroup,
['tbody', 0]
]
return table
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ tr, dispatch, editor }) => {
// Disallow inserting table inside nested nodes when TableCell option allowNestedNodes is false
const tableCellExtension = this.editor.extensionManager.extensions.find((ext) => ext.name === 'tableCell')
const allowNestedNodes: boolean = tableCellExtension
? Boolean((tableCellExtension.options as { allowNestedNodes?: boolean }).allowNestedNodes)
: false
if (!allowNestedNodes) {
const { $from } = tr.selection
// Only allow table insertion at top-level (depth <= 1),
// disallow when selection is inside any nested node (list, blockquote, table, etc.)
if ($from.depth > 1) {
return false
}
}
const node = createTable(editor.schema, rows, cols, withHeaderRow)
if (dispatch) {
const offset = tr.selection.from + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)))
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => {
return addColumnBefore(state, dispatch)
},
addColumnAfter:
() =>
({ state, dispatch }) => {
return addColumnAfter(state, dispatch)
},
deleteColumn:
() =>
({ state, dispatch }) => {
return deleteColumn(state, dispatch)
},
addRowBefore:
() =>
({ state, dispatch }) => {
return addRowBefore(state, dispatch)
},
addRowAfter:
() =>
({ state, dispatch }) => {
return addRowAfter(state, dispatch)
},
deleteRow:
() =>
({ state, dispatch }) => {
return deleteRow(state, dispatch)
},
deleteTable:
() =>
({ state, dispatch }) => {
return deleteTable(state, dispatch)
},
mergeCells:
() =>
({ state, dispatch }) => {
return mergeCells(state, dispatch)
},
splitCell:
() =>
({ state, dispatch }) => {
return splitCell(state, dispatch)
},
toggleHeaderColumn:
() =>
({ state, dispatch }) => {
return toggleHeader('column')(state, dispatch)
},
toggleHeaderRow:
() =>
({ state, dispatch }) => {
return toggleHeader('row')(state, dispatch)
},
toggleHeaderCell:
() =>
({ state, dispatch }) => {
return toggleHeaderCell(state, dispatch)
},
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => {
return setCellAttr(name, value)(state, dispatch)
},
goToNextCell:
() =>
({ state, dispatch }) => {
return goToNextCell(1)(state, dispatch)
},
goToPreviousCell:
() =>
({ state, dispatch }) => {
return goToNextCell(-1)(state, dispatch)
},
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell)
// @ts-ignore - TODO: fix
tr.setSelection(selection)
}
return true
}
}
},
addNodeView() {
return (props) => {
const { node, view } = props
const ViewClass = this.options.View || TableView
if (ViewClass === TableView) {
return new TableView(node, this.options.cellMinWidth, view, {
onRowActionClick: this.options.onRowActionClick,
onColumnActionClick: this.options.onColumnActionClick
})
}
return new ViewClass(node, this.options.cellMinWidth, view)
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
'Shift-Tab': () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
'Mod-Backspace': deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
'Mod-Delete': deleteTableWhenAllCellsSelected
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
return [
...(isResizable
? [
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
defaultCellMinWidth: this.options.cellMinWidth,
View: this.options.View,
lastColumnResizable: this.options.lastColumnResizable
})
]
: []),
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
})
]
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(getExtensionField(extension, 'tableRole', context))
}
}
})

View File

@ -0,0 +1,9 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
// apply the stored width unless it is below the configured minimum cell width
return ['width', `${Math.max(width, minWidth)}px`]
}
// set the minimum with on the column if it has no stored width
return ['min-width', `${minWidth}px`]
}

View File

@ -0,0 +1,12 @@
import type { Fragment, Node as ProsemirrorNode, NodeType } from '@tiptap/pm/model'
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}

View File

@ -0,0 +1,68 @@
import type { DOMOutputSpec, Node as ProseMirrorNode } from '@tiptap/pm/model'
import { getColStyleDeclaration } from './colStyle.js'
export type ColGroup =
| {
colgroup: DOMOutputSpec
tableWidth: string
tableMinWidth: string
}
| Record<string, never>
/**
* Creates a colgroup element for a table node in ProseMirror.
*
* @param node - The ProseMirror node representing the table.
* @param cellMinWidth - The minimum width of a cell in the table.
* @param overrideCol - (Optional) The index of the column to override the width of.
* @param overrideValue - (Optional) The width value to use for the overridden column.
* @returns An object containing the colgroup element, the total width of the table, and the minimum width of the table.
*/
export function createColGroup(node: ProseMirrorNode, cellMinWidth: number): ColGroup
export function createColGroup(
node: ProseMirrorNode,
cellMinWidth: number,
overrideCol: number,
overrideValue: number
): ColGroup
export function createColGroup(
node: ProseMirrorNode,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number
): ColGroup {
let totalWidth = 0
let fixedWidth = true
const cols: DOMOutputSpec[] = []
const row = node.firstChild
if (!row) {
return {}
}
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth = overrideCol === col ? overrideValue : colwidth && (colwidth[j] as number | undefined)
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
const [property, value] = getColStyleDeclaration(cellMinWidth, hasWidth)
cols.push(['col', { style: `${property}: ${value}` }])
}
}
const tableWidth = fixedWidth ? `${totalWidth}px` : ''
const tableMinWidth = fixedWidth ? '' : `${totalWidth}px`
const colgroup: DOMOutputSpec = ['colgroup', {}, ...cols]
return { colgroup, tableWidth, tableMinWidth }
}

View File

@ -0,0 +1,40 @@
import type { Fragment, Node as ProsemirrorNode, Schema } from '@tiptap/pm/model'
import { createCell } from './createCell.js'
import { getTableNodeTypes } from './getTableNodeTypes.js'
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(types.row.createChecked(null, withHeaderRow && index === 0 ? headerCells : cells))
}
return types.table.createChecked(null, rows)
}

View File

@ -0,0 +1,38 @@
import type { KeyboardShortcutCommand } from '@tiptap/core'
import { findParentNodeClosestToPos } from '@tiptap/core'
import { isCellSelection } from './isCellSelection.js'
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ editor }) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => {
return node.type.name === 'table'
})
table?.node.descendants((node) => {
if (node.type.name === 'table') {
return false
}
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
cellCount += 1
}
return true
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}

View File

@ -0,0 +1,14 @@
export function getElementBorderWidth(element: HTMLElement): {
top: number
right: number
bottom: number
left: number
} {
const style = window.getComputedStyle(element)
return {
top: parseFloat(style.borderTopWidth),
right: parseFloat(style.borderRightWidth),
bottom: parseFloat(style.borderBottomWidth),
left: parseFloat(style.borderLeftWidth)
}
}

View File

@ -0,0 +1,21 @@
import type { NodeType, Schema } from '@tiptap/pm/model'
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}

View File

@ -0,0 +1,5 @@
import { CellSelection } from '@tiptap/pm/tables'
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}

View File

@ -0,0 +1,68 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { CellSelection, TableMap } from '@tiptap/pm/tables'
import type { EditorView } from '@tiptap/pm/view'
export interface SelectionBounds {
tablePos: number
tableStart: number
map: ReturnType<typeof TableMap.get>
minRow: number
maxRow: number
minCol: number
maxCol: number
topLeftPos: number
topRightPos: number
}
/**
* Compute logical bounds for current CellSelection inside the provided table node.
* Returns null if current selection is not a CellSelection or not within the table node.
*/
export function getCellSelectionBounds(view: EditorView, tableNode: ProseMirrorNode): SelectionBounds | null {
const selection = view.state.selection
if (!(selection instanceof CellSelection)) return null
const $anchor = selection.$anchorCell || selection.$anchor
let tablePos = -1
let currentTable: ProseMirrorNode | null = null
for (let d = $anchor.depth; d > 0; d--) {
const n = $anchor.node(d)
const role = (n.type.spec as { tableRole?: string } | undefined)?.tableRole
if (n.type.name === 'table' || role === 'table') {
tablePos = $anchor.before(d)
currentTable = n
break
}
}
if (tablePos < 0 || currentTable !== tableNode) return null
const map = TableMap.get(tableNode)
const tableStart = tablePos + 1
let minRow = Number.POSITIVE_INFINITY
let maxRow = Number.NEGATIVE_INFINITY
let minCol = Number.POSITIVE_INFINITY
let maxCol = Number.NEGATIVE_INFINITY
let topLeftPos: number | null = null
let topRightPos: number | null = null
selection.forEachCell((_cell, pos) => {
const rect = map.findCell(pos - tableStart)
if (rect.top < minRow) minRow = rect.top
if (rect.left < minCol) minCol = rect.left
if (rect.bottom - 1 > maxRow) maxRow = rect.bottom - 1
if (rect.right - 1 > maxCol) maxCol = rect.right - 1
if (rect.top === minRow && rect.left === minCol) {
if (topLeftPos === null || pos < topLeftPos) topLeftPos = pos
}
if (rect.top === minRow && rect.right - 1 === maxCol) {
if (topRightPos === null || pos < topRightPos) topRightPos = pos
}
})
if (!isFinite(minRow) || !isFinite(minCol) || topLeftPos == null) return null
if (topRightPos == null) topRightPos = topLeftPos
return { tablePos, tableStart, map, minRow, maxRow, minCol, maxCol, topLeftPos, topRightPos }
}

View File

@ -0,0 +1,19 @@
import type { ParentConfig } from '@tiptap/core'
declare module '@tiptap/core' {
interface NodeConfig<Options, Storage> {
/**
* A string or function to determine the role of the table.
* @default 'table'
* @example () => 'table'
*/
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>['tableRole']
}) => string)
}
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'tsdown'
export default defineConfig(
[
'src/table/index.ts',
'src/cell/index.ts',
'src/header/index.ts',
'src/kit/index.ts',
'src/row/index.ts',
'src/index.ts'
].map((entry) => ({
entry: [entry],
tsconfig: '../../tsconfig.build.json',
outDir: `dist${entry.replace('src', '').split('/').slice(0, -1).join('/')}`,
dts: true,
sourcemap: true,
format: ['esm', 'cjs'],
external: [/^[^./]/]
}))
)

View File

@ -140,16 +140,25 @@ export enum IpcChannel {
File_Upload = 'file:upload',
File_Clear = 'file:clear',
File_Read = 'file:read',
File_ReadExternal = 'file:readExternal',
File_Delete = 'file:delete',
File_DeleteDir = 'file:deleteDir',
File_DeleteExternalFile = 'file:deleteExternalFile',
File_DeleteExternalDir = 'file:deleteExternalDir',
File_Move = 'file:move',
File_MoveDir = 'file:moveDir',
File_Rename = 'file:rename',
File_RenameDir = 'file:renameDir',
File_Get = 'file:get',
File_SelectFolder = 'file:selectFolder',
File_CreateTempFile = 'file:createTempFile',
File_Mkdir = 'file:mkdir',
File_Write = 'file:write',
File_WriteWithId = 'file:writeWithId',
File_SaveImage = 'file:saveImage',
File_Base64Image = 'file:base64Image',
File_SaveBase64Image = 'file:saveBase64Image',
File_SavePastedImage = 'file:savePastedImage',
File_Download = 'file:download',
File_Copy = 'file:copy',
File_BinaryImage = 'file:binaryImage',
@ -159,6 +168,11 @@ export enum IpcChannel {
Fs_ReadText = 'fs:readText',
File_OpenWithRelativePath = 'file:openWithRelativePath',
File_IsTextFile = 'file:isTextFile',
File_GetDirectoryStructure = 'file:getDirectoryStructure',
File_CheckFileName = 'file:checkFileName',
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
File_StartWatcher = 'file:startWatcher',
File_StopWatcher = 'file:stopWatcher',
// file service
FileService_Upload = 'file-service:upload',

View File

@ -9,3 +9,11 @@ export type LoaderReturn = {
message?: string
messageSource?: 'preprocess' | 'embedding'
}
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
export type FileChangeEvent = {
eventType: FileChangeEventType
filePath: string
watchPath: string
}

View File

@ -58,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'
@ -83,6 +91,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: getFilesDir(),
notesPath: getNotesDir(),
configPath: getConfigDir(),
appDataPath: app.getPath('userData'),
resourcesPath: getResourcePath(),
@ -430,16 +439,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))
@ -447,6 +465,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) => {

View File

@ -1,8 +1,18 @@
import { loggerService } from '@logger'
import { getFilesDir, getFileType, getTempDir, readTextFileWithAutoEncoding } from '@main/utils/file'
import {
checkName,
getFilesDir,
getFileType,
getName,
getNotesDir,
getTempDir,
readTextFileWithAutoEncoding,
scanDir
} from '@main/utils/file'
import { documentExts, imageExts, KB, MB } from '@shared/config/constant'
import { FileMetadata } from '@types'
import { FileMetadata, NotesTreeNode } from '@types'
import chardet from 'chardet'
import chokidar, { FSWatcher } from 'chokidar'
import * as crypto from 'crypto'
import {
dialog,
@ -26,9 +36,39 @@ import WordExtractor from 'word-extractor'
const logger = loggerService.withContext('FileStorage')
interface FileWatcherConfig {
watchExtensions?: string[]
ignoredPatterns?: (string | RegExp)[]
debounceMs?: number
maxDepth?: number
usePolling?: boolean
retryOnError?: boolean
retryDelayMs?: number
stabilityThreshold?: number
eventChannel?: string
}
const DEFAULT_WATCHER_CONFIG: Required<FileWatcherConfig> = {
watchExtensions: ['.md', '.markdown', '.txt'],
ignoredPatterns: [/(^|[/\\])\../, '**/node_modules/**', '**/.git/**', '**/*.tmp', '**/*.temp', '**/.DS_Store'],
debounceMs: 1000,
maxDepth: 10,
usePolling: false,
retryOnError: true,
retryDelayMs: 5000,
stabilityThreshold: 500,
eventChannel: 'file-change'
}
class FileStorage {
private storageDir = getFilesDir()
private notesDir = getNotesDir()
private tempDir = getTempDir()
private watcher?: FSWatcher
private watcherSender?: Electron.WebContents
private currentWatchPath?: string
private debounceTimer?: NodeJS.Timeout
private watcherConfig: Required<FileWatcherConfig> = DEFAULT_WATCHER_CONFIG
constructor() {
this.initStorageDir()
@ -39,6 +79,9 @@ class FileStorage {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.notesDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
}
@ -209,7 +252,7 @@ class FileStorage {
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileMetadata = {
return {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
@ -220,8 +263,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileInfo
}
// @TraceProperty({ spanName: 'deleteFile', tag: 'FileStorage' })
@ -239,6 +280,122 @@ class FileStorage {
await fs.promises.rm(path.join(this.storageDir, id), { recursive: true })
}
public deleteExternalFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
return
}
await fs.promises.rm(filePath, { force: true })
logger.debug(`External file deleted successfully: ${filePath}`)
} catch (error) {
logger.error('Failed to delete external file:', error as Error)
throw error
}
}
public deleteExternalDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
return
}
await fs.promises.rm(dirPath, { recursive: true, force: true })
logger.debug(`External directory deleted successfully: ${dirPath}`)
} catch (error) {
logger.error('Failed to delete external directory:', error as Error)
throw error
}
}
public moveFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newPath: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
// 确保目标目录存在
const destDir = path.dirname(newPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 移动文件
await fs.promises.rename(filePath, newPath)
logger.debug(`File moved successfully: ${filePath} to ${newPath}`)
} catch (error) {
logger.error('Move file failed:', error as Error)
throw error
}
}
public moveDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newDirPath: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
// 确保目标父目录存在
const parentDir = path.dirname(newDirPath)
if (!fs.existsSync(parentDir)) {
await fs.promises.mkdir(parentDir, { recursive: true })
}
// 移动目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory moved successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Move directory failed:', error as Error)
throw error
}
}
public renameFile = async (_: Electron.IpcMainInvokeEvent, filePath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(filePath)) {
throw new Error(`Source file does not exist: ${filePath}`)
}
const dirPath = path.dirname(filePath)
const newFilePath = path.join(dirPath, newName + '.md')
// 如果目标文件已存在,抛出错误
if (fs.existsSync(newFilePath)) {
throw new Error(`Target file already exists: ${newFilePath}`)
}
// 重命名文件
await fs.promises.rename(filePath, newFilePath)
logger.debug(`File renamed successfully: ${filePath} to ${newFilePath}`)
} catch (error) {
logger.error('Rename file failed:', error as Error)
throw error
}
}
public renameDir = async (_: Electron.IpcMainInvokeEvent, dirPath: string, newName: string): Promise<void> => {
try {
if (!fs.existsSync(dirPath)) {
throw new Error(`Source directory does not exist: ${dirPath}`)
}
const parentDir = path.dirname(dirPath)
const newDirPath = path.join(parentDir, newName)
// 如果目标目录已存在,抛出错误
if (fs.existsSync(newDirPath)) {
throw new Error(`Target directory already exists: ${newDirPath}`)
}
// 重命名目录
await fs.promises.rename(dirPath, newDirPath)
logger.debug(`Directory renamed successfully: ${dirPath} to ${newDirPath}`)
} catch (error) {
logger.error('Rename directory failed:', error as Error)
throw error
}
}
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
@ -282,6 +439,51 @@ class FileStorage {
}
}
public readExternalFile = async (
_: Electron.IpcMainInvokeEvent,
filePath: string,
detectEncoding: boolean = false
): Promise<string> => {
if (!fs.existsSync(filePath)) {
throw new Error(`File does not exist: ${filePath}`)
}
const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
} catch (error) {
chdir(originalCwd)
logger.error('Failed to read file:', error as Error)
throw error
}
}
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error('Failed to read file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`)
}
}
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true })
@ -298,6 +500,32 @@ class FileStorage {
await fs.promises.writeFile(filePath, data)
}
public fileNameGuard = async (
_: Electron.IpcMainInvokeEvent,
dirPath: string,
fileName: string,
isFile: boolean
): Promise<{ safeName: string; exists: boolean }> => {
const safeName = checkName(fileName)
const finalName = getName(dirPath, safeName, isFile)
const fullPath = path.join(dirPath, finalName + (isFile ? '.md' : ''))
const exists = fs.existsSync(fullPath)
logger.debug(`File name guard: ${fileName} -> ${finalName}, exists: ${exists}`)
return { safeName: finalName, exists }
}
public mkdir = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<string> => {
try {
logger.debug(`Attempting to create directory: ${dirPath}`)
await fs.promises.mkdir(dirPath, { recursive: true })
return dirPath
} catch (error) {
logger.error('Failed to create directory:', error as Error)
throw new Error(`Failed to create directory: ${dirPath}. Error: ${(error as Error).message}`)
}
}
public base64Image = async (
_: Electron.IpcMainInvokeEvent,
id: string
@ -340,7 +568,7 @@ class FileStorage {
await fs.promises.writeFile(destPath, buffer)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: uuid + ext,
name: uuid + ext,
@ -351,14 +579,84 @@ class FileStorage {
type: getFileType(ext),
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Failed to save base64 image:', error as Error)
throw error
}
}
public savePastedImage = async (
_: Electron.IpcMainInvokeEvent,
imageData: Uint8Array | Buffer,
extension?: string
): Promise<FileMetadata> => {
try {
const uuid = uuidv4()
const ext = extension || '.png'
const destPath = path.join(this.storageDir, uuid + ext)
logger.debug('Saving pasted image:', {
storageDir: this.storageDir,
destPath,
bufferSize: imageData.length
})
// 确保目录存在
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
// 确保 imageData 是 Buffer
const buffer = Buffer.isBuffer(imageData) ? imageData : Buffer.from(imageData)
// 如果图片大于1MB进行压缩处理
if (buffer.length > MB) {
await this.compressImageBuffer(buffer, destPath, ext)
} else {
await fs.promises.writeFile(destPath, buffer)
}
const stats = await fs.promises.stat(destPath)
return {
id: uuid,
origin_name: `pasted_image_${uuid}${ext}`,
name: uuid + ext,
path: destPath,
created_at: new Date().toISOString(),
size: stats.size,
ext: ext.slice(1),
type: getFileType(ext),
count: 1
}
} catch (error) {
logger.error('Failed to save pasted image:', error as Error)
throw error
}
}
private async compressImageBuffer(imageBuffer: Buffer, destPath: string, ext: string): Promise<void> {
try {
// 创建临时文件
const tempPath = path.join(this.tempDir, `temp_${uuidv4()}${ext}`)
await fs.promises.writeFile(tempPath, imageBuffer)
// 使用现有的压缩方法
await this.compressImage(tempPath, destPath)
// 清理临时文件
try {
await fs.promises.unlink(tempPath)
} catch (error) {
logger.warn('Failed to cleanup temp file:', error as Error)
}
} catch (error) {
logger.error('Image buffer compression failed, saving original:', error as Error)
// 压缩失败时保存原始文件
await fs.promises.writeFile(destPath, imageBuffer)
}
}
public base64File = async (_: Electron.IpcMainInvokeEvent, id: string): Promise<{ data: string; mime: string }> => {
const filePath = path.join(this.storageDir, id)
const buffer = await fs.promises.readFile(filePath)
@ -384,7 +682,7 @@ class FileStorage {
public clear = async (): Promise<void> => {
await fs.promises.rm(this.storageDir, { recursive: true })
await this.initStorageDir()
this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
@ -432,6 +730,7 @@ class FileStorage {
/**
* 使
* @param _
* @param file
*/
public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise<void> => {
@ -443,6 +742,79 @@ class FileStorage {
}
}
public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<NotesTreeNode[]> => {
try {
return await scanDir(dirPath)
} catch (error) {
logger.error('Failed to get directory structure:', error as Error)
throw error
}
}
public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise<boolean> => {
try {
if (!dirPath || typeof dirPath !== 'string') {
return false
}
// Normalize path
const normalizedPath = path.resolve(dirPath)
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
return false
}
// Check if it's actually a directory
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
return false
}
// Get app paths to prevent selection of restricted directories
const appDataPath = path.resolve(process.env.APPDATA || path.join(require('os').homedir(), '.config'))
const filesDir = path.resolve(getFilesDir())
const currentNotesDir = path.resolve(getNotesDir())
// Prevent selecting app data directories
if (
normalizedPath.startsWith(filesDir) ||
normalizedPath.startsWith(appDataPath) ||
normalizedPath === currentNotesDir
) {
logger.warn(`Invalid directory selection: ${normalizedPath} (app data directory)`)
return false
}
// Prevent selecting system root directories
const isSystemRoot =
process.platform === 'win32'
? /^[a-zA-Z]:[\\/]?$/.test(normalizedPath)
: normalizedPath === '/' ||
normalizedPath === '/usr' ||
normalizedPath === '/etc' ||
normalizedPath === '/System'
if (isSystemRoot) {
logger.warn(`Invalid directory selection: ${normalizedPath} (system root directory)`)
return false
}
// Check write permissions
try {
fs.accessSync(normalizedPath, fs.constants.W_OK)
} catch (error) {
logger.warn(`Directory not writable: ${normalizedPath}`)
return false
}
return true
} catch (error) {
logger.error('Failed to validate notes directory:', error as Error)
return false
}
}
public save = async (
_: Electron.IpcMainInvokeEvent,
fileName: string,
@ -461,7 +833,7 @@ class FileStorage {
}
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
@ -552,7 +924,7 @@ class FileStorage {
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileMetadata = {
return {
id: uuid,
origin_name: filename,
name: uuid + ext,
@ -563,8 +935,6 @@ class FileStorage {
type: fileType,
count: 1
}
return fileMetadata
} catch (error) {
logger.error('Download file error:', error as Error)
throw error
@ -629,6 +999,205 @@ class FileStorage {
}
}
public startFileWatcher = async (
event: Electron.IpcMainInvokeEvent,
dirPath: string,
config?: FileWatcherConfig
): Promise<void> => {
try {
this.watcherConfig = { ...DEFAULT_WATCHER_CONFIG, ...config }
if (!dirPath?.trim()) {
throw new Error('Directory path is required')
}
const normalizedPath = path.resolve(dirPath.trim())
if (!fs.existsSync(normalizedPath)) {
throw new Error(`Directory does not exist: ${normalizedPath}`)
}
const stats = fs.statSync(normalizedPath)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${normalizedPath}`)
}
if (this.currentWatchPath === normalizedPath && this.watcher) {
this.watcherSender = event.sender
logger.debug('Already watching directory, updated sender', { path: normalizedPath })
return
}
await this.stopFileWatcher()
logger.info('Starting file watcher', {
path: normalizedPath,
config: {
extensions: this.watcherConfig.watchExtensions,
debounceMs: this.watcherConfig.debounceMs,
maxDepth: this.watcherConfig.maxDepth
}
})
this.currentWatchPath = normalizedPath
this.watcherSender = event.sender
const watchOptions = {
ignored: this.watcherConfig.ignoredPatterns,
persistent: true,
ignoreInitial: true,
depth: this.watcherConfig.maxDepth,
usePolling: this.watcherConfig.usePolling,
awaitWriteFinish: {
stabilityThreshold: this.watcherConfig.stabilityThreshold,
pollInterval: 100
},
alwaysStat: false,
atomic: true
}
this.watcher = chokidar.watch(normalizedPath, watchOptions)
const handleChange = this.createChangeHandler()
this.watcher
.on('add', (filePath: string) => handleChange('add', filePath))
.on('unlink', (filePath: string) => handleChange('unlink', filePath))
.on('addDir', (dirPath: string) => handleChange('addDir', dirPath))
.on('unlinkDir', (dirPath: string) => handleChange('unlinkDir', dirPath))
.on('error', (error: unknown) => {
logger.error('File watcher error', { error: error as Error, path: normalizedPath })
if (this.watcherConfig.retryOnError) {
this.handleWatcherError(error as Error)
}
})
.on('ready', () => {
logger.debug('File watcher ready', { path: normalizedPath })
})
logger.info('File watcher started successfully')
} catch (error) {
logger.error('Failed to start file watcher', error as Error)
this.cleanup()
throw error
}
}
private createChangeHandler() {
return (eventType: string, filePath: string) => {
if (!this.shouldWatchFile(filePath, eventType)) {
return
}
logger.debug('File change detected', { eventType, filePath, path: this.currentWatchPath })
// 对于目录操作,立即触发同步,不使用防抖
if (eventType === 'addDir' || eventType === 'unlinkDir') {
logger.debug('Directory operation detected, triggering immediate sync', { eventType, filePath })
this.notifyChange(eventType, filePath)
return
}
// 对于文件操作,使用防抖机制
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.notifyChange(eventType, filePath)
this.debounceTimer = undefined
}, this.watcherConfig.debounceMs)
}
}
private shouldWatchFile(filePath: string, eventType: string): boolean {
if (eventType.includes('Dir')) {
return true
}
const ext = path.extname(filePath).toLowerCase()
return this.watcherConfig.watchExtensions.includes(ext)
}
private notifyChange(eventType: string, filePath: string) {
try {
if (!this.watcherSender || this.watcherSender.isDestroyed()) {
logger.warn('Sender destroyed, stopping watcher')
this.stopFileWatcher()
return
}
logger.debug('Sending file change event', {
eventType,
filePath,
channel: this.watcherConfig.eventChannel,
senderExists: !!this.watcherSender,
senderDestroyed: this.watcherSender.isDestroyed()
})
this.watcherSender.send(this.watcherConfig.eventChannel, {
eventType,
filePath,
watchPath: this.currentWatchPath
})
logger.debug('File change event sent successfully')
} catch (error) {
logger.error('Failed to send notification', error as Error)
}
}
private handleWatcherError(error: Error) {
const retryableErrors = ['EMFILE', 'ENFILE', 'ENOSPC']
const isRetryable = retryableErrors.some((code) => error.message.includes(code))
if (isRetryable && this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
logger.warn('Attempting restart due to recoverable error', { error: error.message })
setTimeout(async () => {
try {
if (this.currentWatchPath && this.watcherSender && !this.watcherSender.isDestroyed()) {
const mockEvent = { sender: this.watcherSender } as Electron.IpcMainInvokeEvent
await this.startFileWatcher(mockEvent, this.currentWatchPath, this.watcherConfig)
}
} catch (retryError) {
logger.error('Restart failed', retryError as Error)
}
}, this.watcherConfig.retryDelayMs)
}
}
private cleanup() {
this.currentWatchPath = undefined
this.watcherSender = undefined
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = undefined
}
}
public stopFileWatcher = async (): Promise<void> => {
try {
if (this.watcher) {
logger.info('Stopping file watcher', { path: this.currentWatchPath })
await this.watcher.close()
this.watcher = undefined
logger.debug('File watcher stopped')
}
this.cleanup()
} catch (error) {
logger.error('Failed to stop file watcher', error as Error)
this.watcher = undefined
this.cleanup()
}
}
public getWatcherStatus(): { isActive: boolean; watchPath?: string; hasValidSender: boolean } {
return {
isActive: !!this.watcher,
watchPath: this.currentWatchPath,
hasValidSender: !!this.watcherSender && !this.watcherSender.isDestroyed()
}
}
public getFilePathById(file: FileMetadata): string {
return path.join(this.storageDir, file.id + file.ext)
}

View File

@ -5,7 +5,7 @@ import path from 'node:path'
import { loggerService } from '@logger'
import { audioExts, documentExts, imageExts, MB, textExts, videoExts } from '@shared/config/constant'
import { FileMetadata, FileTypes } from '@types'
import { FileMetadata, FileTypes, NotesTreeNode } from '@types'
import chardet from 'chardet'
import { app } from 'electron'
import iconv from 'iconv-lite'
@ -148,6 +148,15 @@ export function getFilesDir() {
return path.join(app.getPath('userData'), 'Data', 'Files')
}
export function getNotesDir() {
const notesDir = path.join(app.getPath('userData'), 'Data', 'Notes')
if (!fs.existsSync(notesDir)) {
fs.mkdirSync(notesDir, { recursive: true })
logger.info(`Notes directory created at: ${notesDir}`)
}
return notesDir
}
export function getConfigDir() {
return path.join(os.homedir(), '.cherrystudio', 'config')
}
@ -195,3 +204,216 @@ 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 sanitizedName = sanitizeFilename(fileName)
const baseName = sanitizedName.replace(/\d+$/, '')
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
}

View File

@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
import { SpanContext } from '@opentelemetry/api'
import { UpgradeChannel } from '@shared/config/constant'
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import type { FileChangeEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import {
AddMemoryOptions,
@ -141,33 +142,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 +176,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),

View File

@ -2,6 +2,7 @@ import '@renderer/databases'
import { loggerService } from '@logger'
import store, { persistor } from '@renderer/store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
@ -15,26 +16,38 @@ import Router from './Router'
const logger = loggerService.withContext('App.tsx')
// 创建 React Query 客户端
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false
}
}
})
function App(): React.ReactElement {
logger.info('App initialized')
return (
<Provider store={store}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<QueryClientProvider client={queryClient}>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
</QueryClientProvider>
</Provider>
)
}

View File

@ -15,6 +15,7 @@ import HomePage from './pages/home/HomePage'
import KnowledgePage from './pages/knowledge/KnowledgePage'
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@ -31,6 +32,7 @@ const Router: FC = () => {
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<MinAppsPage />} />
<Route path="/code" element={<CodeToolsPage />} />

View File

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

View File

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

View File

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

View File

@ -0,0 +1,493 @@
.tiptap {
padding: 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;
}
}
/* 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;
}
}
// 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;
}
}

View File

@ -345,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);

View File

@ -18,6 +18,15 @@ interface Props {
filter: NodeFilter
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
/**
* true
*
*/
showUserToggle?: boolean
/**
*
*/
positionMode?: 'fixed' | 'absolute' | 'sticky'
}
enum SearchCompletedState {
@ -125,7 +134,10 @@ const findRangesInTarget = (
// eslint-disable-next-line @eslint-react/no-forward-ref
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
(
{ searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' },
ref
) => {
const target: HTMLElement | null = (() => {
if (searchTarget instanceof HTMLElement) {
return searchTarget
@ -335,9 +347,12 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
}
return (
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
<Container
ref={containerRef}
style={enableContentSearch ? {} : { display: 'none' }}
$overlayPosition={positionMode === 'absolute' ? 'absolute' : 'static'}>
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer>
<SearchBarContainer $position={positionMode}>
<InputWrapper>
<Input
ref={searchInputRef}
@ -347,11 +362,13 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
style={{ lineHeight: '20px' }}
/>
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
{showUserToggle && (
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
@ -400,17 +417,21 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
ContentSearch.displayName = 'ContentSearch'
const Container = styled.div`
const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>`
display: flex;
flex-direction: row;
z-index: 2;
position: ${({ $overlayPosition }) => $overlayPosition};
top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
z-index: 999;
`
const SearchBarContainer = styled.div`
const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>`
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.2s ease;
position: fixed;
position: ${({ $position }) => $position};
top: 15px;
left: 20px;
right: 20px;

View File

@ -0,0 +1,159 @@
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}
centered>
<EditorContainer>
<RichEditor
ref={editorRef}
initialContent={content}
placeholder={t('richEditor.placeholder')}
onContentChange={handleContentChange}
onMarkdownChange={handleMarkdownChange}
onCommandsReady={handleCommandsReady}
minHeight={300}
maxHeight={500}
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
)
})
}
}

View File

@ -4,6 +4,7 @@ import { TopView } from '@renderer/components/TopView'
import { useKnowledge, useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { Topic } from '@renderer/types'
import { Message } from '@renderer/types/newMessage'
import { NotesTreeNode } from '@renderer/types/note'
import {
analyzeMessageContent,
analyzeTopicContent,
@ -77,7 +78,10 @@ interface ContentTypeOption {
description: string
}
type ContentSource = { type: 'message'; data: Message } | { type: 'topic'; data: Topic }
type ContentSource =
| { type: 'message'; data: Message }
| { type: 'topic'; data: Topic }
| { type: 'note'; data: NotesTreeNode }
interface ShowParams {
source: ContentSource
@ -106,10 +110,16 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
const { t } = useTranslation()
const isTopicMode = source?.type === 'topic'
const isNoteMode = source?.type === 'note'
// 异步分析内容统计
useEffect(() => {
const analyze = async () => {
if (isNoteMode) {
setAnalysisLoading(false)
return
}
setAnalysisLoading(true)
setContentStats(null)
try {
@ -136,11 +146,11 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
}
}
analyze()
}, [source, isTopicMode])
}, [source, isTopicMode, isNoteMode])
// 生成内容类型选项
const contentTypeOptions: ContentTypeOption[] = useMemo(() => {
if (!contentStats) return []
if (!contentStats || isNoteMode) return []
return Object.entries(CONTENT_TYPE_CONFIG)
.map(([type, config]) => {
@ -159,7 +169,7 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
}
})
.filter((option) => option.enabled)
}, [contentStats, t, isTopicMode])
}, [contentStats, t, isTopicMode, isNoteMode])
// 知识库选项
const knowledgeBaseOptions = useMemo(
@ -175,19 +185,24 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
// 表单状态
const formState = useMemo(() => {
const hasValidBase = selectedBaseId && bases.find((base) => base.id === selectedBaseId)?.version
const hasContent = contentTypeOptions.length > 0
const selectedCount = contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
const hasContent = isNoteMode || contentTypeOptions.length > 0
const canSubmit = hasValidBase && (isNoteMode || (selectedTypes.length > 0 && hasContent))
const selectedCount = isNoteMode
? 1
: contentTypeOptions
.filter((option) => selectedTypes.includes(option.type))
.reduce((sum, option) => sum + option.count, 0)
return {
hasValidBase,
hasContent,
canSubmit: hasValidBase && selectedTypes.length > 0 && hasContent,
canSubmit,
selectedCount,
hasNoSelection: selectedTypes.length === 0 && hasContent
hasNoSelection: !isNoteMode && selectedTypes.length === 0 && hasContent
}
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes])
}, [selectedBaseId, bases, contentTypeOptions, selectedTypes, isNoteMode])
// 默认选择第一个可用知识库
useEffect(() => {
@ -201,28 +216,31 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
// 默认选择所有可用内容类型
useEffect(() => {
if (!hasInitialized && contentTypeOptions.length > 0) {
if (!hasInitialized && contentTypeOptions.length > 0 && !isNoteMode) {
setSelectedTypes(contentTypeOptions.map((option) => option.type))
setHasInitialized(true)
}
}, [contentTypeOptions, hasInitialized])
}, [contentTypeOptions, hasInitialized, isNoteMode])
// UI状态
const uiState = useMemo(() => {
if (analysisLoading) {
return { type: 'loading', message: t('chat.save.topic.knowledge.loading') }
}
if (!formState.hasContent) {
if (!formState.hasContent && !isNoteMode) {
return {
type: 'empty',
message: t(isTopicMode ? 'chat.save.topic.knowledge.empty.no_content' : 'chat.save.knowledge.empty.no_content')
}
}
if (bases.length === 0) {
return { type: 'empty', message: t('chat.save.knowledge.empty.no_knowledge_base') }
}
return { type: 'form' }
}, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode])
}, [analysisLoading, formState.hasContent, bases.length, t, isTopicMode, isNoteMode])
const handleContentTypeToggle = (type: ContentType) => {
setSelectedTypes((prev) => (prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]))
@ -235,18 +253,28 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
let savedCount = 0
try {
const result = isTopicMode
? await processTopicContent(source?.data as Topic, selectedTypes)
: processMessageContent(source?.data as Message, selectedTypes)
if (isNoteMode) {
const note = source.data as NotesTreeNode
const content = await window.api.file.read(note.id + '.md')
logger.debug('Note content:', content)
await addNote(content)
savedCount = 1
} else {
// 原有的消息或主题处理逻辑
const result = isTopicMode
? await processTopicContent(source?.data as Topic, selectedTypes)
: processMessageContent(source?.data as Message, selectedTypes)
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
logger.debug('Processed content:', result)
if (result.text.trim() && selectedTypes.some((type) => type !== CONTENT_TYPES.FILE)) {
await addNote(result.text)
savedCount++
}
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
if (result.files.length > 0 && selectedTypes.includes(CONTENT_TYPES.FILE)) {
addFiles(result.files)
savedCount += result.files.length
}
}
setOpen(false)
@ -285,66 +313,81 @@ const PopupContainer: React.FC<Props> = ({ source, title, resolve }) => {
/>
</Form.Item>
<Form.Item
label={t(
isTopicMode ? 'chat.save.topic.knowledge.select.content.label' : 'chat.save.knowledge.select.content.title'
)}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
{!isNoteMode && (
<Form.Item
label={t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.label'
: 'chat.save.knowledge.select.content.title'
)}>
<Flex gap={8} style={{ flexDirection: 'column' }}>
{contentTypeOptions.map((option) => (
<ContentTypeItem
key={option.type}
align="center"
justify="space-between"
onClick={() => handleContentTypeToggle(option.type)}>
<Flex align="center" gap={8}>
<CustomTag
color={selectedTypes.includes(option.type) ? TAG_COLORS.SELECTED : TAG_COLORS.UNSELECTED}
size={12}>
{option.count}
</CustomTag>
<span>{option.label}</span>
<Tooltip title={option.description} mouseLeaveDelay={0}>
<CircleHelp size={16} style={{ cursor: 'help' }} />
</Tooltip>
</Flex>
{selectedTypes.includes(option.type) && <Check size={16} color={TAG_COLORS.SELECTED} />}
</ContentTypeItem>
))}
</Flex>
</Form.Item>
)}
</Form>
<InfoContainer>
{formState.selectedCount > 0 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.selected_tip'
: 'chat.save.knowledge.select.content.tip',
{
count: formState.selectedCount,
...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 })
}
)}
</Text>
)}
{formState.hasNoSelection && (
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
)}
{!formState.hasNoSelection && formState.selectedCount === 0 && (
<Text type="secondary" style={{ fontSize: '12px', opacity: 0 }}>
&nbsp;
</Text>
)}
</InfoContainer>
{!isNoteMode && (
<InfoContainer>
{formState.selectedCount > 0 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{t(
isTopicMode
? 'chat.save.topic.knowledge.select.content.selected_tip'
: 'chat.save.knowledge.select.content.tip',
{
count: formState.selectedCount,
...(isTopicMode && { messages: (contentStats as TopicContentStats)?.messages || 0 })
}
)}
</Text>
)}
{formState.hasNoSelection && (
<Text type="warning" style={{ fontSize: '12px' }}>
{t('chat.save.knowledge.error.no_content_selected')}
</Text>
)}
{!formState.hasNoSelection && formState.selectedCount === 0 && (
<Text type="secondary" style={{ fontSize: '12px', opacity: 0 }}>
&nbsp;
</Text>
)}
</InfoContainer>
)}
</>
)
return (
<Modal
title={title || t(isTopicMode ? 'chat.save.topic.knowledge.title' : 'chat.save.knowledge.title')}
title={
title ||
t(
isNoteMode
? 'notes.export_knowledge'
: isTopicMode
? 'chat.save.topic.knowledge.title'
: 'chat.save.knowledge.title'
)
}
open={open}
onOk={onOk}
onCancel={onCancel}
@ -389,6 +432,10 @@ export default class SaveToKnowledgePopup {
static showForTopic(topic: Topic, title?: string): Promise<SaveResult | null> {
return this.show({ source: { type: 'topic', data: topic }, title })
}
static showForNote(note: NotesTreeNode, title?: string): Promise<SaveResult | null> {
return this.show({ source: { type: 'note', data: note }, title })
}
}
const EmptyContainer = styled.div`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}
}
// 注意:更多格式化操作可以在后续版本中添加
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { CodeBlockShiki } from './code-block-shiki.js'
export * from './code-block-shiki'
export default CodeBlockShiki

View File

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

View File

@ -0,0 +1,461 @@
// import type { Editor } from '@tiptap/core'
// import type { Node } from '@tiptap/pm/model'
// import { allActions } from '../components/dragContextMenu/actions'
// // 全局状态存储
// let currentEditor: Editor | null = null
// let currentMenuElement: HTMLElement | null = null
// let isMenuVisible = false
/**
*
*/
// function createContextMenu(editor: Editor, node: Node, position: number): HTMLElement {
// const menu = document.createElement('div')
// menu.className = 'drag-context-menu'
// menu.style.cssText = `
// position: fixed;
// background: var(--color-bg-base);
// border: 1px solid var(--color-border);
// border-radius: 8px;
// box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.04);
// padding: 8px 0;
// min-width: 240px;
// max-width: 320px;
// z-index: 2000;
// opacity: 0;
// transform: translateY(-8px);
// transition: all 0.2s ease;
// font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
// `
// // 获取适用于当前节点的操作
// const availableActions = allActions.filter((action) => action.isEnabled(editor, node, position))
// logger.debug('Available actions', {
// total: allActions.length,
// available: availableActions.length,
// nodeType: node.type.name,
// actions: availableActions.map((a) => a.id)
// })
// // 按组分类操作 - 重新组织为符合参考图的结构
// const actionGroups = {
// format: availableActions.filter((a) => a.group === 'format'),
// turnInto: availableActions.filter((a) => a.group === 'transform'),
// actions: availableActions.filter((a) => a.group === 'block' || a.group === 'insert')
// }
// // 渲染操作组 - 使用参考图中的标签
// const groupLabels = {
// format: '', // 格式化组不显示标题
// turnInto: 'Turn Into',
// actions: '' // actions 组不显示标题,直接显示操作
// }
// Object.entries(actionGroups).forEach(([groupKey, actions]) => {
// if (actions.length === 0) return
// const group = groupKey as keyof typeof actionGroups
// // 组标题和分隔线
// if (menu.children.length > 0) {
// const divider = document.createElement('div')
// divider.style.cssText = 'height: 1px; background: var(--color-border-secondary); margin: 8px 0;'
// menu.appendChild(divider)
// }
// // 只为有标题的组显示标题
// if (groupLabels[group]) {
// const groupTitle = document.createElement('div')
// groupTitle.textContent = groupLabels[group]
// groupTitle.style.cssText = `
// padding: 8px 16px 4px;
// font-size: 13px;
// font-weight: 500;
// color: var(--color-text-2);
// margin-bottom: 4px;
// `
// menu.appendChild(groupTitle)
// }
// // 操作项
// actions.forEach((action) => {
// const item = document.createElement('button')
// item.className = 'menu-item'
// item.style.cssText = `
// width: 100%;
// display: flex;
// align-items: center;
// padding: 10px 16px;
// border: none;
// background: transparent;
// color: ${action.danger ? 'var(--color-error)' : 'var(--color-text)'};
// font-size: 14px;
// text-align: left;
// cursor: pointer;
// transition: background-color 0.15s ease;
// gap: 12px;
// border-radius: 6px;
// margin: 0 4px;
// `
// // 图标映射
// const getIconSvg = (actionId: string) => {
// const iconMap: Record<string, string> = {
// 'format-color':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5"/><circle cx="17.5" cy="10.5" r=".5"/><circle cx="8.5" cy="7.5" r=".5"/><circle cx="6.5" cy="11.5" r=".5"/><circle cx="12.5" cy="13.5" r=".5"/><circle cx="13.5" cy="17.5" r=".5"/><circle cx="10.5" cy="16.5" r=".5"/><circle cx="15.5" cy="14.5" r=".5"/><circle cx="9.5" cy="12.5" r=".5"/><circle cx="7.5" cy="15.5" r=".5"/><circle cx="11.5" cy="18.5" r=".5"/><circle cx="14.5" cy="20.5" r=".5"/></svg>',
// 'format-reset':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>',
// 'transform-heading-1':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-heading-2':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-heading-3':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l10 10.5 6-6.5"/><path d="M14 7h7"/></svg>',
// 'transform-paragraph':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,7 4,4 20,4 20,7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>',
// 'transform-bullet-list':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
// 'transform-ordered-list':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>',
// 'transform-blockquote':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>',
// 'transform-code-block':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16,18 22,12 16,6"/><polyline points="8,6 2,12 8,18"/></svg>',
// 'block-copy':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
// 'block-duplicate':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M16 12l-4 4-4-4"/></svg>',
// 'block-delete':
// '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'
// }
// return iconMap[actionId] || ''
// }
// // 图标、标签和右箭头Turn Into 组需要箭头)
// const showArrow = group === 'turnInto'
// const iconSvg = getIconSvg(action.id)
// const content = `
// ${iconSvg ? '<span class="menu-icon" style="width: 20px; height: 20px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;">' + iconSvg + '</span>' : ''}
// <span style="flex: 1; font-weight: 400;">${action.label}</span>
// ${showArrow ? '<span style="width: 16px; height: 16px; flex-shrink: 0; opacity: 0.5;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg></span>' : ''}
// `
// item.innerHTML = content
// // 悬停效果
// item.addEventListener('mouseenter', () => {
// item.style.background = action.danger ? 'var(--color-error-bg)' : 'var(--color-hover)'
// })
// item.addEventListener('mouseleave', () => {
// item.style.background = 'transparent'
// })
// // 点击处理
// item.addEventListener('click', (e) => {
// e.preventDefault()
// e.stopPropagation()
// try {
// action.execute(editor, node, position)
// hideContextMenu()
// logger.debug('Action executed', { actionId: action.id })
// } catch (error) {
// logger.error('Failed to execute action', error as Error)
// hideContextMenu()
// }
// })
// menu.appendChild(item)
// })
// })
// // 如果没有任何操作,添加一个提示
// if (menu.children.length === 0) {
// const emptyItem = document.createElement('div')
// emptyItem.textContent = 'No actions available'
// emptyItem.style.cssText = `
// padding: 12px 16px;
// color: var(--color-text-3);
// font-style: italic;
// text-align: center;
// `
// menu.appendChild(emptyItem)
// }
// logger.debug('Context menu created', {
// childrenCount: menu.children.length,
// hasActions: availableActions.length > 0
// })
// return menu
// }
/**
*
*/
// function showContextMenu(editor: Editor, node: Node, position: number, clientX: number, clientY: number) {
// logger.debug('showContextMenu called', {
// nodeType: node.type.name,
// position,
// clientX,
// clientY
// })
// hideContextMenu() // 先隐藏现有菜单
// currentEditor = editor
// currentMenuElement = createContextMenu(editor, node, position)
// // 添加到 body
// document.body.appendChild(currentMenuElement)
// // 计算位置
// const rect = currentMenuElement.getBoundingClientRect()
// let x = clientX + 10
// let y = clientY
// // 边界检测
// if (x + rect.width > window.innerWidth) {
// x = clientX - rect.width - 10
// }
// if (y + rect.height > window.innerHeight) {
// y = window.innerHeight - rect.height - 10
// }
// currentMenuElement.style.left = `${x}px`
// currentMenuElement.style.top = `${y}px`
// // 显示动画
// requestAnimationFrame(() => {
// if (currentMenuElement) {
// currentMenuElement.style.opacity = '1'
// currentMenuElement.style.transform = 'translateY(0)'
// isMenuVisible = true
// }
// })
// // 全局点击关闭
// const handleClickOutside = (e: MouseEvent) => {
// if (currentMenuElement && !currentMenuElement.contains(e.target as HTMLElement)) {
// hideContextMenu()
// }
// }
// // ESC 键关闭
// const handleEscape = (e: KeyboardEvent) => {
// if (e.key === 'Escape') {
// hideContextMenu()
// }
// }
// setTimeout(() => {
// document.addEventListener('click', handleClickOutside)
// document.addEventListener('keydown', handleEscape)
// }, 0)
// }
/**
*
*/
// function hideContextMenu() {
// if (currentMenuElement && isMenuVisible) {
// currentMenuElement.style.opacity = '0'
// currentMenuElement.style.transform = 'translateY(-10px)'
// setTimeout(() => {
// if (currentMenuElement && document.body.contains(currentMenuElement)) {
// document.body.removeChild(currentMenuElement)
// }
// currentMenuElement = null
// currentEditor = null
// isMenuVisible = false
// }, 150)
// // 移除事件监听器
// document.removeEventListener('click', () => {})
// document.removeEventListener('keydown', () => {})
// }
// }
// /**
// * 拖拽上下文菜单扩展
// */
// export const DragContextMenuExtension = DragHandle.extend({
// name: 'dragContextMenu',
// addOptions() {
// return {
// render: () => {
// // 创建拖拽手柄容器
// const wrapper = document.createElement('div')
// wrapper.className = 'drag-handle-wrapper'
// wrapper.style.cssText = `
// display: flex;
// align-items: center;
// gap: 0.25rem;
// opacity: 0;
// transition: opacity 0.15s ease;
// z-index: 10;
// `
// // 加号按钮 - 使用React组件和ProseMirror plugin
// let plusButtonElement: HTMLElement | null = null
// // 拖拽手柄
// const dragHandle = document.createElement('div')
// dragHandle.className = 'drag-handle'
// dragHandle.innerHTML = `
// <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
// <circle cx="9" cy="12" r="1" fill="currentColor"/>
// <circle cx="15" cy="12" r="1" fill="currentColor"/>
// <circle cx="9" cy="6" r="1" fill="currentColor"/>
// <circle cx="15" cy="6" r="1" fill="currentColor"/>
// <circle cx="9" cy="18" r="1" fill="currentColor"/>
// <circle cx="15" cy="18" r="1" fill="currentColor"/>
// </svg>
// `
// dragHandle.style.cssText = `
// display: flex;
// align-items: center;
// justify-content: center;
// width: 1.5rem;
// height: 1.5rem;
// border-radius: 0.25rem;
// background: var(--color-background);
// color: var(--color-text-3);
// cursor: grab;
// transition: background 0.15s ease;
// pointer-events: auto;
// user-select: none;
// `
// // 悬停效果
// const addHoverEffect = (element: HTMLElement) => {
// element.addEventListener('mouseenter', () => {
// element.style.background = 'var(--color-hover)'
// })
// element.addEventListener('mouseleave', () => {
// element.style.background = 'var(--color-background)'
// })
// }
// addHoverEffect(dragHandle)
// // 显示/隐藏逻辑
// const showControls = () => {
// wrapper.style.opacity = '1'
// }
// const hideControls = () => {
// wrapper.style.opacity = '0'
// }
// // 点击事件 - 显示上下文菜单
// // dragHandle.addEventListener('click', (e) => {
// // e.preventDefault()
// // e.stopPropagation()
// // logger.debug('Drag handle clicked', {
// // currentEditor: !!currentEditor,
// // parentElement: !!wrapper.parentElement,
// // grandParent: !!wrapper.parentElement?.parentElement
// // })
// // // 获取与此拖拽手柄相关的节点
// // if (currentEditor && wrapper.parentElement) {
// // try {
// // // 找到关联的块级元素
// // const blockElement = wrapper.parentElement.parentElement
// // if (blockElement) {
// // logger.debug('Found block element', {
// // tagName: blockElement.tagName,
// // className: blockElement.className
// // })
// // const pos = currentEditor.view.posAtDOM(blockElement, 0)
// // logger.debug('Position from DOM', { pos })
// // // 检查位置是否有效
// // if (pos < 0) {
// // logger.warn('Invalid position from DOM, using selection position')
// // // 使用当前选择位置作为后备
// // const selectionPos = currentEditor.state.selection.from
// // const resolvedPos = currentEditor.state.doc.resolve(selectionPos)
// // const node = resolvedPos.parent
// // showContextMenu(currentEditor, node, selectionPos, e.clientX, e.clientY)
// // return
// // }
// // const resolvedPos = currentEditor.state.doc.resolve(pos)
// // logger.debug('Position info', {
// // pos,
// // depth: resolvedPos.depth,
// // parentType: resolvedPos.parent.type.name
// // })
// // // 找到块级节点
// // let node = resolvedPos.parent
// // let nodePos = pos
// // for (let depth = resolvedPos.depth; depth >= 0; depth--) {
// // const nodeAtDepth = resolvedPos.node(depth)
// // if (nodeAtDepth.isBlock && depth > 0) {
// // node = nodeAtDepth
// // nodePos = resolvedPos.start(depth)
// // break
// // }
// // }
// // logger.debug('Showing context menu', {
// // nodeType: node.type.name,
// // nodePos,
// // clientX: e.clientX,
// // clientY: e.clientY
// // })
// // showContextMenu(currentEditor, node, nodePos, e.clientX, e.clientY)
// // } else {
// // logger.warn('Block element not found')
// // }
// // } catch (error) {
// // logger.error('Failed to show context menu', error as Error)
// // }
// // } else {
// // logger.warn('Missing editor or parent element', {
// // hasEditor: !!currentEditor,
// // hasParent: !!wrapper.parentElement
// // })
// // }
// // })
// // 设置块级悬停监听器
// setTimeout(() => {
// const blockElement = wrapper.parentElement?.parentElement
// if (blockElement) {
// blockElement.addEventListener('mouseenter', showControls)
// blockElement.addEventListener('mouseleave', () => {
// hideControls()
// cleanup()
// })
// }
// }, 0)
// // 只添加 drag handleplus button 会在 showControls 中动态创建
// wrapper.appendChild(dragHandle)
// return wrapper
// }
// // onNodeChange: ({ editor }) => {
// // currentEditor = editor
// // logger.debug('onNodeChange - editor set', { hasEditor: !!editor })
// // }
// }
// }
// })
// export default DragContextMenuExtension

View File

@ -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: {}
})
}
}
}
})
]
}
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
// ported from https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-drag-handle/src/helpers/getOuterNode.ts
import type { Node } from '@tiptap/pm/model'
export const getOuterNodePos = (doc: Node, pos: number): number => {
const resolvedPos = doc.resolve(pos)
const { depth } = resolvedPos
if (depth === 0) {
return pos
}
const a = resolvedPos.pos - resolvedPos.parentOffset
return a - 1
}
export const getOuterNode = (doc: Node, pos: number): Node | null => {
const node = doc.nodeAt(pos)
const resolvedPos = doc.resolve(pos)
let { depth } = resolvedPos
let parent = node
while (depth > 0) {
const currentNode = resolvedPos.node(depth)
depth -= 1
if (depth === 0) {
parent = currentNode
}
}
return parent
}

View File

@ -0,0 +1,129 @@
/**
*
*/
export interface ImageCompressionOptions {
maxWidth?: number
maxHeight?: number
quality?: number
outputFormat?: 'jpeg' | 'png' | 'webp'
}
/**
*
* @param file
* @param options
* @returns Blob
*/
export async function compressImage(file: File, options: ImageCompressionOptions = {}): Promise<Blob> {
const { maxWidth = 1200, maxHeight = 1200, quality = 0.8, outputFormat = 'jpeg' } = options
return new Promise((resolve, reject) => {
const img = new Image()
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法获取 Canvas 上下文'))
return
}
img.onload = () => {
// 计算压缩后的尺寸
let { width, height } = img
const aspectRatio = width / height
if (width > maxWidth) {
width = maxWidth
height = width / aspectRatio
}
if (height > maxHeight) {
height = maxHeight
width = height * aspectRatio
}
// 设置 canvas 尺寸
canvas.width = width
canvas.height = height
// 绘制压缩后的图片
ctx.drawImage(img, 0, 0, width, height)
// 转换为 Blob
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('图片压缩失败'))
}
},
outputFormat === 'png' ? 'image/png' : `image/${outputFormat}`,
quality
)
}
img.onerror = () => {
reject(new Error('图片加载失败'))
}
// 加载图片
img.src = URL.createObjectURL(file)
})
}
/**
*
* @param file
* @param maxSize 1MB
* @returns
*/
export function shouldCompressImage(file: File, maxSize: number = 1024 * 1024): boolean {
return file.size > maxSize && file.type.startsWith('image/')
}
/**
*
* @param file
* @returns
*/
export async function getImageInfo(file: File): Promise<{
width: number
height: number
size: number
type: string
}> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
resolve({
width: img.width,
height: img.height,
size: file.size,
type: file.type
})
}
img.onerror = () => {
reject(new Error('无法加载图片'))
}
img.src = URL.createObjectURL(file)
})
}
/**
* Blob ArrayBuffer
* @param blob Blob
* @returns ArrayBuffer
*/
export async function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error('读取 Blob 失败'))
reader.readAsArrayBuffer(blob)
})
}

View File

@ -0,0 +1,4 @@
// ported from https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-drag-handle/src/helpers/removeNode.ts
export function removeNode(node: HTMLElement) {
node.parentNode?.removeChild(node)
}

View File

@ -0,0 +1,451 @@
import { ContentSearch, type ContentSearchRef } from '@renderer/components/ContentSearch'
import DragHandle from '@tiptap/extension-drag-handle-react'
import { EditorContent } from '@tiptap/react'
import { Tooltip } from 'antd'
import { t } from 'i18next'
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, GripVertical, Plus, Trash2 } from 'lucide-react'
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import Scrollbar from '../Scrollbar'
import {
getAllCommands,
getToolbarCommands,
registerCommand,
registerToolbarCommand,
setCommandAvailability,
unregisterCommand,
unregisterToolbarCommand
} from './command'
import { ActionMenu, type ActionMenuItem } from './components/ActionMenu'
// DragContextMenuWrapper 已被 TipTap 扩展替代
import LinkEditor from './components/LinkEditor'
import PlusButton from './components/PlusButton'
import { EditorContent as StyledEditorContent, RichEditorWrapper } from './styles'
import { ToC } from './TableOfContent'
import { Toolbar } from './toolbar'
import type { FormattingCommand, RichEditorProps, RichEditorRef } from './types'
import { useRichEditor } from './useRichEditor'
const RichEditor = ({
ref,
initialContent = '',
placeholder = t('richEditor.placeholder'),
onContentChange,
onHtmlChange,
onMarkdownChange,
onBlur,
editable = true,
className = '',
showToolbar = true,
minHeight,
maxHeight,
initialCommands,
onCommandsReady,
showTableOfContents = false,
enableContentSearch = false,
isFullWidth = false,
fontFamily = 'default'
// toolbarItems: _toolbarItems // TODO: Implement custom toolbar items
}: RichEditorProps & { ref?: React.RefObject<RichEditorRef | null> }) => {
// Use the rich editor hook for complete editor management
const {
editor,
markdown,
html,
formattingState,
tableOfContentsItems,
linkEditor,
setMarkdown,
setHtml,
clear,
getPreviewText
} = useRichEditor({
initialContent,
onChange: onMarkdownChange,
onHtmlChange,
onContentChange,
onBlur,
placeholder,
editable,
scrollParent: () => scrollContainerRef.current,
onShowTableActionMenu: ({ position, actions }) => {
const iconMap: Record<string, React.ReactNode> = {
insertRowBefore: <ArrowUp size={16} />,
insertColumnBefore: <ArrowLeft size={16} />,
insertRowAfter: <ArrowDown size={16} />,
insertColumnAfter: <ArrowRight size={16} />,
deleteRow: <Trash2 size={16} />,
deleteColumn: <Trash2 size={16} />
}
const items: ActionMenuItem[] = actions.map((a, idx) => ({
key: String(idx),
label: a.label,
icon: iconMap[a.id],
onClick: a.action
}))
setTableActionMenu({ show: true, position, items })
}
})
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const contentSearchRef = useRef<ContentSearchRef>(null)
const onKeyDownEditor = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!enableContentSearch) return
const isModF = (event.metaKey || event.ctrlKey) && (event.key === 'f' || event.key === 'F')
if (isModF) {
event.preventDefault()
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
return
}
if (event.key === 'Escape') {
contentSearchRef.current?.disable()
}
},
[enableContentSearch]
)
useHotkeys(
'mod+f',
(event) => {
if (!enableContentSearch) return
event.preventDefault()
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
},
{ enableOnContentEditable: true, preventDefault: true, enabled: enableContentSearch },
[enableContentSearch]
)
useHotkeys(
'esc',
() => {
if (!enableContentSearch) return
contentSearchRef.current?.disable()
},
{ enableOnContentEditable: true, enabled: enableContentSearch },
[enableContentSearch]
)
// Table action menu state
const [tableActionMenu, setTableActionMenu] = useState<{
show: boolean
position: { x: number; y: number }
items: ActionMenuItem[]
}>({
show: false,
position: { x: 0, y: 0 },
items: []
})
// Register initial commands on mount
useEffect(() => {
if (initialCommands) {
initialCommands.forEach((cmd) => {
if (cmd.showInToolbar) {
registerToolbarCommand(cmd)
} else {
registerCommand(cmd)
}
})
}
}, [initialCommands])
// Call onCommandsReady when editor is ready
useEffect(() => {
if (editor && onCommandsReady) {
const commandAPI = {
registerCommand,
registerToolbarCommand,
unregisterCommand,
unregisterToolbarCommand,
setCommandAvailability
}
onCommandsReady(commandAPI)
}
}, [editor, onCommandsReady])
// Handle drag end callback to clean up draggable attribute
const handleDragEnd = useCallback((e: DragEvent) => {
// Clean up draggable attribute from the drag handle element
const target = e.target as HTMLElement
if (target && target.classList.contains('drag-handle')) {
target.removeAttribute('draggable')
}
}, [])
const closeTableActionMenu = () => {
setTableActionMenu({
show: false,
position: { x: 0, y: 0 },
items: []
})
}
const handlePlusButtonClick = useCallback(
(event: MouseEvent) => {
// 防止事件冒泡
event.preventDefault()
event.stopPropagation()
// 使用 setTimeout 确保在下一个事件循环中执行
setTimeout(() => {
if (editor && !editor.isDestroyed) {
// 聚焦编辑器并插入 '/'
editor.commands.insertContent('/')
}
}, 10)
},
[editor]
)
const handleCommand = useCallback(
(command: FormattingCommand) => {
if (!editor) return
switch (command) {
case 'bold':
editor.chain().focus().toggleBold().run()
break
case 'italic':
editor.chain().focus().toggleItalic().run()
break
case 'underline':
editor.chain().focus().toggleUnderline().run()
break
case 'strike':
editor.chain().focus().toggleStrike().run()
break
case 'code':
editor.chain().focus().toggleCode().run()
break
case 'clearMarks':
editor.chain().focus().unsetAllMarks().run()
break
case 'paragraph':
editor.chain().focus().setParagraph().run()
break
case 'heading1':
editor.chain().focus().toggleHeading({ level: 1 }).run()
break
case 'heading2':
editor.chain().focus().toggleHeading({ level: 2 }).run()
break
case 'heading3':
editor.chain().focus().toggleHeading({ level: 3 }).run()
break
case 'heading4':
editor.chain().focus().toggleHeading({ level: 4 }).run()
break
case 'heading5':
editor.chain().focus().toggleHeading({ level: 5 }).run()
break
case 'heading6':
editor.chain().focus().toggleHeading({ level: 6 }).run()
break
case 'bulletList':
editor.chain().focus().toggleBulletList().run()
break
case 'orderedList':
editor.chain().focus().toggleOrderedList().run()
break
case 'codeBlock':
editor.chain().focus().toggleCodeBlock().run()
break
case 'blockquote':
editor.chain().focus().toggleBlockquote().run()
break
case 'link': {
const { selection } = editor.state
const { from, to, $from } = selection
// 如果当前已经是链接,则取消链接
if (editor.isActive('enhancedLink')) {
editor.chain().focus().unsetEnhancedLink().run()
} else {
// 获取当前段落的文本内容
if (from !== to) {
const selectedText = editor.state.doc.textBetween(from, to)
if (selectedText.trim()) {
const url = selectedText.trim().startsWith('http')
? selectedText.trim()
: `https://${selectedText.trim()}`
editor.chain().focus().setTextSelection({ from, to }).setEnhancedLink({ href: url }).run()
}
} else {
const paragraphText = $from.parent.textContent
// 如果段落有文本,将段落文本设置为链接
if (paragraphText.trim()) {
const url = paragraphText.trim().startsWith('http')
? paragraphText.trim()
: `https://${paragraphText.trim()}`
try {
const { $from } = selection
const start = $from.start()
const end = $from.end()
editor.chain().focus().setTextSelection({ from: start, to: end }).setEnhancedLink({ href: url }).run()
} catch (error) {
editor.chain().focus().toggleEnhancedLink({ href: '' }).run()
}
} else {
editor.chain().focus().toggleEnhancedLink({ href: '' }).run()
}
}
}
break
}
case 'undo':
editor.chain().focus().undo().run()
break
case 'redo':
editor.chain().focus().redo().run()
break
case 'blockMath': {
// Math is handled by the MathInputDialog component in toolbar
// This case is here for completeness but shouldn't be called directly
break
}
case 'table':
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
break
case 'image':
// Image insertion is handled by the ImageUploader component in toolbar
// This case is here for completeness but shouldn't be called directly
break
case 'taskList':
editor.chain().focus().toggleTaskList().run()
}
},
[editor]
)
// Expose editor methods via ref
useImperativeHandle(
ref,
() => ({
getContent: () => editor?.getText() || '',
getHtml: () => html,
getMarkdown: () => markdown,
setContent: (content: string) => {
editor?.commands.setContent(content)
},
setHtml: (htmlContent: string) => {
setHtml(htmlContent)
},
setMarkdown: (markdownContent: string) => {
setMarkdown(markdownContent)
},
focus: () => {
editor?.commands.focus()
},
clear: () => {
clear()
editor?.commands.clearContent()
},
insertText: (text: string) => {
editor?.commands.insertContent(text)
},
executeCommand: (command: string, value?: any) => {
if (editor?.commands && command in editor.commands) {
editor.commands[command](value)
}
},
getPreviewText: (maxLength?: number) => {
return getPreviewText(markdown, maxLength)
},
getScrollTop: () => {
return scrollContainerRef.current?.scrollTop ?? 0
},
setScrollTop: (value: number) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = value
}
},
// Dynamic command management
registerCommand,
registerToolbarCommand,
unregisterCommand,
unregisterToolbarCommand,
setCommandAvailability,
getAllCommands,
getToolbarCommands
}),
[editor, html, markdown, setHtml, setMarkdown, clear, getPreviewText]
)
return (
<RichEditorWrapper
className={`rich-editor-wrapper ${className}`}
$minHeight={minHeight}
$maxHeight={maxHeight}
$isFullWidth={isFullWidth}
$fontFamily={fontFamily}
onKeyDown={onKeyDownEditor}>
{showToolbar && (
<Toolbar
editor={editor}
formattingState={formattingState}
onCommand={handleCommand}
scrollContainer={scrollContainerRef}
/>
)}
<Scrollbar ref={scrollContainerRef} style={{ flex: 1, display: 'flex' }}>
<StyledEditorContent>
<PlusButton editor={editor} onElementClick={handlePlusButtonClick}>
<Tooltip title={t('richEditor.plusButton')}>
<Plus />
</Tooltip>
</PlusButton>
<DragHandle editor={editor} onElementDragEnd={handleDragEnd}>
<Tooltip title={t('richEditor.dragHandle')}>
<GripVertical />
</Tooltip>
</DragHandle>
<EditorContent style={{ height: '100%' }} editor={editor} />
</StyledEditorContent>
</Scrollbar>
{enableContentSearch && (
<ContentSearch
ref={contentSearchRef}
searchTarget={scrollContainerRef as React.RefObject<HTMLElement>}
filter={{
acceptNode(node) {
const inEditor = (node as Node).parentElement?.closest('.ProseMirror')
return inEditor ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
}
}}
includeUser={false}
onIncludeUserChange={() => {}}
showUserToggle={false}
positionMode="absolute"
/>
)}
{showTableOfContents && (
<ToC items={tableOfContentsItems} editor={editor} scrollContainerRef={scrollContainerRef} />
)}
<ActionMenu
show={tableActionMenu.show}
position={tableActionMenu.position}
items={tableActionMenu.items}
onClose={closeTableActionMenu}
/>
<LinkEditor
visible={linkEditor.show}
position={linkEditor.position}
link={linkEditor.link}
onSave={linkEditor.onSave}
onRemove={linkEditor.onRemove}
onCancel={linkEditor.onCancel}
/>
</RichEditorWrapper>
)
}
RichEditor.displayName = 'RichEditor'
export default RichEditor

View File

@ -0,0 +1,259 @@
import { computePosition, type ComputePositionConfig } from '@floating-ui/dom'
import type { Editor } from '@tiptap/core'
import type { Node } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { findElementNextToCoords } from '../helpers/findNextElementFromCursor'
import { getOuterNode, getOuterNodePos } from '../helpers/getOutNode'
import { removeNode } from '../helpers/removeNode'
const getOuterDomNode = (view: EditorView, domNode: HTMLElement) => {
let tmpDomNode = domNode
// Traverse to top level node.
while (tmpDomNode?.parentNode) {
if (tmpDomNode.parentNode === view.dom) {
break
}
tmpDomNode = tmpDomNode.parentNode as HTMLElement
}
return tmpDomNode
}
export const plusButtonPluginDefaultKey = new PluginKey('plusButton')
export interface PlusButtonPluginOptions {
pluginKey?: PluginKey | string
editor: Editor
element: HTMLElement
insertNodeType?: string
insertNodeAttrs?: Record<string, any>
onNodeChange?: (data: { editor: Editor; node: Node | null; pos: number }) => void
onElementClick?: (event: MouseEvent) => void
computePositionConfig?: ComputePositionConfig
}
export const PlusButtonPlugin = ({
pluginKey = plusButtonPluginDefaultKey,
editor,
element,
insertNodeType = 'paragraph',
insertNodeAttrs = {},
computePositionConfig,
onNodeChange,
onElementClick
}: PlusButtonPluginOptions) => {
const wrapper = document.createElement('div')
let currentNode: Node | null = null
let currentNodePos = -1
function hideButton() {
if (!element) {
return
}
element.style.visibility = 'hidden'
element.style.pointerEvents = 'none'
}
function showButton() {
if (!element) {
return
}
if (!editor.isEditable) {
hideButton()
return
}
element.style.visibility = ''
element.style.pointerEvents = 'auto'
}
function repositionPlusButton(dom: Element) {
const virtualElement = {
getBoundingClientRect: () => dom.getBoundingClientRect()
}
computePosition(virtualElement, element, computePositionConfig).then((val) => {
Object.assign(element.style, {
position: val.strategy,
left: `${val.x}px`,
top: `${val.y}px`
})
})
}
function onClick(e: MouseEvent) {
if (currentNodePos === -1) return
const nodeType = editor.schema.nodes[insertNodeType]
const insertPos = currentNodePos + currentNode!.nodeSize
const newNode = nodeType.create(insertNodeAttrs)
const tr = editor.state.tr.insert(insertPos, newNode)
// 设置光标位置到新插入的节点内部
const newNodePos = insertPos + 1 // 进入新节点内部
tr.setSelection(TextSelection.near(tr.doc.resolve(newNodePos)))
editor.view.dispatch(tr)
onElementClick?.(e)
}
element.addEventListener('click', onClick)
wrapper.appendChild(element)
hideButton()
return {
unbind() {
element.removeEventListener('click', onClick)
},
plugin: new Plugin({
key: typeof pluginKey === 'string' ? new PluginKey(pluginKey) : pluginKey,
view: (view) => {
editor.view.dom.parentElement?.appendChild(wrapper)
wrapper.style.position = 'absolute'
wrapper.style.top = '0'
wrapper.style.left = '0'
wrapper.style.pointerEvents = 'none'
return {
update: (_, prevState) => {
if (!editor.isEditable) {
hideButton()
return
}
if (view.state.doc.eq(prevState.doc) && currentNodePos !== -1) {
// 只要鼠标位置没有变化,就不必重新定位
return
}
if (currentNodePos === -1) {
hideButton()
onNodeChange?.({ editor, node: null, pos: -1 })
return
}
let domNode = view.nodeDOM(currentNodePos) as HTMLElement
if (!domNode) return
domNode = getOuterDomNode(view, domNode)
if (domNode === view.dom) {
hideButton()
onNodeChange?.({ editor, node: null, pos: -1 })
return
}
const outerNodePos = getOuterNodePos(editor.state.doc, view.posAtDOM(domNode, 0))
const outerNode = getOuterNode(editor.state.doc, outerNodePos)
// 若外层节点没有变化则不必重复处理
if (outerNode === currentNode && outerNodePos === currentNodePos) {
return
}
currentNode = outerNode
currentNodePos = outerNodePos
repositionPlusButton(domNode as Element)
showButton()
},
destroy: () => {
removeNode(wrapper)
}
}
},
props: {
// Add any additional editor props if needed
handleDOMEvents: {
mousemove(view, e) {
// 当编辑器不可编辑或按钮已被锁定时直接返回
if (!editor.isEditable) return false
// 通过坐标向右寻找最近的块级元素
const result = findElementNextToCoords({
editor,
x: e.clientX,
y: e.clientY,
direction: 'right'
})
if (!result.resultNode || result.pos === null) {
// 没有匹配到块 → 隐藏按钮
hideButton()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
return false
}
// 取到块对应的 DOM
let domNode = result.resultElement as HTMLElement
domNode = getOuterDomNode(view, domNode)
if (domNode === view.dom || domNode?.nodeType !== 1) {
hideButton()
return false
}
// 通过 DOM → 文档位置 → 最外层块位置
const outerPos = getOuterNodePos(editor.state.doc, view.posAtDOM(domNode, 0))
const outerNode = getOuterNode(editor.state.doc, outerPos)
// 若目标块未改变直接返回
if (outerNode === currentNode && outerPos === currentNodePos) {
return false
}
// 更新缓存并回调
currentNode = outerNode
currentNodePos = outerPos
onNodeChange?.({ editor, node: currentNode, pos: currentNodePos })
// 重新定位按钮并显示
repositionPlusButton(domNode as Element)
showButton()
return false // 继续向下传播其它 mousemove 处理器
},
// Hide button when typing/input events occur
keydown(view) {
if (view.hasFocus()) {
hideButton()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
return false
}
return false
},
scroll(view) {
if (view.hasFocus()) {
hideButton()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
return false
}
return false
},
// 当鼠标离开编辑器区域时隐藏按钮
mouseleave(_view, e) {
// 如果指针正好在 wrapper按钮上则不隐藏
if (wrapper.contains(e.relatedTarget as HTMLElement)) return false
hideButton()
currentNode = null
currentNodePos = -1
onNodeChange?.({ editor, node: null, pos: -1 })
return false
}
}
}
})
}
}

View File

@ -0,0 +1,376 @@
import styled from 'styled-components'
export const RichEditorWrapper = styled.div<{
$minHeight?: number
$maxHeight?: number
$isFullWidth?: boolean
$fontFamily?: 'default' | 'serif'
}>`
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
overflow-y: hidden;
width: ${({ $isFullWidth }) => ($isFullWidth ? '100%' : '60%')};
margin: ${({ $isFullWidth }) => ($isFullWidth ? '0' : '0 auto')};
font-family: ${({ $fontFamily }) => ($fontFamily === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)')};
${({ $minHeight }) => $minHeight && `min-height: ${$minHeight}px;`}
${({ $maxHeight }) => $maxHeight && `max-height: ${$maxHeight}px;`}
`
export const ToolbarWrapper = styled.div`
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--color-border);
background: var(--color-background-soft);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
&::-webkit-scrollbar-track {
background: var(--color-background-soft);
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--color-text-3);
}
/* Firefox 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: var(--color-border) var(--color-background-soft);
`
export const ToolbarButton = styled.button<{
$active?: boolean
$disabled?: boolean
}>`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'transparent')};
color: ${({ $active, $disabled }) =>
$disabled ? 'var(--color-text-3)' : $active ? 'var(--color-white)' : 'var(--color-text)'};
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
transition: all 0.2s ease;
flex-shrink: 0; /* 防止按钮收缩 */
&:hover:not(:disabled) {
background: ${({ $active }) => ($active ? 'var(--color-primary)' : 'var(--color-hover)')};
}
&:disabled {
opacity: 0.5;
}
svg {
width: 16px;
height: 16px;
}
`
export const ToolbarDivider = styled.div`
width: 1px;
height: 20px;
background: var(--color-border);
margin: 0 4px;
flex-shrink: 0; /* 防止分隔符收缩 */
`
export const EditorContent = styled.div`
flex: 1;
/* overflow handled by Scrollbar wrapper */
position: relative; /* keep internal elements positioned, but ToC is now sibling */
.plus-button,
.drag-handle {
align-items: center;
border-radius: 0.25rem;
cursor: grab;
display: flex;
height: 1.5rem;
justify-content: center;
z-index: 10;
flex-shrink: 0;
&:hover {
background: var(--color-hover);
}
svg {
width: 1.25rem;
height: 1.25rem;
color: var(--color-icon);
}
}
.plus-button {
width: 1.5rem;
cursor: pointer;
transform: translateX(calc(-1 * 1.5rem));
}
.drag-handle {
width: 1rem;
transform: translateX(-0.5rem) !important;
}
/* Ensure the ProseMirror editor content doesn't override drag handle positioning */
.ProseMirror {
position: relative;
height: 100%;
/* Allow text selection when not editable */
&:not([contenteditable='true']) {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
cursor: text;
/* Ensure all child elements allow text selection */
* {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
}
/* Enhanced link styles */
.rich-editor-link {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
text-decoration-thickness: 2px;
background-color: var(--color-hover);
padding: 1px 2px;
margin: -1px -2px;
border-radius: 3px;
}
}
}
`
export const TableOfContentsWrapper = styled.div`
.table-of-contents {
display: flex;
flex-direction: column;
font-size: 0.86rem;
gap: 0.1rem; /* tighter spacing between items */
overflow: auto;
text-decoration: none;
> div {
border-radius: 0.25rem;
padding-left: calc(0.4rem * (var(--level, 1) - 1));
transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1);
&:hover {
background-color: var(--gray-2);
}
}
.empty-state {
color: var(--gray-5);
user-select: none;
}
.is-active a {
color: var(--purple);
}
.is-scrolled-over a {
color: var(--gray-5);
}
a {
color: var(--black);
display: flex;
gap: 0.25rem;
text-decoration: none;
&::before {
content: attr(data-item-index) '.';
}
}
}
.toc-item {
margin-left: 0.25rem;
margin-bottom: 0.25rem;
a {
display: block;
padding: 0.25rem 0.5rem;
color: var(--color-text-2);
text-decoration: none;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1.4;
transition: all 0.2s ease;
&:hover {
background: var(--color-hover);
color: var(--color-text);
}
}
&.is-active a {
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 500;
}
&.is-scrolled-over a {
opacity: 0.6;
}
}
.toc-empty-state {
text-align: center;
padding: 2rem 1rem;
color: var(--color-text-3);
p {
margin: 0;
font-style: italic;
}
}
`
export const ToCDock = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0; /* dock fixed to wrapper, not editor scroll */
width: 26px; /* narrow by default; panel will overlay the rail */
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
pointer-events: auto; /* allow interacting with rail/panel */
/* Show panel when hovering anywhere within the dock */
.toc-rail:hover ~ .toc-panel,
.toc-panel:hover {
opacity: 1;
visibility: visible;
transform: translateX(0);
pointer-events: auto;
}
.toc-rail:hover {
opacity: 1;
}
.toc-rail {
pointer-events: auto; /* clickable */
width: 18px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center; /* dense and centered */
align-items: center;
gap: 4px;
opacity: 0.6;
transition: opacity 0.2s ease;
overflow: hidden; /* prevent overflow */
}
.toc-rail-button {
appearance: none;
border: none;
padding: 0;
background: var(--color-gray-3, var(--color-border));
height: 4px;
border-radius: 3px;
cursor: pointer;
opacity: 0.8;
width: 12px; /* default for level 1 */
display: block;
flex-shrink: 0;
transition:
background 0.2s ease,
opacity 0.2s ease,
transform 0.1s ease;
&:hover {
background: var(--color-text);
opacity: 1;
transform: scaleX(1.05);
}
&.active {
background: var(--color-text);
opacity: 1;
}
&.scrolled-over {
background: var(--color-gray-3);
opacity: 0.9;
}
&.level-1 {
width: 12px;
}
&.level-2 {
width: 10px;
}
&.level-3 {
width: 8px;
}
&.level-4 {
width: 6px;
}
&.level-5 {
width: 5px;
}
&.level-6 {
width: 4px;
}
}
.toc-panel {
pointer-events: none; /* enabled on hover */
position: absolute;
right: 8px; /* cover the rail */
top: 55px; /* insets within wrapper */
bottom: 8px; /* bound to wrapper height, not editor scroll */
width: auto;
max-width: 360px; /* capped width */
min-width: 220px;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
padding: 8px 8px 0;
padding-left: 0;
overflow: auto;
opacity: 0;
visibility: hidden;
transform: translateX(8px);
transition:
opacity 0.15s ease,
transform 0.15s ease,
visibility 0.15s ease;
backdrop-filter: blur(6px);
z-index: 40;
}
`

View File

@ -0,0 +1,338 @@
import { Tooltip } from 'antd'
import type { TFunction } from 'i18next'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getCommandsByGroup } from './command'
import { ImageUploader } from './components/ImageUploader'
import MathInputDialog from './components/MathInputDialog'
import { ToolbarButton, ToolbarDivider, ToolbarWrapper } from './styles'
import type { FormattingCommand, FormattingState, ToolbarProps } from './types'
interface ToolbarItemInternal {
id: string
command?: FormattingCommand
icon?: React.ComponentType
type?: 'divider'
handler?: () => void
}
// Group ordering for toolbar layout
const TOOLBAR_GROUP_ORDER = ['formatting', 'text', 'blocks', 'structure', 'media', 'history']
function getToolbarItems(): ToolbarItemInternal[] {
const items: ToolbarItemInternal[] = []
TOOLBAR_GROUP_ORDER.forEach((groupName, groupIndex) => {
const groupCommands = getCommandsByGroup(groupName)
if (groupCommands.length > 0 && groupIndex > 0) {
items.push({ id: `divider-${groupIndex}`, type: 'divider' })
}
groupCommands.forEach((cmd) => {
items.push({
id: cmd.id,
command: cmd.formattingCommand as FormattingCommand,
icon: cmd.icon,
handler: () => cmd.handler
})
})
})
return items
}
// Function to get tooltip text for toolbar commands
const getTooltipText = (t: TFunction, command: FormattingCommand): string => {
const tooltipMap: Record<FormattingCommand, string> = {
bold: t('richEditor.toolbar.bold'),
italic: t('richEditor.toolbar.italic'),
underline: t('richEditor.toolbar.underline'),
strike: t('richEditor.toolbar.strike'),
code: t('richEditor.toolbar.code'),
clearMarks: t('richEditor.toolbar.clearMarks'),
paragraph: t('richEditor.toolbar.paragraph'),
heading1: t('richEditor.toolbar.heading1'),
heading2: t('richEditor.toolbar.heading2'),
heading3: t('richEditor.toolbar.heading3'),
heading4: t('richEditor.toolbar.heading4'),
heading5: t('richEditor.toolbar.heading5'),
heading6: t('richEditor.toolbar.heading6'),
bulletList: t('richEditor.toolbar.bulletList'),
orderedList: t('richEditor.toolbar.orderedList'),
codeBlock: t('richEditor.toolbar.codeBlock'),
taskList: t('richEditor.toolbar.taskList'),
blockquote: t('richEditor.toolbar.blockquote'),
link: t('richEditor.toolbar.link'),
undo: t('richEditor.toolbar.undo'),
redo: t('richEditor.toolbar.redo'),
table: t('richEditor.toolbar.table'),
image: t('richEditor.toolbar.image'),
blockMath: t('richEditor.toolbar.blockMath'),
inlineMath: t('richEditor.toolbar.inlineMath')
}
return tooltipMap[command] || command
}
export const Toolbar: React.FC<ToolbarProps> = ({ editor, formattingState, onCommand, scrollContainer }) => {
const { t } = useTranslation()
const [showImageUploader, setShowImageUploader] = useState(false)
const [showMathInput, setShowMathInput] = useState(false)
const [placeholderCallbacks, setPlaceholderCallbacks] = useState<{
onMathSubmit?: (latex: string) => void
onMathCancel?: () => void
onMathFormulaChange?: (formula: string) => void
mathDefaultValue?: string
mathPosition?: { x: number; y: number; top: number }
onImageSelect?: (imageUrl: string) => void
onImageCancel?: () => void
}>({})
// Listen for custom events from placeholder nodes
useEffect(() => {
const handleMathDialog = (event: CustomEvent) => {
const { defaultValue, onSubmit, onFormulaChange, position } = event.detail
setPlaceholderCallbacks((prev) => ({
...prev,
onMathSubmit: onSubmit,
onMathCancel: () => {},
onMathFormulaChange: onFormulaChange,
mathDefaultValue: defaultValue,
mathPosition: position
}))
setShowMathInput(true)
}
const handleImageUploader = (event: CustomEvent) => {
const { onImageSelect, onCancel } = event.detail
setPlaceholderCallbacks((prev) => ({ ...prev, onImageSelect, onImageCancel: onCancel }))
setShowImageUploader(true)
}
window.addEventListener('openMathDialog', handleMathDialog as EventListener)
window.addEventListener('openImageUploader', handleImageUploader as EventListener)
return () => {
window.removeEventListener('openMathDialog', handleMathDialog as EventListener)
window.removeEventListener('openImageUploader', handleImageUploader as EventListener)
}
}, [])
if (!editor) {
return null
}
const handleCommand = (command: FormattingCommand) => {
if (command === 'image') {
editor.chain().focus().insertImagePlaceholder().run()
} else if (command === 'blockMath') {
editor.chain().focus().insertMathPlaceholder({ mathType: 'block' }).run()
} else if (command === 'inlineMath') {
editor.chain().focus().insertMathPlaceholder({ mathType: 'inline' }).run()
} else {
onCommand(command)
}
}
const handleImageSelect = (imageUrl: string) => {
if (editor) {
editor.chain().focus().setImage({ src: imageUrl }).run()
}
setShowImageUploader(false)
}
const toolbarItems = getToolbarItems()
return (
<ToolbarWrapper data-testid="rich-editor-toolbar">
{toolbarItems.map((item) => {
if (item.type === 'divider') {
return <ToolbarDivider key={item.id} />
}
const Icon = item.icon
const command = item.command
if (!Icon || !command) {
return null
}
const isActive = getFormattingState(formattingState, command)
const isDisabled = getDisabledState(formattingState, command)
const tooltipText = getTooltipText(t, command)
const buttonElement = (
<ToolbarButton
$active={isActive}
data-active={isActive}
disabled={isDisabled}
onClick={() => handleCommand(command)}
data-testid={`toolbar-${command}`}>
<Icon />
</ToolbarButton>
)
return (
<Tooltip key={item.id} title={tooltipText} placement="top">
{buttonElement}
</Tooltip>
)
})}
<ImageUploader
visible={showImageUploader}
onImageSelect={(imageUrl) => {
if (placeholderCallbacks.onImageSelect) {
placeholderCallbacks.onImageSelect(imageUrl)
setPlaceholderCallbacks((prev) => ({ ...prev, onImageSelect: undefined, onImageCancel: undefined }))
} else {
handleImageSelect(imageUrl)
}
setShowImageUploader(false)
}}
onClose={() => {
if (placeholderCallbacks.onImageCancel) {
placeholderCallbacks.onImageCancel()
setPlaceholderCallbacks((prev) => ({ ...prev, onImageSelect: undefined, onImageCancel: undefined }))
}
setShowImageUploader(false)
}}
/>
<MathInputDialog
visible={showMathInput}
defaultValue={placeholderCallbacks.mathDefaultValue || ''}
position={placeholderCallbacks.mathPosition}
scrollContainer={scrollContainer}
onSubmit={(formula) => {
if (placeholderCallbacks.onMathSubmit) {
placeholderCallbacks.onMathSubmit(formula)
} else {
if (editor && formula.trim()) {
editor.chain().focus().insertBlockMath({ latex: formula }).run()
}
}
setPlaceholderCallbacks((prev) => ({
...prev,
onMathSubmit: undefined,
onMathCancel: undefined,
onMathFormulaChange: undefined,
mathDefaultValue: undefined,
mathPosition: undefined
}))
setShowMathInput(false)
}}
onCancel={() => {
if (placeholderCallbacks.onMathCancel) {
placeholderCallbacks.onMathCancel()
setPlaceholderCallbacks((prev) => ({
...prev,
onMathSubmit: undefined,
onMathCancel: undefined,
onMathFormulaChange: undefined,
mathDefaultValue: undefined,
mathPosition: undefined
}))
}
setShowMathInput(false)
}}
onFormulaChange={(formula) => {
if (placeholderCallbacks.onMathFormulaChange) {
placeholderCallbacks.onMathFormulaChange(formula)
} else {
if (editor) {
const mathNodeType = editor.schema.nodes.inlineMath || editor.schema.nodes.blockMath
if (mathNodeType === editor.schema.nodes.inlineMath) {
editor.chain().updateInlineMath({ latex: formula }).run()
} else if (mathNodeType === editor.schema.nodes.blockMath) {
editor.chain().updateBlockMath({ latex: formula }).run()
}
}
}
}}
/>
</ToolbarWrapper>
)
}
function getFormattingState(state: FormattingState, command: FormattingCommand): boolean {
switch (command) {
case 'bold':
return state?.isBold || false
case 'italic':
return state?.isItalic || false
case 'underline':
return state?.isUnderline || false
case 'strike':
return state?.isStrike || false
case 'code':
return state?.isCode || false
case 'paragraph':
return state?.isParagraph || false
case 'heading1':
return state?.isHeading1 || false
case 'heading2':
return state?.isHeading2 || false
case 'heading3':
return state?.isHeading3 || false
case 'heading4':
return state?.isHeading4 || false
case 'heading5':
return state?.isHeading5 || false
case 'heading6':
return state?.isHeading6 || false
case 'bulletList':
return state?.isBulletList || false
case 'orderedList':
return state?.isOrderedList || false
case 'codeBlock':
return state?.isCodeBlock || false
case 'blockquote':
return state?.isBlockquote || false
case 'link':
return state?.isLink || false
case 'table':
return state?.isTable || false
case 'taskList':
return state?.isTaskList || false
case 'blockMath':
return state?.isMath || false
case 'inlineMath':
return state?.isInlineMath || false
default:
return false
}
}
function getDisabledState(state: FormattingState, command: FormattingCommand): boolean {
switch (command) {
case 'bold':
return !state?.canBold
case 'italic':
return !state?.canItalic
case 'underline':
return !state?.canUnderline
case 'strike':
return !state?.canStrike
case 'code':
return !state?.canCode
case 'undo':
return !state?.canUndo
case 'redo':
return !state?.canRedo
case 'clearMarks':
return !state?.canClearMarks
case 'link':
return !state?.canLink
case 'table':
return !state?.canTable
case 'image':
return !state?.canImage
case 'blockMath':
return !state?.canMath
case 'inlineMath':
return !state?.canMath
default:
return false
}
}

View File

@ -0,0 +1,301 @@
export interface RichEditorProps {
/** Initial content for the editor (can be markdown or HTML) */
initialContent?: string
/** Placeholder text when editor is empty */
placeholder?: string
/** Enable in-editor content search UI */
enableContentSearch?: boolean
/** Callback when content changes (plain text) */
onContentChange?: (content: string) => void
/** Callback when HTML content changes */
onHtmlChange?: (html: string) => void
/** Callback when Markdown content changes */
onMarkdownChange?: (markdown: string) => void
/** Callback when editor loses focus */
onBlur?: () => void
/** Callback when paste event occurs */
onPaste?: (event: ClipboardEvent) => string
/** Whether the editor is editable */
editable?: boolean
/** Whether to show the table of contents component */
showTableOfContents?: boolean
/** Custom CSS class name */
className?: string
/** Whether to show the toolbar */
showToolbar?: boolean
/** Minimum height of the editor */
minHeight?: number
/** Maximum height of the editor */
maxHeight?: number
/** Available toolbar tools */
toolbarItems?: ToolbarItem[]
/** Whether initial content is markdown (default: auto-detect) */
isMarkdown?: boolean
/** Initial commands to register on mount */
initialCommands?: Command[]
/** Command configuration callback called after editor initialization */
onCommandsReady?: (
commandAPI: Pick<
RichEditorRef,
| 'registerCommand'
| 'registerToolbarCommand'
| 'unregisterCommand'
| 'unregisterToolbarCommand'
| 'setCommandAvailability'
>
) => void
/** Whether to use full width layout */
isFullWidth?: boolean
/** Font family setting */
fontFamily?: 'default' | 'serif'
}
export interface ToolbarItem {
/** Unique identifier for the toolbar item */
id: string
/** Type of toolbar item */
type: 'button' | 'divider' | 'dropdown'
/** Display label */
label?: string
/** Icon component or icon name */
icon?: React.ComponentType | string
/** Click handler for buttons */
onClick?: () => void
/** Whether the item is active/pressed */
active?: boolean
/** Whether the item is disabled */
disabled?: boolean
/** Dropdown options (for dropdown type) */
options?: ToolbarDropdownOption[]
}
export interface ToolbarDropdownOption {
/** Option value */
value: string
/** Option label */
label: string
/** Option icon */
icon?: React.ComponentType | string
/** Click handler */
onClick: () => void
}
export interface RichEditorRef {
/** Get current editor content as plain text */
getContent: () => string
/** Get current editor content as HTML */
getHtml: () => string
/** Get current editor content as Markdown */
getMarkdown: () => string
/** Set editor content (plain text) */
setContent: (content: string) => void
/** Set editor HTML content */
setHtml: (html: string) => void
/** Set editor Markdown content */
setMarkdown: (markdown: string) => void
/** Focus the editor */
focus: () => void
/** Clear all content */
clear: () => void
/** Insert text at current cursor position */
insertText: (text: string) => void
/** Execute a formatting command */
executeCommand: (command: string, value?: any) => void
/** Get preview text from current content */
getPreviewText: (maxLength?: number) => string
/** Get scrollTop of the editor scroll container */
getScrollTop: () => number
/** Set scrollTop of the editor scroll container */
setScrollTop: (value: number) => void
// Dynamic command management
/** Register a new command/toolbar item */
registerCommand: (cmd: Command) => void
/** Register a command that shows in toolbar */
registerToolbarCommand: (cmd: Command) => void
/** Remove a command completely */
unregisterCommand: (id: string) => void
/** Hide a command from toolbar (keep in slash menu) */
unregisterToolbarCommand: (id: string) => void
/** Set command availability condition */
setCommandAvailability: (id: string, isAvailable: (editor: any) => boolean) => void
/** Get all registered commands */
getAllCommands: () => Command[]
/** Get toolbar commands only */
getToolbarCommands: () => Command[]
}
export interface FormattingState {
/** Whether bold is active */
isBold: boolean
/** Whether bold command can be executed */
canBold: boolean
/** Whether italic is active */
isItalic: boolean
/** Whether italic command can be executed */
canItalic: boolean
/** Whether underline is active */
isUnderline: boolean
/** Whether underline command can be executed */
canUnderline: boolean
/** Whether strike is active */
isStrike: boolean
/** Whether strike command can be executed */
canStrike: boolean
/** Whether code is active */
isCode: boolean
/** Whether code command can be executed */
canCode: boolean
/** Whether marks can be cleared */
canClearMarks: boolean
/** Whether paragraph is active */
isParagraph: boolean
/** Whether heading level 1 is active */
isHeading1: boolean
/** Whether heading level 2 is active */
isHeading2: boolean
/** Whether heading level 3 is active */
isHeading3: boolean
/** Whether heading level 4 is active */
isHeading4: boolean
/** Whether heading level 5 is active */
isHeading5: boolean
/** Whether heading level 6 is active */
isHeading6: boolean
/** Whether bullet list is active */
isBulletList: boolean
/** Whether ordered list is active */
isOrderedList: boolean
/** Whether code block is active */
isCodeBlock: boolean
/** Whether blockquote is active */
isBlockquote: boolean
/** Whether link is active */
isLink: boolean
/** Whether link command can be executed */
canLink: boolean
/** Whether undo can be executed */
canUndo: boolean
/** Whether redo can be executed */
canRedo: boolean
/** Whether table is active */
isTable: boolean
/** Whether table command can be executed */
canTable: boolean
/** Whether image command can be executed */
canImage: boolean
/** Whether math is active */
isMath: boolean
/** Whether inline math is active */
isInlineMath: boolean
/** Whether math command can be executed */
canMath: boolean
/** Whether taskList is active */
isTaskList: boolean
}
export type FormattingCommand =
| 'bold'
| 'italic'
| 'underline'
| 'strike'
| 'code'
| 'clearMarks'
| 'paragraph'
| 'heading1'
| 'heading2'
| 'heading3'
| 'heading4'
| 'heading5'
| 'heading6'
| 'bulletList'
| 'orderedList'
| 'codeBlock'
| 'blockquote'
| 'link'
| 'undo'
| 'redo'
| 'blockMath'
| 'inlineMath'
| 'table'
| 'taskList'
| 'image'
export interface ToolbarProps {
/** Editor instance ref */
editor: Editor
/** Custom toolbar items */
items?: ToolbarItem[]
/** Current formatting state */
formattingState: FormattingState
/** Callback when formatting command is executed */
onCommand: (command: FormattingCommand) => void
/** Scroll container reference to prevent scrolling when dialogs open */
scrollContainer?: React.RefObject<HTMLDivElement | null>
}
// Command System Types for Slash Commands
import type { Editor } from '@tiptap/core'
import { LucideIcon } from 'lucide-react'
export enum CommandCategory {
TEXT = 'text',
LISTS = 'lists',
BLOCKS = 'blocks',
MEDIA = 'media',
STRUCTURE = 'structure',
SPECIAL = 'special'
}
export interface Command {
/** Unique identifier for the command */
id: string
/** Display title for the command */
title: string
/** Description of what the command does */
description: string
/** Search keywords for filtering */
keywords: string[]
/** Command category for grouping */
category: CommandCategory
/** Icon component or icon name */
icon: LucideIcon
/** Handler function to execute the command */
handler: (editor: Editor) => void
/** Whether the command is available in current context */
isAvailable?: (editor: Editor) => boolean
// Toolbar support
showInToolbar?: boolean
toolbarGroup?: 'text' | 'formatting' | 'blocks' | 'media' | 'structure' | 'history'
formattingCommand?: string // Maps to FormattingCommand for state checking
}
export interface CommandSuggestion {
/** Range where the suggestion was triggered */
range: Range
/** Current query text after the trigger character */
query: string
/** Text content of the suggestion */
text: string
/** Whether suggestion is active */
active: boolean
}
export interface CommandListProps {
/** List of filtered commands to display */
commands: Command[]
/** Currently selected command index */
selectedIndex: number
/** Callback when command is selected */
onSelect: (command: Command) => void
/** Callback when selection changes via keyboard */
onSelectionChange: (index: number) => void
}
export interface CommandFilterOptions {
/** Query string to filter commands */
query: string
/** Category to filter by */
category?: string
/** Current editor state for availability checks */
editor?: any
}

View File

@ -0,0 +1,834 @@
import 'katex/dist/katex.min.css'
import { TableKit } from '@cherrystudio/extension-table-plus'
import { loggerService } from '@logger'
import type { FormattingState } from '@renderer/components/RichEditor/types'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import {
htmlToMarkdown,
isMarkdownContent,
markdownToHtml,
markdownToPreviewText,
markdownToSafeHtml,
sanitizeHtml
} from '@renderer/utils/markdownConverter'
import type { Editor } from '@tiptap/core'
import { TaskItem, TaskList } from '@tiptap/extension-list'
import { migrateMathStrings } from '@tiptap/extension-mathematics'
import Mention from '@tiptap/extension-mention'
import {
getHierarchicalIndexes,
type TableOfContentDataItem,
TableOfContents
} from '@tiptap/extension-table-of-contents'
import Typography from '@tiptap/extension-typography'
import { useEditor, useEditorState } from '@tiptap/react'
import { StarterKit } from '@tiptap/starter-kit'
import { t } from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { commandSuggestion } from './command'
import { CodeBlockShiki } from './extensions/code-block-shiki/code-block-shiki'
import { EnhancedImage } from './extensions/enhanced-image'
import { EnhancedLink } from './extensions/enhanced-link'
import { EnhancedMath } from './extensions/enhanced-math'
import { Placeholder } from './extensions/placeholder'
import { blobToArrayBuffer, compressImage, shouldCompressImage } from './helpers/imageUtils'
const logger = loggerService.withContext('useRichEditor')
export interface UseRichEditorOptions {
/** Initial markdown content */
initialContent?: string
/** Callback when markdown content changes */
onChange?: (markdown: string) => void
/** Callback when HTML content changes */
onHtmlChange?: (html: string) => void
/** Callback when content changes (plain text) */
onContentChange?: (content: string) => void
/** Callback when editor loses focus */
onBlur?: () => void
/** Callback when paste event occurs */
onPaste?: (html: string) => void
/** Maximum length for preview text */
previewLength?: number
/** Placeholder text when editor is empty */
placeholder?: string
/** Whether the editor is editable */
editable?: boolean
/** Whether to enable table of contents functionality */
enableTableOfContents?: boolean
/** Show table action menu (row/column) with concrete actions and position */
onShowTableActionMenu?: (payload: {
type: 'row' | 'column'
index: number
position: { x: number; y: number }
actions: { id: string; label: string; action: () => void }[]
}) => void
scrollParent?: () => HTMLElement | null
}
export interface UseRichEditorReturn {
/** TipTap editor instance */
editor: Editor
/** Current markdown content */
markdown: string
/** Current HTML content (converted from markdown) */
html: string
/** Preview text for display */
previewText: string
/** Whether content is detected as markdown */
isMarkdown: boolean
/** Whether editor is disabled */
disabled: boolean
/** Current formatting state from TipTap editor */
formattingState: FormattingState
/** Table of contents items */
tableOfContentsItems: TableOfContentDataItem[]
/** Link editor state */
linkEditor: {
show: boolean
position: { x: number; y: number }
link: { href: string; text: string; title?: string }
onSave: (href: string, text: string, title?: string) => void
onRemove: () => void
onCancel: () => void
}
/** Set markdown content */
setMarkdown: (content: string) => void
/** Set HTML content (converts to markdown) */
setHtml: (html: string) => void
/** Clear all content */
clear: () => void
/** Convert markdown to HTML */
toHtml: (markdown: string) => string
/** Convert markdown to safe HTML */
toSafeHtml: (markdown: string) => string
/** Convert HTML to markdown */
toMarkdown: (html: string) => string
/** Get preview text from markdown */
getPreviewText: (markdown: string, maxLength?: number) => string
}
/**
* Custom hook for managing rich text content with Markdown storage
* Provides conversion between Markdown and HTML with sanitization
*/
export const useRichEditor = (options: UseRichEditorOptions = {}): UseRichEditorReturn => {
const {
initialContent = '',
onChange,
onHtmlChange,
onContentChange,
onBlur,
onPaste,
previewLength = 50,
placeholder = '',
editable = true,
onShowTableActionMenu,
scrollParent
} = options
const [markdown, setMarkdownState] = useState<string>(initialContent)
const html = useMemo(() => {
if (!markdown) return ''
return markdownToSafeHtml(markdown)
}, [markdown])
const previewText = useMemo(() => {
if (!markdown) return ''
return markdownToPreviewText(markdown, previewLength)
}, [markdown, previewLength])
const isMarkdown = useMemo(() => {
return isMarkdownContent(markdown)
}, [markdown])
// Get theme and language mapping from CodeStyleProvider
const { activeShikiTheme } = useCodeStyle()
const [tableOfContentsItems, setTableOfContentsItems] = useState<TableOfContentDataItem[]>([])
// Link editor state
const [linkEditorState, setLinkEditorState] = useState<{
show: boolean
position: { x: number; y: number }
link: { href: string; text: string; title?: string }
linkRange?: { from: number; to: number }
}>({
show: false,
position: { x: 0, y: 0 },
link: { href: '', text: '' }
})
// Link hover handlers
const handleLinkHover = useCallback(
(
attrs: { href: string; text: string; title?: string },
position: DOMRect,
_element: HTMLElement,
linkRange?: { from: number; to: number }
) => {
if (!editable) return
const linkPosition = { x: position.left, y: position.top }
// For empty href, use the text content as initial href suggestion
const effectiveHref = attrs.href || attrs.text || ''
setLinkEditorState({
show: true,
position: linkPosition,
link: { ...attrs, href: effectiveHref },
linkRange
})
},
[editable]
)
const handleLinkHoverEnd = useCallback(() => {}, [])
// TipTap editor extensions
const extensions = useMemo(
() => [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6]
},
codeBlock: false,
link: false
}),
EnhancedLink.configure({
onLinkHover: handleLinkHover,
onLinkHoverEnd: handleLinkHoverEnd,
editable: editable
}),
TableOfContents.configure({
getIndex: getHierarchicalIndexes,
onUpdate(content) {
const resolveParent = (): HTMLElement | null => {
if (!scrollParent) return null
return typeof scrollParent === 'function' ? (scrollParent as () => HTMLElement)() : scrollParent
}
const parent = resolveParent()
if (!parent) return
const parentTop = parent.getBoundingClientRect().top
let closestIndex = -1
let minDelta = Number.POSITIVE_INFINITY
for (let i = 0; i < content.length; i++) {
const rect = content[i].dom.getBoundingClientRect()
const delta = rect.top - parentTop
const inThreshold = delta >= -50 && delta < minDelta
if (inThreshold) {
minDelta = delta
closestIndex = i
}
}
if (closestIndex === -1) {
// If all are above the viewport, pick the last one above
for (let i = 0; i < content.length; i++) {
const rect = content[i].dom.getBoundingClientRect()
if (rect.top < parentTop) closestIndex = i
}
if (closestIndex === -1) closestIndex = 0
}
const normalized = content.map((item, idx) => {
const rect = item.dom.getBoundingClientRect()
const isScrolledOver = rect.top < parentTop
const isActive = idx === closestIndex
return { ...item, isActive, isScrolledOver }
})
setTableOfContentsItems(normalized)
},
scrollParent: (scrollParent as any) ?? window
}),
CodeBlockShiki.configure({
theme: activeShikiTheme,
defaultLanguage: 'text'
}),
EnhancedMath.configure({
blockOptions: {
onClick: (node, pos) => {
// Get position from the clicked element
let position: { x: number; y: number; top: number } | undefined
if (event?.target instanceof HTMLElement) {
const rect =
event.target.closest('.math-display')?.getBoundingClientRect() || event.target.getBoundingClientRect()
position = {
x: rect.left + rect.width / 2,
y: rect.bottom,
top: rect.top
}
}
const customEvent = new CustomEvent('openMathDialog', {
detail: {
defaultValue: node.attrs.latex || '',
position: position,
onSubmit: () => {
editor.commands.focus()
},
onFormulaChange: (formula: string) => {
editor.chain().setNodeSelection(pos).updateBlockMath({ latex: formula }).run()
}
}
})
window.dispatchEvent(customEvent)
return true
}
},
inlineOptions: {
onClick: (node, pos) => {
let position: { x: number; y: number; top: number } | undefined
if (event?.target instanceof HTMLElement) {
const rect =
event.target.closest('.math-inline')?.getBoundingClientRect() || event.target.getBoundingClientRect()
position = {
x: rect.left + rect.width / 2,
y: rect.bottom,
top: rect.top
}
}
const customEvent = new CustomEvent('openMathDialog', {
detail: {
defaultValue: node.attrs.latex || '',
position: position,
onSubmit: () => {
editor.commands.focus()
},
onFormulaChange: (formula: string) => {
editor.chain().setNodeSelection(pos).updateInlineMath({ latex: formula }).run()
}
}
})
window.dispatchEvent(customEvent)
return true
}
}
}),
EnhancedImage,
Placeholder.configure({
placeholder,
showOnlyWhenEditable: true,
showOnlyCurrent: true,
includeChildren: false
}),
Mention.configure({
HTMLAttributes: {
class: 'mention'
},
suggestion: commandSuggestion
}),
Typography,
TableKit.configure({
table: {
resizable: true,
allowTableNodeSelection: true,
onRowActionClick: ({ rowIndex, position }) => {
showTableActionMenu('row', rowIndex, position)
},
onColumnActionClick: ({ colIndex, position }) => {
showTableActionMenu('column', colIndex, position)
}
},
tableRow: {},
tableHeader: {},
tableCell: {
allowNestedNodes: false
}
}),
TaskList,
TaskItem.configure({
nested: true
})
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeholder, activeShikiTheme, handleLinkHover, handleLinkHoverEnd]
)
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions,
content: html || '',
editable: editable,
editorProps: {
handlePaste: (view, event) => {
// First check if we're inside a code block - if so, insert plain text
const { selection } = view.state
const { $from } = selection
if ($from.parent.type.name === 'codeBlock') {
const text = event.clipboardData?.getData('text/plain') || ''
if (text) {
const tr = view.state.tr.insertText(text, selection.from, selection.to)
view.dispatch(tr)
return true
}
}
// Handle image paste
const items = Array.from(event.clipboardData?.items || [])
const imageItem = items.find((item) => item.type.startsWith('image/'))
if (imageItem) {
const file = imageItem.getAsFile()
if (file) {
// Handle image paste by saving to local storage
handleImagePaste(file)
return true
}
}
// Default behavior for non-code blocks
const text = event.clipboardData?.getData('text/plain') ?? ''
if (text) {
const html = markdownToHtml(text)
const { $from } = selection
const atStartOfLine = $from.parentOffset === 0
const inEmptyParagraph = $from.parent.type.name === 'paragraph' && $from.parent.textContent === ''
if (!atStartOfLine && !inEmptyParagraph) {
const cleanHtml = html.replace(/^<p>(.*?)<\/p>/s, '$1')
editor.commands.insertContent(cleanHtml)
} else {
editor.commands.insertContent(html)
}
onPaste?.(html)
return true
}
return false
},
attributes: {
// Allow text selection even when not editable
style: editable
? ''
: 'user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text;'
}
},
onUpdate: ({ editor }) => {
const content = editor.getText()
const htmlContent = editor.getHTML()
try {
const convertedMarkdown = htmlToMarkdown(htmlContent)
setMarkdownState(convertedMarkdown)
onChange?.(convertedMarkdown)
onContentChange?.(content)
if (onHtmlChange) {
const safeHtml = sanitizeHtml(htmlContent)
onHtmlChange(safeHtml)
}
} catch (error) {
logger.error('Error converting HTML to markdown:', error as Error)
}
},
onBlur: () => {
onBlur?.()
},
onCreate: ({ editor: currentEditor }) => {
migrateMathStrings(currentEditor)
try {
currentEditor.commands.focus('end')
} catch (error) {
logger.warn('Could not set cursor to end:', error as Error)
}
}
})
// Handle image paste function
const handleImagePaste = useCallback(
async (file: File) => {
try {
let processedFile: File | Blob = file
let extension = file.type.split('/')[1] ? `.${file.type.split('/')[1]}` : '.png'
// 如果图片需要压缩,先进行压缩
if (shouldCompressImage(file)) {
logger.info('Image needs compression, compressing...', {
originalSize: file.size,
fileName: file.name
})
processedFile = await compressImage(file, {
maxWidth: 1200,
maxHeight: 1200,
quality: 0.8,
outputFormat: file.type.includes('png') ? 'png' : 'jpeg'
})
// 更新扩展名
extension = file.type.includes('png') ? '.png' : '.jpg'
logger.info('Image compressed successfully', {
originalSize: file.size,
compressedSize: processedFile.size,
compressionRatio: (((file.size - processedFile.size) / file.size) * 100).toFixed(1) + '%'
})
}
// Convert file to buffer
const arrayBuffer = await blobToArrayBuffer(processedFile)
const buffer = new Uint8Array(arrayBuffer)
// Save image to local storage
const fileMetadata = await window.api.file.savePastedImage(buffer, extension)
// Insert image into editor using local file path
if (editor && !editor.isDestroyed) {
const imageUrl = `file://${fileMetadata.path}`
editor.chain().focus().setImage({ src: imageUrl, alt: fileMetadata.origin_name }).run()
}
logger.info('Image pasted and saved:', fileMetadata)
} catch (error) {
logger.error('Failed to handle image paste:', error as Error)
}
},
[editor]
)
useEffect(() => {
if (editor && !editor.isDestroyed) {
editor.setEditable(editable)
if (editable) {
try {
setTimeout(() => {
if (editor && !editor.isDestroyed) {
editor.commands.focus('end')
}
}, 0)
} catch (error) {
logger.warn('Could not set cursor to end after enabling editable:', error as Error)
}
}
}
}, [editor, editable])
// Link editor callbacks (after editor is defined)
const handleLinkSave = useCallback(
(href: string, text: string) => {
if (!editor || editor.isDestroyed) return
const { linkRange } = linkEditorState
if (linkRange) {
// We have explicit link range - use it
editor
.chain()
.focus()
.setTextSelection({ from: linkRange.from, to: linkRange.to })
.insertContent(text)
.setTextSelection({ from: linkRange.from, to: linkRange.from + text.length })
.setEnhancedLink({ href })
.run()
}
setLinkEditorState({
show: false,
position: { x: 0, y: 0 },
link: { href: '', text: '' }
})
},
[editor, linkEditorState]
)
const handleLinkRemove = useCallback(() => {
if (!editor || editor.isDestroyed) return
const { linkRange } = linkEditorState
if (linkRange) {
// Use a more reliable method - directly remove the mark from the range
const tr = editor.state.tr
tr.removeMark(linkRange.from, linkRange.to, editor.schema.marks.enhancedLink || editor.schema.marks.link)
editor.view.dispatch(tr)
} else {
// No explicit range - try to extend current mark range and remove
editor.chain().focus().extendMarkRange('enhancedLink').unsetEnhancedLink().run()
}
// Close link editor
setLinkEditorState({
show: false,
position: { x: 0, y: 0 },
link: { href: '', text: '' }
})
}, [editor, linkEditorState])
const handleLinkCancel = useCallback(() => {
setLinkEditorState({
show: false,
position: { x: 0, y: 0 },
link: { href: '', text: '' }
})
}, [])
// Show action menu for table rows/columns
const showTableActionMenu = useCallback(
(type: 'row' | 'column', index: number, position?: { x: number; y: number }) => {
if (!editor) return
const actions = [
{
id: type === 'row' ? 'insertRowBefore' : 'insertColumnBefore',
label:
type === 'row'
? t('richEditor.action.table.insertRowBefore')
: t('richEditor.action.table.insertColumnBefore'),
action: () => {
if (type === 'row') {
editor.chain().focus().addRowBefore().run()
} else {
editor.chain().focus().addColumnBefore().run()
}
}
},
{
id: type === 'row' ? 'insertRowAfter' : 'insertColumnAfter',
label:
type === 'row'
? t('richEditor.action.table.insertRowAfter')
: t('richEditor.action.table.insertColumnAfter'),
action: () => {
if (type === 'row') {
editor.chain().focus().addRowAfter().run()
} else {
editor.chain().focus().addColumnAfter().run()
}
}
},
{
id: type === 'row' ? 'deleteRow' : 'deleteColumn',
label: type === 'row' ? t('richEditor.action.table.deleteRow') : t('richEditor.action.table.deleteColumn'),
action: () => {
if (type === 'row') {
editor.chain().focus().deleteRow().run()
} else {
editor.chain().focus().deleteColumn().run()
}
}
}
]
// Compute fallback position if not provided
let finalPosition = position
if (!finalPosition) {
const rect = editor.view.dom.getBoundingClientRect()
finalPosition = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
onShowTableActionMenu?.({ type, index, position: finalPosition!, actions })
},
[editor, onShowTableActionMenu]
)
useEffect(() => {
return () => {
if (editor && !editor.isDestroyed) {
editor.destroy()
}
}
}, [editor])
const formattingState = useEditorState({
editor,
selector: ({ editor }) => {
if (!editor || editor.isDestroyed) {
return {
isBold: false,
canBold: false,
isItalic: false,
canItalic: false,
isUnderline: false,
canUnderline: false,
isStrike: false,
canStrike: false,
isCode: false,
canCode: false,
canClearMarks: false,
isParagraph: false,
isHeading1: false,
isHeading2: false,
isHeading3: false,
isHeading4: false,
isHeading5: false,
isHeading6: false,
isBulletList: false,
isOrderedList: false,
isCodeBlock: false,
isBlockquote: false,
isLink: false,
canLink: false,
canUnlink: false,
canUndo: false,
canRedo: false,
isTable: false,
canTable: false,
canImage: false,
isMath: false,
isInlineMath: false,
canMath: false,
isTaskList: false
}
}
return {
isBold: editor.isActive('bold') ?? false,
canBold: editor.can().chain().toggleBold().run() ?? false,
isItalic: editor.isActive('italic') ?? false,
canItalic: editor.can().chain().toggleItalic().run() ?? false,
isUnderline: editor.isActive('underline') ?? false,
canUnderline: editor.can().chain().toggleUnderline().run() ?? false,
isStrike: editor.isActive('strike') ?? false,
canStrike: editor.can().chain().toggleStrike().run() ?? false,
isCode: editor.isActive('code') ?? false,
canCode: editor.can().chain().toggleCode().run() ?? false,
canClearMarks: editor.can().chain().unsetAllMarks().run() ?? false,
isParagraph: editor.isActive('paragraph') ?? false,
isHeading1: editor.isActive('heading', { level: 1 }) ?? false,
isHeading2: editor.isActive('heading', { level: 2 }) ?? false,
isHeading3: editor.isActive('heading', { level: 3 }) ?? false,
isHeading4: editor.isActive('heading', { level: 4 }) ?? false,
isHeading5: editor.isActive('heading', { level: 5 }) ?? false,
isHeading6: editor.isActive('heading', { level: 6 }) ?? false,
isBulletList: editor.isActive('bulletList') ?? false,
isOrderedList: editor.isActive('orderedList') ?? false,
isCodeBlock: editor.isActive('codeBlock') ?? false,
isBlockquote: editor.isActive('blockquote') ?? false,
isLink: (editor.isActive('enhancedLink') || editor.isActive('link')) ?? false,
canLink: editor.can().chain().setEnhancedLink({ href: '' }).run() ?? false,
canUnlink: editor.can().chain().unsetEnhancedLink().run() ?? false,
canUndo: editor.can().chain().undo().run() ?? false,
canRedo: editor.can().chain().redo().run() ?? false,
isTable: editor.isActive('table') ?? false,
canTable: editor.can().chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() ?? false,
canImage: editor.can().chain().setImage({ src: '' }).run() ?? false,
isMath: editor.isActive('blockMath') ?? false,
isInlineMath: editor.isActive('inlineMath') ?? false,
canMath: true,
isTaskList: editor.isActive('taskList') ?? false
}
}
})
const setMarkdown = useCallback(
(content: string) => {
try {
setMarkdownState(content)
onChange?.(content)
const convertedHtml = markdownToSafeHtml(content)
editor.commands.setContent(convertedHtml)
onHtmlChange?.(convertedHtml)
} catch (error) {
logger.error('Error setting markdown content:', error as Error)
}
},
[editor.commands, onChange, onHtmlChange]
)
const setHtml = useCallback(
(htmlContent: string) => {
try {
const convertedMarkdown = htmlToMarkdown(htmlContent)
setMarkdownState(convertedMarkdown)
onChange?.(convertedMarkdown)
editor.commands.setContent(htmlContent)
onHtmlChange?.(htmlContent)
} catch (error) {
logger.error('Error setting HTML content:', error as Error)
}
},
[editor.commands, onChange, onHtmlChange]
)
const clear = useCallback(() => {
setMarkdownState('')
onChange?.('')
onHtmlChange?.('')
}, [onChange, onHtmlChange])
// Utility methods
const toHtml = useCallback((content: string): string => {
try {
return markdownToHtml(content)
} catch (error) {
logger.error('Error converting markdown to HTML:', error as Error)
return ''
}
}, [])
const toSafeHtml = useCallback((content: string): string => {
try {
return markdownToSafeHtml(content)
} catch (error) {
logger.error('Error converting markdown to safe HTML:', error as Error)
return ''
}
}, [])
const toMarkdown = useCallback((htmlContent: string): string => {
try {
return htmlToMarkdown(htmlContent)
} catch (error) {
logger.error('Error converting HTML to markdown:', error as Error)
return ''
}
}, [])
const getPreviewText = useCallback(
(content: string, maxLength?: number): string => {
try {
return markdownToPreviewText(content, maxLength || previewLength)
} catch (error) {
logger.error('Error generating preview text:', error as Error)
return ''
}
},
[previewLength]
)
return {
// Editor instance
editor,
// State
markdown,
html,
previewText,
isMarkdown,
disabled: !editable,
formattingState,
tableOfContentsItems,
linkEditor: {
show: linkEditorState.show,
position: linkEditorState.position,
link: linkEditorState.link,
onSave: handleLinkSave,
onRemove: handleLinkRemove,
onCancel: handleLinkCancel
},
// Actions
setMarkdown,
setHtml,
clear,
// Utilities
toHtml,
toSafeHtml,
toMarkdown,
getPreviewText
}
}

View File

@ -20,6 +20,7 @@ import {
LayoutGrid,
Monitor,
Moon,
NotepadText,
Palette,
Settings,
Sparkle,
@ -50,6 +51,8 @@ const getTabIcon = (tabId: string): React.ReactNode | undefined => {
return <Palette size={14} />
case 'apps':
return <LayoutGrid size={14} />
case 'notes':
return <NotepadText size={14} />
case 'knowledge':
return <FileSearch size={14} />
case 'mcp':

View File

@ -22,6 +22,7 @@ import {
MessageSquare,
Monitor,
Moon,
NotepadText,
Palette,
Settings,
Sparkle,
@ -136,7 +137,8 @@ const MainMenus: FC = () => {
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
knowledge: <FileSearch size={18} className="icon" />,
files: <Folder size={17} className="icon" />,
files: <Folder size={18} className="icon" />,
notes: <NotepadText size={18} className="icon" />,
code_tools: <Code size={18} className="icon" />
}
@ -148,7 +150,8 @@ const MainMenus: FC = () => {
minapp: '/apps',
knowledge: '/knowledge',
files: '/files',
code_tools: '/code'
code_tools: '/code',
notes: '/notes'
}
return sidebarIcons.visible.map((icon) => {

View File

@ -12,7 +12,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
'minapp',
'knowledge',
'files',
'code_tools'
'code_tools',
'notes'
]
/**

View File

@ -1,6 +1,7 @@
import { CustomTranslateLanguage, FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@renderer/types'
// Import necessary types for blocks and new message structure
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
import { NotesTreeNode } from '@renderer/types/note'
import { Dexie, type EntityTable } from 'dexie'
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
@ -17,6 +18,7 @@ export const db = new Dexie('CherryStudio', {
quick_phrases: EntityTable<QuickPhrase, 'id'>
message_blocks: EntityTable<MessageBlock, 'id'> // Correct type for message_blocks
translate_languages: EntityTable<CustomTranslateLanguage, 'id'>
notes_tree: EntityTable<{ id: string; tree: NotesTreeNode[] }, 'id'>
}
db.version(1).stores({
@ -102,4 +104,16 @@ db.version(9).stores({
message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator
})
db.version(10).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id',
settings: '&id, value',
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
translate_languages: '&id, langCode',
quick_phrases: 'id',
message_blocks: 'id, messageId, file.id',
notes_tree: '&id'
})
export default db

View File

@ -0,0 +1,85 @@
import { useAppSelector } from '@renderer/store'
import { selectActiveFilePath } from '@renderer/store/note'
import { NotesTreeNode } from '@renderer/types/note'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo } from 'react'
// 查找节点的工具函数
export const findNodeByPath = (tree: NotesTreeNode[], targetPath: string): NotesTreeNode | null => {
for (const node of tree) {
if (node.externalPath === targetPath) {
return node
}
if (node.children) {
const found = findNodeByPath(node.children, targetPath)
if (found) return found
}
}
return null
}
/**
* useLiveQuery的树数据
*/
export function useActiveNode(notesTree: NotesTreeNode[]) {
const activeFilePath = useAppSelector(selectActiveFilePath)
const activeNode = useMemo(() => {
if (!notesTree || !activeFilePath) return null
return findNodeByPath(notesTree, activeFilePath)
}, [notesTree, activeFilePath])
return {
activeNode,
hasActiveFile: !!activeFilePath
}
}
/**
* hook -
*/
export function useFileContentSync() {
const queryClient = useQueryClient()
const invalidateFileContent = useCallback(
(filePath: string) => {
queryClient.invalidateQueries({
queryKey: ['fileContent', filePath],
exact: true
})
},
[queryClient]
)
const refetchFileContent = useCallback(
async (filePath: string) => {
await queryClient.refetchQueries({
queryKey: ['fileContent', filePath],
exact: true
})
},
[queryClient]
)
return {
invalidateFileContent,
refetchFileContent
}
}
/**
* hook - 使React Query管理
*/
export function useFileContent(filePath?: string) {
return useQuery({
queryKey: ['fileContent', filePath],
queryFn: async () => {
if (!filePath) return ''
return await window.api.file.readExternal(filePath)
},
enabled: !!filePath,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
retry: 1
})
}

View File

@ -0,0 +1,29 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
NotesSettings,
selectNotesPath,
selectNotesSettings,
setNotesPath,
updateNotesSettings
} from '@renderer/store/note'
export const useNotesSettings = () => {
const dispatch = useAppDispatch()
const settings = useAppSelector(selectNotesSettings)
const notesPath = useAppSelector(selectNotesPath)
const updateSettings = (newSettings: Partial<NotesSettings>) => {
dispatch(updateNotesSettings(newSettings))
}
const updateNotesPath = (path: string) => {
dispatch(setNotesPath(path))
}
return {
settings,
updateSettings,
notesPath,
updateNotesPath
}
}

View File

@ -3,8 +3,10 @@ import {
setAssistantsTabSortType,
setShowAssistants,
setShowTopics,
setShowWorkspace,
toggleShowAssistants,
toggleShowTopics
toggleShowTopics,
toggleShowWorkspace
} from '@renderer/store/settings'
import { AssistantsSortType } from '@renderer/types'
@ -39,3 +41,14 @@ export function useAssistantsTabSortType() {
setAssistantsTabSortType: (sortType: AssistantsSortType) => dispatch(setAssistantsTabSortType(sortType))
}
}
export function useShowWorkspace() {
const showWorkspace = useAppSelector((state) => state.settings.showWorkspace)
const dispatch = useAppDispatch()
return {
showWorkspace,
setShowWorkspace: (show: boolean) => dispatch(setShowWorkspace(show)),
toggleShowWorkspace: () => dispatch(toggleShowWorkspace())
}
}

View File

@ -134,6 +134,7 @@ const titleKeyMap = {
launchpad: 'title.launchpad',
'mcp-servers': 'title.mcp-servers',
memories: 'title.memories',
notes: 'title.notes',
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate'
@ -161,7 +162,8 @@ const sidebarIconKeyMap = {
minapp: 'minapp.title',
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title'
code_tools: 'code.title',
notes: 'notes.title'
} as const
export const getSidebarIconLabel = (key: string): string => {

View File

@ -597,6 +597,7 @@
"label": "Export as markdown",
"reason": "Export as Markdown (with reasoning)"
},
"notes": "Export to Notes",
"notion": "Export to Notion",
"obsidian": "Export to Obsidian",
"obsidian_atributes": "Configure Note Attributes",
@ -1292,6 +1293,9 @@
"specified": "Failed to export the Markdown file"
}
},
"notes": {
"export": "Failed to export notes"
},
"notion": {
"export": "Failed to export to Notion. Please check connection status and configuration according to documentation",
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
@ -1385,6 +1389,9 @@
"specified": "Successfully exported the Markdown file"
}
},
"notes": {
"export": "Successfully exported to notes"
},
"notion": {
"export": "Successfully exported to Notion"
},
@ -1589,6 +1596,88 @@
"navigate": {
"provider_settings": "Go to provider settings"
},
"notes": {
"characters": "Characters",
"collapse": "Collapse",
"content_placeholder": "Please enter the note content...",
"copyContent": "Copy Content",
"delete": "delete",
"delete_confirm": "Are you sure you want to delete this {{type}}?",
"delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?",
"delete_note_confirm": "Are you sure you want to delete the note \"{{name}}\"?",
"drop_markdown_hint": "Drop markdown files here to import",
"empty": "No notes available yet",
"expand": "unfold",
"export_failed": "Failed to export to knowledge base",
"export_knowledge": "Export notes to knowledge base",
"export_success": "Successfully exported to the knowledge base",
"folder": "folder",
"new_folder": "New Folder",
"new_note": "Create a new note",
"no_content_to_copy": "No content to copy",
"only_markdown": "Only Markdown files are supported",
"open_folder": "Open an external folder",
"rename": "Rename",
"save": "Save to Notes",
"settings": {
"data": {
"apply": "Apply",
"apply_path_failed": "Failed to apply path",
"current_work_directory": "Current Work Directory",
"invalid_directory": "Selected directory is invalid or access denied",
"path_required": "Please select a work directory",
"path_updated": "Work directory updated successfully",
"reset_failed": "Reset failed",
"reset_to_default": "Reset to Default",
"select": "Select",
"select_directory_failed": "Failed to select directory",
"title": "Data Settings",
"work_directory_description": "Work directory is where all note files are stored. Changing the work directory won't move existing files, please migrate files manually.",
"work_directory_placeholder": "Select notes work directory"
},
"display": {
"compress_content": "Content Compression",
"compress_content_description": "When enabled, it will limit the number of characters per line, reducing the content displayed on the screen, but making longer paragraphs more readable.",
"default_font": "Default font",
"font_title": "Font settings",
"serif_font": "Serif font",
"title": "Display Settings"
},
"editor": {
"edit_mode": {
"description": "In Edit View, the default editing mode for new notes",
"preview_mode": "Live preview",
"source_mode": "Source code mode",
"title": "Default edit view"
},
"title": "Editor Settings",
"view_mode": {
"description": "New Notes Default View Mode",
"edit_mode": "Editing mode",
"read_mode": "Reading mode",
"title": "Default view"
},
"view_mode_description": "Sets the default view mode for the new tab page."
},
"title": "Notes"
},
"show_starred": "Show favorite notes",
"sort_a2z": "File name (A-Z)",
"sort_created_asc": "Creation time (oldest first)",
"sort_created_desc": "Creation time (newest first)",
"sort_updated_asc": "Update time (oldest first)",
"sort_updated_desc": "Update time (newest first)",
"sort_z2a": "File name (Z-A)",
"star": "Favorite",
"starred_notes": "Collected notes",
"title": "Notes",
"unsaved_changes": "You have unsaved content, are you sure you want to leave?",
"unstar": "Unfavorite",
"untitled_folder": "New Folder",
"untitled_note": "Untitled Note",
"upload_failed": "Note upload failed",
"upload_success": "Note uploaded success"
},
"notification": {
"assistant": "Assistant Response",
"knowledge": {
@ -1893,6 +1982,206 @@
},
"title": "Data Restore"
},
"richEditor": {
"action": {
"table": {
"deleteColumn": "Delete columns",
"deleteRow": "Delete rows",
"insertColumnAfter": "Insert After",
"insertColumnBefore": "Insert Before",
"insertRowAfter": "Insert Below",
"insertRowBefore": "Insert Above"
}
},
"commands": {
"blockMath": {
"description": "Insert mathematical formula",
"title": "Math Formula"
},
"blockquote": {
"description": "Capture a quote",
"title": "Quote"
},
"bold": {
"description": "Marked in bold",
"title": "Bold"
},
"bulletList": {
"description": "Create a simple bulleted list",
"title": "Bulleted list"
},
"calloutInfo": {
"description": "Add an info callout box",
"title": "Info Callout"
},
"calloutWarning": {
"description": "Add a warning callout box",
"title": "Warning Callout"
},
"code": {
"description": "Insert code snippet",
"title": "Code"
},
"codeBlock": {
"description": "Capture a code snippet",
"title": "Code"
},
"columns": {
"description": "Create column layout",
"title": "Columns"
},
"date": {
"description": "Insert current date",
"title": "Date"
},
"divider": {
"description": "Add a horizontal line",
"title": "Divider"
},
"hardBreak": {
"description": "Insert a line break",
"title": "Line Break"
},
"heading1": {
"description": "Big section heading",
"title": "Heading 1"
},
"heading2": {
"description": "Medium section heading",
"title": "Heading 2"
},
"heading3": {
"description": "Small section heading",
"title": "Heading 3"
},
"heading4": {
"description": "Smaller section heading",
"title": "Heading 4"
},
"heading5": {
"description": "Even smaller section heading",
"title": "Heading 5"
},
"heading6": {
"description": "Smallest section heading",
"title": "Heading 6"
},
"image": {
"description": "Insert an image",
"title": "Image"
},
"inlineCode": {
"description": "Add inline code",
"title": "Inline Code"
},
"inlineMath": {
"description": "Insert inline mathematical formulas",
"title": "Inline Math"
},
"italic": {
"description": "Marked as italic",
"title": "Italic"
},
"link": {
"description": "Add a link",
"title": "Link"
},
"noCommandsFound": "No commands found",
"orderedList": {
"description": "Create a list with numbering",
"title": "Numbered list"
},
"paragraph": {
"description": "Start writing with plain text",
"title": "Text"
},
"redo": {
"description": "Redo the last action",
"title": "Redo"
},
"strike": {
"description": "Mark as a delete line",
"title": "Delete line"
},
"table": {
"description": "Insert a table",
"title": "Table"
},
"taskList": {
"description": "Create a checklist",
"title": "Task List"
},
"underline": {
"description": "Mark as underlined",
"title": "Underline"
},
"undo": {
"description": "Undo the last action",
"title": "Undo"
}
},
"dragHandle": "Drag to move",
"image": {
"placeholder": "Add a picture"
},
"imageUploader": {
"embedImage": "Embed image",
"embedLink": "Embed link",
"embedSuccess": "Image embedded successfully",
"invalidType": "Please select an image file",
"invalidUrl": "Invalid image URL",
"processing": "Processing image...",
"title": "Add an image",
"tooLarge": "Image size cannot exceed 10MB",
"upload": "Upload",
"uploadError": "Image upload failed",
"uploadFile": "Upload file",
"uploadHint": "Supports JPG, PNG, GIF and other formats, max 10MB",
"uploadSuccess": "Image uploaded successfully",
"uploadText": "Click or drag image here to upload",
"uploading": "Uploading image",
"urlPlaceholder": "Paste image link",
"urlRequired": "Please enter image URL"
},
"link": {
"remove": "Remove link",
"text": "Link Title",
"textPlaceholder": "Please enter the link title",
"url": "Link URL"
},
"math": {
"placeholder": "Enter LaTeX formula"
},
"placeholder": "Write '/' for commands",
"plusButton": "Click to add below",
"toolbar": {
"blockMath": "Block Math",
"blockquote": "Quote",
"bold": "Bold",
"bulletList": "Bullet List",
"clearMarks": "Clear Formatting",
"code": "Inline Code",
"codeBlock": "Code Block",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"heading4": "Heading 4",
"heading5": "Heading 5",
"heading6": "Heading 6",
"image": "Image",
"inlineMath": "Inline Equation",
"italic": "Italic",
"link": "Link",
"orderedList": "Ordered List",
"paragraph": "Paragraph",
"redo": "Redo",
"strike": "Strikethrough",
"table": "Table",
"taskList": "Task List",
"underline": "Underline",
"undo": "Undo"
}
},
"selection": {
"action": {
"builtin": {
@ -2204,6 +2493,7 @@
"joplin": "Export to Joplin",
"markdown": "Export as Markdown",
"markdown_reason": "Export as Markdown (with reasoning)",
"notes": "Export to Notes",
"notion": "Export to Notion",
"obsidian": "Export to Obsidian",
"plain_text": "Copy as Plain Text",
@ -3356,7 +3646,7 @@
"label": "HTTP authentication",
"password": {
"label": "Password",
"tip": ""
"tip": "Enter your password"
},
"tip": "Applicable to instances deployed remotely (see the documentation). Currently, only the Basic scheme (RFC 7617) is supported.",
"user_name": {
@ -3735,6 +4025,7 @@
"launchpad": "Launchpad",
"mcp-servers": "MCP Servers",
"memories": "Memories",
"notes": "Notes",
"paintings": "Paintings",
"settings": "Settings",
"translate": "Translate"

View File

@ -597,6 +597,7 @@
"label": "Markdownとしてエクスポート",
"reason": "Markdown としてエクスポート (思考内容を含む)"
},
"notes": "ノートにエクスポート",
"notion": "Notion にエクスポート",
"obsidian": "Obsidian にエクスポート",
"obsidian_atributes": "ノートの属性を設定",
@ -1292,6 +1293,9 @@
"specified": "Markdown ファイルのエクスポートに失敗しました"
}
},
"notes": {
"export": "ノートのエクスポートに失敗しました"
},
"notion": {
"export": "Notionへのエクスポートに失敗しました。接続状態と設定を確認してください",
"no_api_key": "Notion ApiKey または Notion DatabaseID が設定されていません",
@ -1385,6 +1389,9 @@
"specified": "Markdown ファイルを正常にエクスポートしました"
}
},
"notes": {
"export": "成功的にノートにエクスポートされました"
},
"notion": {
"export": "Notionへのエクスポートに成功しました"
},
@ -1589,6 +1596,88 @@
"navigate": {
"provider_settings": "プロバイダー設定に移動"
},
"notes": {
"characters": "文字",
"collapse": "閉じる",
"content_placeholder": "メモの内容を入力してください...",
"copyContent": "コンテンツをコピーします",
"delete": "削除",
"delete_confirm": "この{{type}}を本当に削除しますか?",
"delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?",
"delete_note_confirm": "メモ \"{{name}}\" を削除してもよろしいですか?",
"drop_markdown_hint": "マークダウンファイルをドラッグアンドドロップしてここにインポートします",
"empty": "暫無ノート",
"expand": "展開",
"export_failed": "知識ベースへのエクスポートに失敗しました",
"export_knowledge": "ノートをナレッジベースにエクスポートする",
"export_success": "知識ベースへのエクスポートが成功しました",
"folder": "フォルダー",
"new_folder": "新しいフォルダーを作成する",
"new_note": "新規ノート作成",
"no_content_to_copy": "コピーするコンテンツはありません",
"only_markdown": "Markdown ファイルのみをアップロードできます",
"open_folder": "外部フォルダーを開きます",
"rename": "名前の変更",
"save": "メモに保存する",
"settings": {
"data": {
"apply": "応用",
"apply_path_failed": "アプリケーションパスが失敗しました",
"current_work_directory": "現在の作業ディレクトリ",
"invalid_directory": "選択したディレクトリは無効であるか、権限がありません",
"path_required": "ワーキングディレクトリを選択してください",
"path_updated": "ワーキングディレクトリの更新は正常に更新されます",
"reset_failed": "リセットに失敗しました",
"reset_to_default": "デフォルトにリセットします",
"select": "選ぶ",
"select_directory_failed": "ディレクトリの選択に失敗しました",
"title": "データ設定",
"work_directory_description": "作業ディレクトリは、すべてのメモが保存される場所です。ワーキングディレクトリを変更しても、既存のファイルは移動しません。ファイルを手動で移行してください。",
"work_directory_placeholder": "ノートワークディレクトリを選択します"
},
"display": {
"compress_content": "バーの幅を減らします",
"compress_content_description": "有効にすると、1行あたりの単語数が制限され、画面に表示されるコンテンツが減少します。",
"default_font": "デフォルトフォント",
"font_title": "フォント設定",
"serif_font": "セリフフォント",
"title": "見せる"
},
"editor": {
"edit_mode": {
"description": "編集ビューでは、新しいメモのデフォルトの編集モード",
"preview_mode": "ライブプレビュー",
"source_mode": "ソースコードモード",
"title": "デフォルトの編集ビュー"
},
"title": "エディター設定",
"view_mode": {
"description": "新しいノートデフォルトビューモード",
"edit_mode": "編集モード",
"read_mode": "読み取りモード",
"title": "デフォルトビュー"
},
"view_mode_description": "新しいタブページのデフォルトビューモードを設定します。"
},
"title": "その他のオプション"
},
"show_starred": "お気に入りのノートを表示する",
"sort_a2z": "ファイル名A-Z",
"sort_created_asc": "作成日時(古い順)",
"sort_created_desc": "作成日時(新しい順)",
"sort_updated_asc": "更新日時(古い順)",
"sort_updated_desc": "更新日時(新しい順)",
"sort_z2a": "ファイル名Z-A",
"star": "お気に入りに追加する",
"starred_notes": "収集したノート",
"title": "ノート",
"unsaved_changes": "保存されていないコンテンツがあります。本当に離れますか?",
"unstar": "お気に入りを解除する",
"untitled_folder": "新ファイル夹",
"untitled_note": "無題のメモ",
"upload_failed": "ノートのアップロードに失敗しました",
"upload_success": "ノートのアップロードが成功しました"
},
"notification": {
"assistant": "助手回應",
"knowledge": {
@ -1893,6 +1982,206 @@
},
"title": "データ復元"
},
"richEditor": {
"action": {
"table": {
"deleteColumn": "列を削除",
"deleteRow": "行を削除",
"insertColumnAfter": "右に挿入",
"insertColumnBefore": "左に挿入",
"insertRowAfter": "下に挿入",
"insertRowBefore": "上に挿入"
}
},
"commands": {
"blockMath": {
"description": "数式を挿入します",
"title": "数式"
},
"blockquote": {
"description": "参照されたテキストを挿入します",
"title": "引用"
},
"bold": {
"description": "太字でマークされています",
"title": "大胆な"
},
"bulletList": {
"description": "シンプルな弾丸リストを作成します",
"title": "順序付けられていないリスト"
},
"calloutInfo": {
"description": "メッセージプロンプトボックスを追加します",
"title": "情報プロンプトボックス"
},
"calloutWarning": {
"description": "警告ボックスを追加します",
"title": "警告プロンプトボックス"
},
"code": {
"description": "コードスニペットを挿入します",
"title": "コード"
},
"codeBlock": {
"description": "コードスニペットを挿入します",
"title": "コードブロック"
},
"columns": {
"description": "列レイアウトを作成します",
"title": "セクション列"
},
"date": {
"description": "現在の日付を挿入します",
"title": "日付"
},
"divider": {
"description": "水平方向のスプリットラインを追加します",
"title": "分割線"
},
"hardBreak": {
"description": "ラインブレークを挿入します",
"title": "ラインブレーク"
},
"heading1": {
"description": "大きな段落タイトル",
"title": "レベル1タイトル"
},
"heading2": {
"description": "真ん中の段落タイトル",
"title": "二次タイトル"
},
"heading3": {
"description": "小さな段落タイトル",
"title": "レベル3タイトル"
},
"heading4": {
"description": "より小さな段落タイトル",
"title": "レベル4タイトル"
},
"heading5": {
"description": "より小さな段落タイトル",
"title": "レベル5タイトル"
},
"heading6": {
"description": "最小限の段落タイトル",
"title": "レベル6タイトル"
},
"image": {
"description": "画像を挿入します",
"title": "写真"
},
"inlineCode": {
"description": "インラインコードを追加します",
"title": "インラインコード"
},
"inlineMath": {
"description": "行に数式を挿入します",
"title": "業界の数式"
},
"italic": {
"description": "イタリックとしてマークされています",
"title": "イタリック"
},
"link": {
"description": "リンクを追加します",
"title": "リンク"
},
"noCommandsFound": "コマンドが見つかりません",
"orderedList": {
"description": "番号付きリストを作成します",
"title": "注文リスト"
},
"paragraph": {
"description": "プレーンテキストの書き始めます",
"title": "文章"
},
"redo": {
"description": "前のステップを作り直します",
"title": "やり直し"
},
"strike": {
"description": "削除行としてマークします",
"title": "行を削除します"
},
"table": {
"description": "テーブルを挿入します",
"title": "シート"
},
"taskList": {
"description": "To Doリストを作成します",
"title": "タスクリスト"
},
"underline": {
"description": "下線付けのマーク",
"title": "下線"
},
"undo": {
"description": "前の操作を元に戻します",
"title": "取り消す"
}
},
"dragHandle": "ブロックをドラッグします",
"image": {
"placeholder": "写真を追加します"
},
"imageUploader": {
"embedImage": "埋め込まれた写真",
"embedLink": "埋め込みリンク",
"embedSuccess": "画像埋め込みは正常に埋め込まれています",
"invalidType": "画像ファイルを選択してください",
"invalidUrl": "無効な画像リンク",
"processing": "写真を扱う...",
"title": "写真を追加します",
"tooLarge": "画像サイズは10MBを超えることはできません",
"upload": "アップロード",
"uploadError": "画像のアップロードに失敗しました",
"uploadFile": "ファイルをアップロード",
"uploadHint": "JPG、PNG、GIFおよびその他の形式をサポートし、最大10MB",
"uploadSuccess": "画像アップロードに正常にアップロードします",
"uploadText": "画像をクリックまたはドラッグしてここにアップロードします",
"uploading": "写真のアップロード",
"urlPlaceholder": "画像リンクアドレスを貼り付けます",
"urlRequired": "画像リンクアドレスを入力してください"
},
"link": {
"remove": "リンクを削除します",
"text": "リンクタイトル",
"textPlaceholder": "リンクタイトルを入力してください",
"url": "リンクアドレス"
},
"math": {
"placeholder": "ラテックスフォーミュラを入力します"
},
"placeholder": "'/'を入力してコマンドを呼び出します",
"plusButton": "クリックして以下を追加します",
"toolbar": {
"blockMath": "数式",
"blockquote": "引用",
"bold": "大胆な",
"bulletList": "順序付けられていないリスト",
"clearMarks": "クリア形式",
"code": "インラインコード",
"codeBlock": "コードブロック",
"heading1": "レベル1タイトル",
"heading2": "二次タイトル",
"heading3": "レベル3タイトル",
"heading4": "レベル4タイトル",
"heading5": "レベル5タイトル",
"heading6": "CET-6タイトル",
"image": "写真",
"inlineMath": "業界の数式",
"italic": "イタリック",
"link": "リンク",
"orderedList": "注文リスト",
"paragraph": "文章",
"redo": "やり直し",
"strike": "行を削除します",
"table": "シート",
"taskList": "タスクリスト",
"underline": "下線",
"undo": "取り消す"
}
},
"selection": {
"action": {
"builtin": {
@ -2204,6 +2493,7 @@
"joplin": "Joplinにエクスポート",
"markdown": "Markdownとしてエクスポート",
"markdown_reason": "Markdownとしてエクスポート思考内容を含む",
"notes": "ノートにエクスポートする",
"notion": "Notionにエクスポート",
"obsidian": "Obsidianにエクスポート",
"plain_text": "プレーンテキストとしてコピー",
@ -3356,7 +3646,7 @@
"label": "HTTP 認証",
"password": {
"label": "パスワード",
"tip": ""
"tip": "パスワードを入力してください"
},
"tip": "サーバー展開によるインスタンスに適用されますドキュメントを参照。現在はBasicスキームRFC7617のみをサポートしています。",
"user_name": {
@ -3735,6 +4025,7 @@
"launchpad": "ランチパッド",
"mcp-servers": "MCP サーバー",
"memories": "メモリ",
"notes": "ノート",
"paintings": "ペインティング",
"settings": "設定",
"translate": "翻訳"

View File

@ -597,6 +597,7 @@
"label": "Экспорт как markdown",
"reason": "Экспорт в Markdown (с рассуждениями)"
},
"notes": "экспорт в заметки",
"notion": "Экспорт в Notion",
"obsidian": "Экспорт в Obsidian",
"obsidian_atributes": "Настроить атрибуты заметки",
@ -1292,6 +1293,9 @@
"specified": "Не удалось экспортировать файл Markdown"
}
},
"notes": {
"export": "не удалось экспортировать заметку"
},
"notion": {
"export": "Ошибка экспорта в Notion, пожалуйста, проверьте состояние подключения и настройки в документации",
"no_api_key": "Notion ApiKey или Notion DatabaseID не настроен",
@ -1385,6 +1389,9 @@
"specified": "Файл Markdown успешно экспортирован"
}
},
"notes": {
"export": "Успешно экспортировано в заметки"
},
"notion": {
"export": "Успешный экспорт в Notion"
},
@ -1589,6 +1596,88 @@
"navigate": {
"provider_settings": "Перейти к настройкам поставщика"
},
"notes": {
"characters": "Символы",
"collapse": "Свернуть",
"content_placeholder": "Введите содержимое заметки...",
"copyContent": "Копировать контент",
"delete": "удалить",
"delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?",
"delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?",
"delete_note_confirm": "Вы действительно хотите удалить заметку \"{{name}}\"?",
"drop_markdown_hint": "Перетаскивать файл разметки, чтобы импортировать его здесь",
"empty": "заметок пока нет",
"expand": "развернуть",
"export_failed": "Экспорт в базу знаний не выполнен",
"export_knowledge": "Экспортировать заметки в базу знаний",
"export_success": "Успешно экспортировано в базу знаний",
"folder": "папка",
"new_folder": "Новая папка",
"new_note": "Создать заметку",
"no_content_to_copy": "Нет контента для копирования",
"only_markdown": "Только Markdown",
"open_folder": "Откройте внешнюю папку",
"rename": "переименовать",
"save": "Сохранить в заметки",
"settings": {
"data": {
"apply": "приложение",
"apply_path_failed": "Путь применения не удался",
"current_work_directory": "Текущий рабочий каталог",
"invalid_directory": "Выбранный каталог недействителен или не имеет разрешений",
"path_required": "Пожалуйста, выберите рабочий каталог",
"path_updated": "Успешное обновление рабочего каталога",
"reset_failed": "Сброс не удался",
"reset_to_default": "Сбросить по умолчанию",
"select": "выбирать",
"select_directory_failed": "Не удалось выбрать каталог",
"title": "Настройки данных",
"work_directory_description": "Рабочий каталог - это место, где хранятся все заметки. Изменение рабочего каталога не будет перемещать существующие файлы, пожалуйста, переносите файлы вручную.",
"work_directory_placeholder": "Выберите Справочник рабочих примечаний"
},
"display": {
"compress_content": "Уменьшить ширину стержня",
"compress_content_description": "При включении он ограничит количество слов на строку, уменьшая содержимое, отображаемое на экране.",
"default_font": "По умолчанию шрифт",
"font_title": "Настройки шрифта",
"serif_font": "Serif Font",
"title": "показывать"
},
"editor": {
"edit_mode": {
"description": "В Edit View режим редактирования по умолчанию для новых заметок",
"preview_mode": "Живой предварительный просмотр",
"source_mode": "Режим исходного кода",
"title": "По умолчанию редактирование представление"
},
"title": "Настройки редактора",
"view_mode": {
"description": "Новые примечания по умолчанию режим просмотра",
"edit_mode": "Режим редактирования",
"read_mode": "Режим чтения",
"title": "По умолчанию представление"
},
"view_mode_description": "Устанавливает режим просмотра по умолчанию для новой страницы вкладки."
},
"title": "Больше вариантов"
},
"show_starred": "Показать сохраненные заметки",
"sort_a2z": "Имя файла (A-Я)",
"sort_created_asc": "Время создания (от старого к новому)",
"sort_created_desc": "Время создания (от нового к старому)",
"sort_updated_asc": "Время обновления (от старого к новому)",
"sort_updated_desc": "Время обновления (от нового к старому)",
"sort_z2a": "Имя файла (Я-А)",
"star": "Сохранить",
"starred_notes": "Сохраненные заметки",
"title": "заметки",
"unsaved_changes": "Вы не сохранили содержимое. Вы уверены, что хотите уйти?",
"unstar": "отменить избранное",
"untitled_folder": "Новая папка",
"untitled_note": "Незаглавленная заметка",
"upload_failed": "Не удалось загрузить заметку",
"upload_success": "Заметка успешно загружена"
},
"notification": {
"assistant": "Ответ ассистента",
"knowledge": {
@ -1893,6 +1982,206 @@
},
"title": "Восстановление данных"
},
"richEditor": {
"action": {
"table": {
"deleteColumn": "Удалить столбцы",
"deleteRow": "Удалить ряды",
"insertColumnAfter": "Вставить справа",
"insertColumnBefore": "Вставить слева",
"insertRowAfter": "Вставьте ниже",
"insertRowBefore": "Вставьте выше"
}
},
"commands": {
"blockMath": {
"description": "Вставьте математические формулы",
"title": "Математические формулы"
},
"blockquote": {
"description": "Вставьте ссылочный текст",
"title": "Цитировать"
},
"bold": {
"description": "Отмечен жирным шрифтом",
"title": "Смелый"
},
"bulletList": {
"description": "Создайте простой список пуль",
"title": "Неупомянутый список"
},
"calloutInfo": {
"description": "Добавить поле для подсказки сообщения",
"title": "Информационная подсказка"
},
"calloutWarning": {
"description": "Добавить ящик для предупреждения",
"title": "Предупреждение о приглашении"
},
"code": {
"description": "Вставьте фрагмент кода",
"title": "Код"
},
"codeBlock": {
"description": "Вставьте фрагмент кода",
"title": "Кодовый блок"
},
"columns": {
"description": "Создать макет колонны",
"title": "Раздел столбцы"
},
"date": {
"description": "Вставьте текущую дату",
"title": "дата"
},
"divider": {
"description": "Добавить горизонтальную линию разделения",
"title": "Разделительная линия"
},
"hardBreak": {
"description": "Вставьте разрыв линии",
"title": "Линии перерывы"
},
"heading1": {
"description": "Большой титул абзаца",
"title": "Название 1 -го уровня"
},
"heading2": {
"description": "Название среднего абзаца",
"title": "Вторичное название"
},
"heading3": {
"description": "Название маленького абзаца",
"title": "Название 3 уровня"
},
"heading4": {
"description": "Название меньшего абзаца",
"title": "Название 4 уровня"
},
"heading5": {
"description": "Название меньшего абзаца",
"title": "Название 5 -го уровня"
},
"heading6": {
"description": "Минимальный титул абзаца",
"title": "CET-6 название"
},
"image": {
"description": "Вставьте картинку",
"title": "картина"
},
"inlineCode": {
"description": "Добавить встроенный код",
"title": "Встроенный код"
},
"inlineMath": {
"description": "Вставить математические формулы в ряд",
"title": "Математические формулы в отрасли"
},
"italic": {
"description": "Отмечен как курсив",
"title": "Курсив"
},
"link": {
"description": "Добавить ссылку",
"title": "Связь"
},
"noCommandsFound": "Команда не найдена",
"orderedList": {
"description": "Создать пронумерованный список",
"title": "Заказанный список"
},
"paragraph": {
"description": "Начните писать простой текст",
"title": "текст"
},
"redo": {
"description": "Переработать предыдущий шаг",
"title": "Переработка"
},
"strike": {
"description": "Отметьте как линию удаления",
"title": "Удалить линию"
},
"table": {
"description": "Вставьте таблицу",
"title": "лист"
},
"taskList": {
"description": "Создать список дел",
"title": "Список задач"
},
"underline": {
"description": "Марк как подчеркнут",
"title": "Подчеркнуть"
},
"undo": {
"description": "Отменить предыдущую операцию",
"title": "Отменить"
}
},
"dragHandle": "Перетащить блок",
"image": {
"placeholder": "Добавить картинку"
},
"imageUploader": {
"embedImage": "Встроенные картинки",
"embedLink": "Встраивать ссылку",
"embedSuccess": "Изображение успешно встраивается",
"invalidType": "Пожалуйста, выберите файл изображения",
"invalidUrl": "Неверная ссылка на изображение",
"processing": "Работа с картинками ...",
"title": "Добавить картинку",
"tooLarge": "Размер изображения не может превышать 10 МБ",
"upload": "Загрузить",
"uploadError": "Загрузка изображения не удалась",
"uploadFile": "Загрузить файл",
"uploadHint": "Поддерживает JPG, PNG, GIF и другие форматы, до 10 МБ",
"uploadSuccess": "Загрузка изображения успешно",
"uploadText": "Нажмите или перетащите изображение, чтобы загрузить здесь",
"uploading": "Загрузка изображений",
"urlPlaceholder": "Вставьте адрес ссылки изображения",
"urlRequired": "Пожалуйста, введите адрес ссылки изображения"
},
"link": {
"remove": "Удалить ссылку",
"text": "Название ссылки",
"textPlaceholder": "Пожалуйста, введите заголовок ссылки",
"url": "Адрес ссылки"
},
"math": {
"placeholder": "Введите латексную формулу"
},
"placeholder": "Введите '/', чтобы вызвать команду",
"plusButton": "Нажмите, чтобы добавить ниже",
"toolbar": {
"blockMath": "Математические формулы",
"blockquote": "Цитировать",
"bold": "Смелый",
"bulletList": "Неупомянутый список",
"clearMarks": "Четкий формат",
"code": "Встроенный код",
"codeBlock": "Кодовый блок",
"heading1": "Название 1 -го уровня",
"heading2": "Вторичное название",
"heading3": "Название 3 уровня",
"heading4": "Название 4 уровня",
"heading5": "Название 5 -го уровня",
"heading6": "CET-6 название",
"image": "картина",
"inlineMath": "Математические формулы в отрасли",
"italic": "Курсив",
"link": "Связь",
"orderedList": "Заказанный список",
"paragraph": "текст",
"redo": "Переработка",
"strike": "Удалить линию",
"table": "лист",
"taskList": "Список задач",
"underline": "Подчеркнуть",
"undo": "Отменить"
}
},
"selection": {
"action": {
"builtin": {
@ -2204,6 +2493,7 @@
"joplin": "Экспорт в Joplin",
"markdown": "Экспорт в Markdown",
"markdown_reason": "Экспорт в Markdown (с рассуждениями)",
"notes": "экспорт в заметки",
"notion": "Экспорт в Notion",
"obsidian": "Экспорт в Obsidian",
"plain_text": "Копировать как чистый текст",
@ -3356,7 +3646,7 @@
"label": "HTTP аутентификация",
"password": {
"label": "Пароль",
"tip": ""
"tip": "Введите свой пароль"
},
"tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).",
"user_name": {
@ -3735,6 +4025,7 @@
"launchpad": "Запуск",
"mcp-servers": "MCP серверы",
"memories": "Память",
"notes": "заметки",
"paintings": "Рисунки",
"settings": "Настройки",
"translate": "Перевод"

View File

@ -597,6 +597,7 @@
"label": "导出为 Markdown",
"reason": "导出为 Markdown (包含思考)"
},
"notes": "导出到笔记",
"notion": "导出到 Notion",
"obsidian": "导出到 Obsidian",
"obsidian_atributes": "配置笔记属性",
@ -1292,6 +1293,9 @@
"specified": "导出 Markdown 文件失败"
}
},
"notes": {
"export": "导出笔记失败"
},
"notion": {
"export": "导出 Notion 错误,请检查连接状态并对照文档检查配置",
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
@ -1385,6 +1389,9 @@
"specified": "成功导出 Markdown 文件"
}
},
"notes": {
"export": "成功导出到笔记"
},
"notion": {
"export": "成功导出到 Notion"
},
@ -1589,6 +1596,88 @@
"navigate": {
"provider_settings": "跳转到服务商设置界面"
},
"notes": {
"characters": "字符",
"collapse": "收起",
"content_placeholder": "请输入笔记内容...",
"copyContent": "复制内容",
"delete": "删除",
"delete_confirm": "确定要删除这个{{type}}吗?",
"delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?",
"delete_note_confirm": "确定要删除笔记 \"{{name}}\" 吗?",
"drop_markdown_hint": "拖拽 Markdown 文件到此处导入",
"empty": "暂无笔记",
"expand": "展开",
"export_failed": "导出到知识库失败",
"export_knowledge": "导出笔记到知识库",
"export_success": "成功导出到知识库",
"folder": "文件夹",
"new_folder": "新建文件夹",
"new_note": "新建笔记",
"no_content_to_copy": "没有内容可复制",
"only_markdown": "仅支持 Markdown 格式",
"open_folder": "打开外部文件夹",
"rename": "重命名",
"save": "保存到笔记",
"settings": {
"data": {
"apply": "应用",
"apply_path_failed": "应用路径失败",
"current_work_directory": "当前工作目录",
"invalid_directory": "选择的目录无效或无权限",
"path_required": "请选择工作目录",
"path_updated": "工作目录更新成功",
"reset_failed": "重置失败",
"reset_to_default": "重置为默认",
"select": "选择",
"select_directory_failed": "选择目录失败",
"title": "数据设置",
"work_directory_description": "工作目录是存储所有笔记文件的位置。更改工作目录不会移动现有文件,请手动迁移文件。",
"work_directory_placeholder": "选择笔记工作目录"
},
"display": {
"compress_content": "缩减栏宽",
"compress_content_description": "开启后将限制每行字数,使屏幕显示的内容减少",
"default_font": "默认字体",
"font_title": "字体设置",
"serif_font": "衬线字体",
"title": "显示设置"
},
"editor": {
"edit_mode": {
"description": "在编辑视图下,新笔记默认采用的编辑模式",
"preview_mode": "实时预览",
"source_mode": "源码模式",
"title": "默认编辑视图"
},
"title": "编辑器设置",
"view_mode": {
"description": "新笔记默认的视图模式",
"edit_mode": "编辑模式",
"read_mode": "阅读模式",
"title": "默认视图"
},
"view_mode_description": "设置新标签页的默认视图模式。"
},
"title": "笔记"
},
"show_starred": "显示收藏的笔记",
"sort_a2z": "文件名A-Z",
"sort_created_asc": "创建时间(从旧到新)",
"sort_created_desc": "创建时间(从新到旧)",
"sort_updated_asc": "更新时间(从旧到新)",
"sort_updated_desc": "更新时间(从新到旧)",
"sort_z2a": "文件名Z-A",
"star": "收藏",
"starred_notes": "收藏的笔记",
"title": "笔记",
"unsaved_changes": "你有未保存的内容,确定要离开吗?",
"unstar": "取消收藏",
"untitled_folder": "新文件夹",
"untitled_note": "无标题笔记",
"upload_failed": "笔记上传失败",
"upload_success": "笔记上传成功"
},
"notification": {
"assistant": "助手响应",
"knowledge": {
@ -1893,6 +1982,206 @@
},
"title": "数据恢复"
},
"richEditor": {
"action": {
"table": {
"deleteColumn": "删除列",
"deleteRow": "删除行",
"insertColumnAfter": "在右侧插入",
"insertColumnBefore": "在左侧插入",
"insertRowAfter": "在下方插入",
"insertRowBefore": "在上方插入"
}
},
"commands": {
"blockMath": {
"description": "插入数学公式",
"title": "数学公式"
},
"blockquote": {
"description": "插入引用文本",
"title": "引用"
},
"bold": {
"description": "标记为粗体",
"title": "粗体"
},
"bulletList": {
"description": "创建简单的项目符号列表",
"title": "无序列表"
},
"calloutInfo": {
"description": "添加信息提示框",
"title": "信息提示框"
},
"calloutWarning": {
"description": "添加警告提示框",
"title": "警告提示框"
},
"code": {
"description": "插入代码片段",
"title": "代码"
},
"codeBlock": {
"description": "插入代码片段",
"title": "代码块"
},
"columns": {
"description": "创建分栏布局",
"title": "分栏"
},
"date": {
"description": "插入当前日期",
"title": "日期"
},
"divider": {
"description": "添加水平分割线",
"title": "分割线"
},
"hardBreak": {
"description": "插入换行符",
"title": "换行符"
},
"heading1": {
"description": "大段落标题",
"title": "一级标题"
},
"heading2": {
"description": "中段落标题",
"title": "二级标题"
},
"heading3": {
"description": "小段落标题",
"title": "三级标题"
},
"heading4": {
"description": "较小的段落标题",
"title": "四级标题"
},
"heading5": {
"description": "更小的段落标题",
"title": "五级标题"
},
"heading6": {
"description": "最小的段落标题",
"title": "六级标题"
},
"image": {
"description": "插入图片",
"title": "图片"
},
"inlineCode": {
"description": "添加行内代码",
"title": "行内代码"
},
"inlineMath": {
"description": "插入行内数学公式",
"title": "行内数学公式"
},
"italic": {
"description": "标记为斜体",
"title": "斜体"
},
"link": {
"description": "添加链接",
"title": "链接"
},
"noCommandsFound": "未找到命令",
"orderedList": {
"description": "创建带编号的列表",
"title": "有序列表"
},
"paragraph": {
"description": "开始编写普通文本",
"title": "正文"
},
"redo": {
"description": "重做上一步操作",
"title": "重做"
},
"strike": {
"description": "标记为删除线",
"title": "删除线"
},
"table": {
"description": "插入表格",
"title": "表格"
},
"taskList": {
"description": "创建待办事项清单",
"title": "任务列表"
},
"underline": {
"description": "标记为下划线",
"title": "下划线"
},
"undo": {
"description": "撤销上一步操作",
"title": "撤销"
}
},
"dragHandle": "拖拽块",
"image": {
"placeholder": "添加图片"
},
"imageUploader": {
"embedImage": "嵌入图片",
"embedLink": "嵌入链接",
"embedSuccess": "图片嵌入成功",
"invalidType": "请选择图片文件",
"invalidUrl": "无效的图片链接",
"processing": "正在处理图片...",
"title": "添加图片",
"tooLarge": "图片大小不能超过 10MB",
"upload": "上传",
"uploadError": "图片上传失败",
"uploadFile": "上传文件",
"uploadHint": "支持 JPG、PNG、GIF 等格式,最大 10MB",
"uploadSuccess": "图片上传成功",
"uploadText": "点击或拖拽图片到此处上传",
"uploading": "正在上传图片",
"urlPlaceholder": "粘贴图片链接地址",
"urlRequired": "请输入图片链接地址"
},
"link": {
"remove": "移除链接",
"text": "链接标题",
"textPlaceholder": "请输入链接标题",
"url": "链接地址"
},
"math": {
"placeholder": "输入 LaTeX 公式"
},
"placeholder": "输入'/'调用命令",
"plusButton": "点击在下方添加",
"toolbar": {
"blockMath": "数学公式块",
"blockquote": "引用",
"bold": "粗体",
"bulletList": "无序列表",
"clearMarks": "清除格式",
"code": "行内代码",
"codeBlock": "代码块",
"heading1": "一级标题",
"heading2": "二级标题",
"heading3": "三级标题",
"heading4": "四级标题",
"heading5": "五级标题",
"heading6": "六级标题",
"image": "图片",
"inlineMath": "行内数学公式",
"italic": "斜体",
"link": "链接",
"orderedList": "有序列表",
"paragraph": "正文",
"redo": "重做",
"strike": "删除线",
"table": "表格",
"taskList": "任务清单",
"underline": "下划线",
"undo": "撤销"
}
},
"selection": {
"action": {
"builtin": {
@ -2204,6 +2493,7 @@
"joplin": "导出到 Joplin",
"markdown": "导出为 Markdown",
"markdown_reason": "导出为 Markdown包含思考",
"notes": "导出到笔记",
"notion": "导出到 Notion",
"obsidian": "导出到 Obsidian",
"plain_text": "复制为纯文本",
@ -3735,6 +4025,7 @@
"launchpad": "启动台",
"mcp-servers": "MCP 服务器",
"memories": "记忆",
"notes": "笔记",
"paintings": "绘画",
"settings": "设置",
"translate": "翻译"

View File

@ -597,6 +597,7 @@
"label": "匯出為 Markdown",
"reason": "匯出為 Markdown (包含思考)"
},
"notes": "導出到筆記",
"notion": "匯出到 Notion",
"obsidian": "匯出到 Obsidian",
"obsidian_atributes": "配置筆記屬性",
@ -1292,6 +1293,9 @@
"specified": "導出 Markdown 文件失敗"
}
},
"notes": {
"export": "導出筆記失敗"
},
"notion": {
"export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定",
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
@ -1385,6 +1389,9 @@
"specified": "成功導出 Markdown 文件"
}
},
"notes": {
"export": "成功導出到筆記"
},
"notion": {
"export": "成功匯出到 Notion"
},
@ -1589,6 +1596,88 @@
"navigate": {
"provider_settings": "跳轉到服務商設置界面"
},
"notes": {
"characters": "字符",
"collapse": "收起",
"content_placeholder": "請輸入筆記內容...",
"copyContent": "複製內容",
"delete": "删除",
"delete_confirm": "確定要刪除此 {{type}} 嗎?",
"delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?",
"delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?",
"drop_markdown_hint": "拖拽 Markdown 文件到此處導入",
"empty": "暫無筆記",
"expand": "展開",
"export_failed": "匯出至知識庫失敗",
"export_knowledge": "匯出筆記至知識庫",
"export_success": "成功匯出至知識庫",
"folder": "文件夹",
"new_folder": "新建文件夾",
"new_note": "新建筆記",
"no_content_to_copy": "沒有內容可複制",
"only_markdown": "僅支援 Markdown 格式",
"open_folder": "打開外部文件夾",
"rename": "重命名",
"save": "儲存到筆記",
"settings": {
"data": {
"apply": "應用",
"apply_path_failed": "應用路徑失敗",
"current_work_directory": "當前工作目錄",
"invalid_directory": "選擇的目錄無效或無權限",
"path_required": "請選擇工作目錄",
"path_updated": "工作目錄更新成功",
"reset_failed": "重置失敗",
"reset_to_default": "重置為默認",
"select": "選擇",
"select_directory_failed": "選擇目錄失敗",
"title": "數據設置",
"work_directory_description": "工作目錄是存儲所有筆記文件的位置。\n更改工作目錄不會移動現有文件請手動遷移文件。",
"work_directory_placeholder": "選擇筆記工作目錄"
},
"display": {
"compress_content": "縮減欄寬",
"compress_content_description": "開啟後將限制每行字數,使屏幕顯示的內容減少",
"default_font": "默認字體",
"font_title": "字體設置",
"serif_font": "襯線字體",
"title": "顯示"
},
"editor": {
"edit_mode": {
"description": "在編輯視圖下,新筆記默認採用的編輯模式",
"preview_mode": "實時預覽",
"source_mode": "源碼模式",
"title": "默認編輯視圖"
},
"title": "編輯器設置",
"view_mode": {
"description": "新筆記默認的視圖模式",
"edit_mode": "編輯模式",
"read_mode": "閱讀模式",
"title": "默認視圖"
},
"view_mode_description": "設置新標籤頁的默認視圖模式。"
},
"title": "更多選項"
},
"show_starred": "顯示收藏的筆記",
"sort_a2z": "文件名A-Z",
"sort_created_asc": "建立時間(從舊到新)",
"sort_created_desc": "建立時間(從新到舊)",
"sort_updated_asc": "更新時間(從舊到新)",
"sort_updated_desc": "更新時間(從新到舊)",
"sort_z2a": "文件名Z-A",
"star": "收藏",
"starred_notes": "收藏的筆記",
"title": "筆記",
"unsaved_changes": "你有未儲存的內容,確定要離開嗎?",
"unstar": "取消收藏",
"untitled_folder": "新資料夾",
"untitled_note": "無標題筆記",
"upload_failed": "筆記上傳失敗",
"upload_success": "筆記上傳成功"
},
"notification": {
"assistant": "助手回應",
"knowledge": {
@ -1893,6 +1982,206 @@
},
"title": "資料復原"
},
"richEditor": {
"action": {
"table": {
"deleteColumn": "刪除列",
"deleteRow": "刪除行",
"insertColumnAfter": "在右側插入",
"insertColumnBefore": "在左側插入",
"insertRowAfter": "在下方插入",
"insertRowBefore": "在上方插入"
}
},
"commands": {
"blockMath": {
"description": "插入數學公式",
"title": "數學公式"
},
"blockquote": {
"description": "插入引用文字",
"title": "引用"
},
"bold": {
"description": "標記為粗體",
"title": "粗體"
},
"bulletList": {
"description": "建立簡單的項目符號清單",
"title": "無序清單"
},
"calloutInfo": {
"description": "添加資訊提示框",
"title": "資訊提示框"
},
"calloutWarning": {
"description": "添加警告提示框",
"title": "警告提示框"
},
"code": {
"description": "插入代碼片段",
"title": "代碼"
},
"codeBlock": {
"description": "插入程式碼片段",
"title": "程式碼區塊"
},
"columns": {
"description": "建立分欄版面",
"title": "分欄"
},
"date": {
"description": "插入當前日期",
"title": "日期"
},
"divider": {
"description": "添加水平分隔線",
"title": "分隔線"
},
"hardBreak": {
"description": "插入換行符",
"title": "換行符"
},
"heading1": {
"description": "大段落標題",
"title": "一級標題"
},
"heading2": {
"description": "中段落標題",
"title": "二級標題"
},
"heading3": {
"description": "小段落標題",
"title": "三級標題"
},
"heading4": {
"description": "較小的段落標題",
"title": "四級標題"
},
"heading5": {
"description": "更小的段落標題",
"title": "五級標題"
},
"heading6": {
"description": "最小的段落標題",
"title": "六級標題"
},
"image": {
"description": "插入圖片",
"title": "圖片"
},
"inlineCode": {
"description": "添加行內程式碼",
"title": "行內程式碼"
},
"inlineMath": {
"description": "插入行內數學公式",
"title": "行內數學公式"
},
"italic": {
"description": "標記為斜體",
"title": "斜體"
},
"link": {
"description": "添加連結",
"title": "連結"
},
"noCommandsFound": "未找到命令",
"orderedList": {
"description": "建立帶編號的清單",
"title": "有序清單"
},
"paragraph": {
"description": "開始編寫普通文字",
"title": "內文"
},
"redo": {
"description": "重做上一步操作",
"title": "重做"
},
"strike": {
"description": "標記為刪除線",
"title": "刪除線"
},
"table": {
"description": "插入表格",
"title": "表格"
},
"taskList": {
"description": "建立待辦事項清單",
"title": "任務清單"
},
"underline": {
"description": "標記為下劃線",
"title": "下劃線"
},
"undo": {
"description": "撤銷上一步操作",
"title": "撤銷"
}
},
"dragHandle": "拖拽塊",
"image": {
"placeholder": "添加圖片"
},
"imageUploader": {
"embedImage": "嵌入圖片",
"embedLink": "嵌入連結",
"embedSuccess": "圖片嵌入成功",
"invalidType": "請選擇圖片檔案",
"invalidUrl": "無效的圖片連結",
"processing": "正在處理圖片...",
"title": "添加圖片",
"tooLarge": "圖片大小不能超過 10MB",
"upload": "上傳",
"uploadError": "圖片上傳失敗",
"uploadFile": "上傳檔案",
"uploadHint": "支援 JPG、PNG、GIF 等格式,最大 10MB",
"uploadSuccess": "圖片上傳成功",
"uploadText": "點擊或拖拽圖片到此處上傳",
"uploading": "正在上傳圖片",
"urlPlaceholder": "貼上圖片連結地址",
"urlRequired": "請輸入圖片連結地址"
},
"link": {
"remove": "移除鏈接",
"text": "鏈接標題",
"textPlaceholder": "請輸入鏈接標題",
"url": "鏈接地址"
},
"math": {
"placeholder": "輸入 LaTeX 公式"
},
"placeholder": "輸入'/'調用命令",
"plusButton": "點擊在下方添加",
"toolbar": {
"blockMath": "數學公式塊",
"blockquote": "引用",
"bold": "粗體",
"bulletList": "無序清單",
"clearMarks": "清除格式",
"code": "行內程式碼",
"codeBlock": "程式碼區塊",
"heading1": "一級標題",
"heading2": "二級標題",
"heading3": "三級標題",
"heading4": "四級標題",
"heading5": "五級標題",
"heading6": "六級標題",
"image": "圖片",
"inlineMath": "行內數學公式",
"italic": "斜體",
"link": "連結",
"orderedList": "有序清單",
"paragraph": "內文",
"redo": "重做",
"strike": "刪除線",
"table": "表格",
"taskList": "任務清單",
"underline": "底線",
"undo": "復原"
}
},
"selection": {
"action": {
"builtin": {
@ -2204,6 +2493,7 @@
"joplin": "匯出到 Joplin",
"markdown": "匯出為 Markdown",
"markdown_reason": "匯出為 Markdown包含思考",
"notes": "導出到筆記",
"notion": "匯出到 Notion",
"obsidian": "匯出到 Obsidian",
"plain_text": "複製為純文本",
@ -3356,7 +3646,7 @@
"label": "HTTP 認證",
"password": {
"label": "密碼",
"tip": ""
"tip": "輸入密碼"
},
"tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案RFC7617",
"user_name": {
@ -3735,6 +4025,7 @@
"launchpad": "啟動台",
"mcp-servers": "MCP 伺服器",
"memories": "記憶",
"notes": "筆記",
"paintings": "繪畫",
"settings": "設定",
"translate": "翻譯"

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