From dfb3322b280475a0bf92030672883cdf64b04d0a Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 30 Aug 2025 23:09:13 +0800 Subject: [PATCH] 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 a1b9c5a5b0979871301b9f3430b7e94806a54b7a. * 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
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 * 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 --- ...ion-drag-handle-npm-3.2.0-5a9ebff7c9.patch | 48 + electron.vite.config.ts | 3 +- package.json | 30 +- packages/extension-table-plus/CHANGELOG.md | 1457 +++++++++++++++++ packages/extension-table-plus/README.md | 18 + packages/extension-table-plus/package.json | 93 ++ .../extension-table-plus/src/cell/index.ts | 1 + .../src/cell/table-cell.ts | 150 ++ .../extension-table-plus/src/header/index.ts | 1 + .../src/header/table-header.ts | 60 + packages/extension-table-plus/src/index.ts | 6 + .../extension-table-plus/src/kit/index.ts | 64 + .../extension-table-plus/src/row/index.ts | 1 + .../extension-table-plus/src/row/table-row.ts | 38 + .../src/table/TableView.ts | 558 +++++++ .../extension-table-plus/src/table/index.ts | 3 + .../extension-table-plus/src/table/table.ts | 486 ++++++ .../src/table/utilities/colStyle.ts | 9 + .../src/table/utilities/createCell.ts | 12 + .../src/table/utilities/createColGroup.ts | 68 + .../src/table/utilities/createTable.ts | 40 + .../deleteTableWhenAllCellsSelected.ts | 38 + .../src/table/utilities/getBorderWidth.ts | 14 + .../src/table/utilities/getTableNodeTypes.ts | 21 + .../src/table/utilities/isCellSelection.ts | 5 + .../src/table/utilities/selectionBounds.ts | 68 + packages/extension-table-plus/src/types.ts | 19 + .../extension-table-plus/tsdown.config.ts | 20 + packages/shared/IpcChannel.ts | 14 + packages/shared/config/types.ts | 8 + src/main/ipc.ts | 27 +- src/main/services/FileStorage.ts | 595 ++++++- src/main/utils/file.ts | 224 ++- src/preload/index.ts | 43 +- src/renderer/src/App.tsx | 43 +- src/renderer/src/Router.tsx | 2 + .../src/assets/styles/CommandListPopover.scss | 59 + src/renderer/src/assets/styles/color.scss | 4 + src/renderer/src/assets/styles/index.scss | 1 + src/renderer/src/assets/styles/richtext.scss | 493 ++++++ .../src/components/CodeBlockView/view.tsx | 2 +- src/renderer/src/components/ContentSearch.tsx | 45 +- .../src/components/Popups/RichEditPopup.tsx | 159 ++ .../Popups/SaveToKnowledgePopup.tsx | 203 ++- .../RichEditor/CommandListPopover.tsx | 227 +++ .../components/RichEditor/TableOfContent.tsx | 158 ++ .../src/components/RichEditor/command.ts | 648 ++++++++ .../RichEditor/components/ActionMenu.tsx | 84 + .../RichEditor/components/ImageUploader.tsx | 206 +++ .../RichEditor/components/LinkEditor.tsx | 166 ++ .../RichEditor/components/MathInputDialog.tsx | 161 ++ .../RichEditor/components/PlusButton.tsx | 79 + .../RichEditor/components/TableActionMenu.tsx | 111 ++ .../dragContextMenu/DragContextMenu.tsx | 240 +++ .../dragContextMenu/actions/block.ts | 113 ++ .../dragContextMenu/actions/formatting.ts | 55 + .../dragContextMenu/actions/index.ts | 53 + .../dragContextMenu/actions/insert.ts | 81 + .../dragContextMenu/actions/transform.ts | 146 ++ .../hooks/useDragContextMenu.ts | 279 ++++ .../hooks/useMenuActionVisibility.ts | 110 ++ .../components/dragContextMenu/index.ts | 54 + .../components/dragContextMenu/styles.ts | 280 ++++ .../components/dragContextMenu/types.ts | 224 +++ .../placeholder/ImagePlaceholderNodeView.tsx | 47 + .../placeholder/MathPlaceholderNodeView.tsx | 74 + .../placeholder/PlaceholderBlock.tsx | 62 + .../code-block-shiki/CodeBlockNodeView.tsx | 87 + .../code-block-shiki/code-block-shiki.ts | 144 ++ .../extensions/code-block-shiki/index.ts | 5 + .../code-block-shiki/shikijsPlugin.ts | 248 +++ .../extensions/drag-context-menu.ts | 461 ++++++ .../RichEditor/extensions/enhanced-image.ts | 124 ++ .../RichEditor/extensions/enhanced-link.ts | 405 +++++ .../RichEditor/extensions/enhanced-math.ts | 123 ++ .../RichEditor/extensions/placeholder.ts | 83 + .../RichEditor/extensions/plus-button.ts | 94 ++ .../helpers/findNextElementFromCursor.ts | 54 + .../RichEditor/helpers/getOutNode.ts | 35 + .../RichEditor/helpers/imageUtils.ts | 129 ++ .../RichEditor/helpers/removeNode.ts | 4 + .../src/components/RichEditor/index.tsx | 451 +++++ .../RichEditor/plugins/plusButtonPlugin.ts | 259 +++ .../src/components/RichEditor/styles.ts | 376 +++++ .../src/components/RichEditor/toolbar.tsx | 338 ++++ .../src/components/RichEditor/types.ts | 301 ++++ .../components/RichEditor/useRichEditor.ts | 834 ++++++++++ .../src/components/Tab/TabContainer.tsx | 3 + src/renderer/src/components/app/Sidebar.tsx | 7 +- src/renderer/src/config/sidebar.ts | 3 +- src/renderer/src/databases/index.ts | 14 + src/renderer/src/hooks/useNotesQuery.ts | 85 + src/renderer/src/hooks/useNotesSettings.ts | 29 + src/renderer/src/hooks/useStore.ts | 15 +- src/renderer/src/i18n/label.ts | 4 +- src/renderer/src/i18n/locales/en-us.json | 293 +++- src/renderer/src/i18n/locales/ja-jp.json | 293 +++- src/renderer/src/i18n/locales/ru-ru.json | 293 +++- src/renderer/src/i18n/locales/zh-cn.json | 291 ++++ src/renderer/src/i18n/locales/zh-tw.json | 293 +++- src/renderer/src/i18n/translate/el-gr.json | 291 ++++ src/renderer/src/i18n/translate/es-es.json | 291 ++++ src/renderer/src/i18n/translate/fr-fr.json | 291 ++++ src/renderer/src/i18n/translate/pt-pt.json | 291 ++++ src/renderer/src/pages/files/FilesPage.tsx | 4 + .../pages/home/Messages/MessageMenubar.tsx | 28 +- .../src/pages/home/Tabs/TopicsTab.tsx | 20 +- .../pages/knowledge/items/KnowledgeNotes.tsx | 35 +- .../src/pages/launchpad/LaunchpadPage.tsx | 8 +- src/renderer/src/pages/notes/HeaderNavbar.tsx | 190 +++ src/renderer/src/pages/notes/MenuConfig.tsx | 60 + src/renderer/src/pages/notes/NotesEditor.tsx | 191 +++ src/renderer/src/pages/notes/NotesNavbar.tsx | 83 + src/renderer/src/pages/notes/NotesPage.tsx | 598 +++++++ src/renderer/src/pages/notes/NotesSidebar.tsx | 666 ++++++++ .../src/pages/notes/NotesSidebarHeader.tsx | 179 ++ .../AssistantPromptSettings.tsx | 107 +- .../DataSettings/ExportMenuSettings.tsx | 1 - .../DisplaySettings/SidebarIconsManager.tsx | 16 +- .../src/pages/settings/NotesSettings.tsx | 190 +++ .../src/pages/settings/SettingsPage.tsx | 9 + src/renderer/src/services/FileManager.ts | 3 +- src/renderer/src/services/NotesService.ts | 370 +++++ src/renderer/src/services/NotesTreeService.ts | 285 ++++ src/renderer/src/store/index.ts | 8 +- src/renderer/src/store/migrate.ts | 27 + src/renderer/src/store/note.ts | 59 + src/renderer/src/store/settings.ts | 21 +- src/renderer/src/types/index.ts | 4 + src/renderer/src/types/note.ts | 24 + .../utils/__tests__/markdownConverter.test.ts | 442 +++++ src/renderer/src/utils/asyncInitializer.ts | 8 +- src/renderer/src/utils/export.ts | 67 +- src/renderer/src/utils/file.ts | 16 +- src/renderer/src/utils/markdownConverter.ts | 700 ++++++++ src/renderer/src/utils/shiki.ts | 12 +- tsconfig.web.json | 6 +- yarn.lock | 1212 +++++++++++++- 138 files changed, 21661 insertions(+), 241 deletions(-) create mode 100644 .yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch create mode 100755 packages/extension-table-plus/CHANGELOG.md create mode 100755 packages/extension-table-plus/README.md create mode 100755 packages/extension-table-plus/package.json create mode 100755 packages/extension-table-plus/src/cell/index.ts create mode 100755 packages/extension-table-plus/src/cell/table-cell.ts create mode 100755 packages/extension-table-plus/src/header/index.ts create mode 100755 packages/extension-table-plus/src/header/table-header.ts create mode 100755 packages/extension-table-plus/src/index.ts create mode 100755 packages/extension-table-plus/src/kit/index.ts create mode 100755 packages/extension-table-plus/src/row/index.ts create mode 100755 packages/extension-table-plus/src/row/table-row.ts create mode 100755 packages/extension-table-plus/src/table/TableView.ts create mode 100755 packages/extension-table-plus/src/table/index.ts create mode 100755 packages/extension-table-plus/src/table/table.ts create mode 100755 packages/extension-table-plus/src/table/utilities/colStyle.ts create mode 100755 packages/extension-table-plus/src/table/utilities/createCell.ts create mode 100755 packages/extension-table-plus/src/table/utilities/createColGroup.ts create mode 100755 packages/extension-table-plus/src/table/utilities/createTable.ts create mode 100755 packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts create mode 100644 packages/extension-table-plus/src/table/utilities/getBorderWidth.ts create mode 100755 packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts create mode 100755 packages/extension-table-plus/src/table/utilities/isCellSelection.ts create mode 100644 packages/extension-table-plus/src/table/utilities/selectionBounds.ts create mode 100755 packages/extension-table-plus/src/types.ts create mode 100755 packages/extension-table-plus/tsdown.config.ts create mode 100644 src/renderer/src/assets/styles/CommandListPopover.scss create mode 100644 src/renderer/src/assets/styles/richtext.scss create mode 100644 src/renderer/src/components/Popups/RichEditPopup.tsx create mode 100644 src/renderer/src/components/RichEditor/CommandListPopover.tsx create mode 100644 src/renderer/src/components/RichEditor/TableOfContent.tsx create mode 100644 src/renderer/src/components/RichEditor/command.ts create mode 100644 src/renderer/src/components/RichEditor/components/ActionMenu.tsx create mode 100644 src/renderer/src/components/RichEditor/components/ImageUploader.tsx create mode 100644 src/renderer/src/components/RichEditor/components/LinkEditor.tsx create mode 100644 src/renderer/src/components/RichEditor/components/MathInputDialog.tsx create mode 100644 src/renderer/src/components/RichEditor/components/PlusButton.tsx create mode 100644 src/renderer/src/components/RichEditor/components/TableActionMenu.tsx create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts create mode 100644 src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts create mode 100644 src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx create mode 100644 src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx create mode 100644 src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx create mode 100644 src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx create mode 100644 src/renderer/src/components/RichEditor/extensions/code-block-shiki/code-block-shiki.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/code-block-shiki/index.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/code-block-shiki/shikijsPlugin.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/drag-context-menu.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/enhanced-image.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/enhanced-link.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/enhanced-math.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/placeholder.ts create mode 100644 src/renderer/src/components/RichEditor/extensions/plus-button.ts create mode 100644 src/renderer/src/components/RichEditor/helpers/findNextElementFromCursor.ts create mode 100644 src/renderer/src/components/RichEditor/helpers/getOutNode.ts create mode 100644 src/renderer/src/components/RichEditor/helpers/imageUtils.ts create mode 100644 src/renderer/src/components/RichEditor/helpers/removeNode.ts create mode 100644 src/renderer/src/components/RichEditor/index.tsx create mode 100644 src/renderer/src/components/RichEditor/plugins/plusButtonPlugin.ts create mode 100644 src/renderer/src/components/RichEditor/styles.ts create mode 100644 src/renderer/src/components/RichEditor/toolbar.tsx create mode 100644 src/renderer/src/components/RichEditor/types.ts create mode 100644 src/renderer/src/components/RichEditor/useRichEditor.ts create mode 100644 src/renderer/src/hooks/useNotesQuery.ts create mode 100644 src/renderer/src/hooks/useNotesSettings.ts create mode 100644 src/renderer/src/pages/notes/HeaderNavbar.tsx create mode 100644 src/renderer/src/pages/notes/MenuConfig.tsx create mode 100644 src/renderer/src/pages/notes/NotesEditor.tsx create mode 100644 src/renderer/src/pages/notes/NotesNavbar.tsx create mode 100644 src/renderer/src/pages/notes/NotesPage.tsx create mode 100644 src/renderer/src/pages/notes/NotesSidebar.tsx create mode 100644 src/renderer/src/pages/notes/NotesSidebarHeader.tsx create mode 100644 src/renderer/src/pages/settings/NotesSettings.tsx create mode 100644 src/renderer/src/services/NotesService.ts create mode 100644 src/renderer/src/services/NotesTreeService.ts create mode 100644 src/renderer/src/store/note.ts create mode 100644 src/renderer/src/types/note.ts create mode 100644 src/renderer/src/utils/__tests__/markdownConverter.test.ts create mode 100644 src/renderer/src/utils/markdownConverter.ts diff --git a/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch new file mode 100644 index 0000000000..575577acec --- /dev/null +++ b/.yarn/patches/@tiptap-extension-drag-handle-npm-3.2.0-5a9ebff7c9.patch @@ -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; diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f7cbd950f2..69a949f45c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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: { diff --git a/package.json b/package.json index 517f914748..ab22cab85c 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/extension-table-plus/CHANGELOG.md b/packages/extension-table-plus/CHANGELOG.md new file mode 100755 index 0000000000..6f24f5b060 --- /dev/null +++ b/packages/extension-table-plus/CHANGELOG.md @@ -0,0 +1,1457 @@ +# Change Log + +## 3.0.9 + +### Patch Changes + +- @tiptap/core@3.0.9 +- @tiptap/pm@3.0.9 + +## 3.0.8 + +### Patch Changes + +- @tiptap/core@3.0.8 +- @tiptap/pm@3.0.8 + +## 3.0.7 + +### Patch Changes + +- @tiptap/core@3.0.7 +- @tiptap/pm@3.0.7 + +## 3.0.6 + +### Patch Changes + +- Updated dependencies [2e71d05] + - @tiptap/core@3.0.6 + - @tiptap/pm@3.0.6 + +## 3.0.5 + +### Patch Changes + +- @tiptap/core@3.0.5 +- @tiptap/pm@3.0.5 + +## 3.0.4 + +### Patch Changes + +- Updated dependencies [7ed03fa] + - @tiptap/core@3.0.4 + - @tiptap/pm@3.0.4 + +## 3.0.3 + +### Patch Changes + +- Updated dependencies [75cabde] + - @tiptap/core@3.0.3 + - @tiptap/pm@3.0.3 + +## 3.0.2 + +### Patch Changes + +- @tiptap/core@3.0.2 +- @tiptap/pm@3.0.2 + +## 3.0.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory +- 991f43c: Added new export for TableView class +- 8c69002: Synced beta with stable features +- Updated dependencies [1b4c82b] +- Updated dependencies [1e91f9b] +- Updated dependencies [a92f4a6] +- Updated dependencies [8de8e13] +- Updated dependencies [20f68f6] +- Updated dependencies [5e957e5] +- Updated dependencies [89bd9c7] +- Updated dependencies [d0fda30] +- Updated dependencies [0e3207f] +- Updated dependencies [37913d5] +- Updated dependencies [28c5418] +- Updated dependencies [32958d6] +- Updated dependencies [12bb31a] +- Updated dependencies [9f207a6] +- Updated dependencies [412e1bd] +- Updated dependencies [062afaf] +- Updated dependencies [ff8eed6] +- Updated dependencies [704f462] +- Updated dependencies [95b8c71] +- Updated dependencies [8c69002] +- Updated dependencies [664834f] +- Updated dependencies [ac897e7] +- Updated dependencies [087d114] +- Updated dependencies [32958d6] +- Updated dependencies [fc17b21] +- Updated dependencies [62b0877] +- Updated dependencies [e20006b] +- Updated dependencies [5ba480b] +- Updated dependencies [d6c7558] +- Updated dependencies [062afaf] +- Updated dependencies [9ceeab4] +- Updated dependencies [32958d6] +- Updated dependencies [bf835b0] +- Updated dependencies [4e2f6d8] +- Updated dependencies [32958d6] + - @tiptap/core@3.0.1 + - @tiptap/pm@3.0.1 + +## 3.0.0-beta.30 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.30 +- @tiptap/pm@3.0.0-beta.30 + +## 3.0.0-beta.29 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.29 +- @tiptap/pm@3.0.0-beta.29 + +## 3.0.0-beta.28 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.28 +- @tiptap/pm@3.0.0-beta.28 + +## 3.0.0-beta.27 + +### Patch Changes + +- Updated dependencies [412e1bd] + - @tiptap/core@3.0.0-beta.27 + - @tiptap/pm@3.0.0-beta.27 + +## 3.0.0-beta.26 + +### Patch Changes + +- Updated dependencies [5ba480b] + - @tiptap/core@3.0.0-beta.26 + - @tiptap/pm@3.0.0-beta.26 + +## 3.0.0-beta.25 + +### Patch Changes + +- Updated dependencies [4e2f6d8] + - @tiptap/core@3.0.0-beta.25 + - @tiptap/pm@3.0.0-beta.25 + +## 3.0.0-beta.24 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.24 +- @tiptap/pm@3.0.0-beta.24 + +## 3.0.0-beta.23 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.23 +- @tiptap/pm@3.0.0-beta.23 + +## 3.0.0-beta.22 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.22 +- @tiptap/pm@3.0.0-beta.22 + +## 3.0.0-beta.21 + +### Patch Changes + +- Updated dependencies [813674c] +- Updated dependencies [fc17b21] + - @tiptap/core@3.0.0-beta.21 + - @tiptap/pm@3.0.0-beta.21 + +## 3.0.0-beta.20 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.20 +- @tiptap/pm@3.0.0-beta.20 + +## 3.0.0-beta.19 + +### Patch Changes + +- Updated dependencies [9ceeab4] + - @tiptap/core@3.0.0-beta.19 + - @tiptap/pm@3.0.0-beta.19 + +## 3.0.0-beta.18 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.18 +- @tiptap/pm@3.0.0-beta.18 + +## 3.0.0-beta.17 + +### Patch Changes + +- Updated dependencies [e20006b] + - @tiptap/core@3.0.0-beta.17 + - @tiptap/pm@3.0.0-beta.17 + +## 3.0.0-beta.16 + +### Patch Changes + +- Updated dependencies [ac897e7] +- Updated dependencies [bf835b0] + - @tiptap/core@3.0.0-beta.16 + - @tiptap/pm@3.0.0-beta.16 + +## 3.0.0-beta.15 + +### Patch Changes + +- Updated dependencies [087d114] + - @tiptap/core@3.0.0-beta.15 + - @tiptap/pm@3.0.0-beta.15 + +## 3.0.0-beta.14 + +### Patch Changes + +- Updated dependencies [95b8c71] + - @tiptap/core@3.0.0-beta.14 + - @tiptap/pm@3.0.0-beta.14 + +## 3.0.0-beta.13 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.13 +- @tiptap/pm@3.0.0-beta.13 + +## 3.0.0-beta.12 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.12 +- @tiptap/pm@3.0.0-beta.12 + +## 3.0.0-beta.11 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.11 +- @tiptap/pm@3.0.0-beta.11 + +## 3.0.0-beta.10 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.10 +- @tiptap/pm@3.0.0-beta.10 + +## 3.0.0-beta.9 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.9 +- @tiptap/pm@3.0.0-beta.9 + +## 3.0.0-beta.8 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.8 +- @tiptap/pm@3.0.0-beta.8 + +## 3.0.0-beta.7 + +### Patch Changes + +- Updated dependencies [d0fda30] + - @tiptap/core@3.0.0-beta.7 + - @tiptap/pm@3.0.0-beta.7 + +## 3.0.0-beta.6 + +### Patch Changes + +- @tiptap/core@3.0.0-beta.6 +- @tiptap/pm@3.0.0-beta.6 + +## 3.0.0-beta.5 + +### Patch Changes + +- 8c69002: Synced beta with stable features +- Updated dependencies [8c69002] +- Updated dependencies [62b0877] + - @tiptap/core@3.0.0-beta.5 + - @tiptap/pm@3.0.0-beta.5 + +## 3.0.0-beta.4 + +### Patch Changes + +- Updated dependencies [5e957e5] +- Updated dependencies [9f207a6] + - @tiptap/core@3.0.0-beta.4 + - @tiptap/pm@3.0.0-beta.4 + +## 3.0.0-beta.3 + +### Patch Changes + +- 1b4c82b: We are now using pnpm package aliases for versions to enable better version pinning for the monorepository +- Updated dependencies [1b4c82b] + - @tiptap/core@3.0.0-beta.3 + - @tiptap/pm@3.0.0-beta.3 + +## 3.0.0-beta.2 + +## 3.0.0-beta.1 + +### Patch Changes + +- 991f43c: Added new export for TableView class + +## 3.0.0-beta.0 + +## 3.0.0-next.8 + +## 3.0.0-next.7 + +### Patch Changes + +- 89bd9c7: Enforce type imports so that the bundler ignores TypeScript type imports when generating the index.js file of the dist directory + +## 3.0.0-next.6 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Minor Changes + +- 131c7d0: This adds all of the table packages to the `@tiptap/extension-table` package. + + ## TableKit + + The `TableKit` export allows configuring the entire table with one extension, and is the recommended way of using the table extensions. + + ```ts + import { TableKit } from '@tiptap/extension-table' + + new Editor({ + extensions: [ + TableKit.configure({ + table: { + HTMLAttributes: { + class: 'table', + }, + }, + tableCell: { + HTMLAttributes: { + class: 'table-cell', + }, + }, + tableHeader: { + HTMLAttributes: { + class: 'table-header', + }, + }, + tableRow: { + HTMLAttributes: { + class: 'table-row', + }, + }, + }), + ], + }) + ``` + + ## Table repackaging + + Since we've moved the code out of the table extensions to the `@tiptap/extension-table` package, you can remove the following packages from your project: + + ```bash + npm uninstall @tiptap/extension-table-header @tiptap/extension-table-cell @tiptap/extension-table-row + ``` + + And replace them with the new `@tiptap/extension-table` package: + + ```bash + npm install @tiptap/extension-table + ``` + + ## Want to use the extensions separately? + + For more control, you can also use the extensions separately. + + ### Table + + This extension adds a table to the editor. + + Migrate from default export to named export: + + ```diff + - import Table from '@tiptap/extension-table' + + import { Table } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { Table } from '@tiptap/extension-table' + ``` + + ### TableCell + + This extension adds a table cell to the editor. + + Migrate from `@tiptap/extension-table-cell` to `@tiptap/extension-table`: + + ```diff + - import TableCell from '@tiptap/extension-table-cell' + + import { TableCell } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableCell } from '@tiptap/extension-table' + ``` + + ### TableHeader + + This extension adds a table header to the editor. + + Migrate from `@tiptap/extension-table-header` to `@tiptap/extension-table`: + + ```diff + - import TableHeader from '@tiptap/extension-table-header' + + import { TableHeader } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableHeader } from '@tiptap/extension-table' + ``` + + ### TableRow + + This extension adds a table row to the editor. + + Migrate from `@tiptap/extension-table-row` to `@tiptap/extension-table`: + + ```diff + - import TableRow from '@tiptap/extension-table-row' + + import { TableRow } from '@tiptap/extension-table' + ``` + + Usage: + + ```ts + import { TableRow } from '@tiptap/extension-table' + ``` + +## 3.0.0-next.5 + +## 3.0.0-next.4 + +## 3.0.0-next.3 + +## 3.0.0-next.2 + +## 3.0.0-next.1 + +### Major Changes + +- a92f4a6: We are now building packages with tsup which does not support UMD builds, please repackage if you require UMD builds + +### Patch Changes + +- Updated dependencies [a92f4a6] +- Updated dependencies [da76972] + - @tiptap/core@3.0.0-next.1 + - @tiptap/pm@3.0.0-next.1 + +## 3.0.0-next.0 + +### Patch Changes + +- Updated dependencies [0ec0af6] + - @tiptap/core@3.0.0-next.0 + - @tiptap/pm@3.0.0-next.0 + +## 2.12.0 + +## 2.11.9 + +## 2.11.8 + +## 2.11.7 + +### Patch Changes + +- a44a311: Added new export for TableView class + +## 2.11.6 + +## 2.11.5 + +## 2.11.4 + +## 2.11.3 + +## 2.11.2 + +## 2.11.1 + +## 2.11.0 + +## 2.10.4 + +## 2.10.3 + +## 2.10.2 + +## 2.10.1 + +## 2.10.0 + +### Patch Changes + +- 7619215: enforce cellMinWidth even on column not resized by the user, fixes #5435 + +## 2.9.1 + +## 2.9.0 + +## 2.8.0 + +### Minor Changes + +- 131c7d0: This change repackages all of the table extensions to be within the `@tiptap/extension-table` package (other packages are just a re-export of the `@tiptap/extension-table` package). It also adds the `TableKit` export which will allow configuring the entire table with one extension. + +## 2.5.8 + +### Patch Changes + +- Updated dependencies [a08bf85] + - @tiptap/core@2.5.8 + - @tiptap/pm@2.5.8 + +## 2.5.7 + +### Patch Changes + +- Updated dependencies [b012471] +- Updated dependencies [cc3497e] + - @tiptap/core@2.5.7 + - @tiptap/pm@2.5.7 + +## 2.5.6 + +### Patch Changes + +- c7f5550: Set correct `min-width` for a table fixes #5217 +- Updated dependencies [b5c1b32] +- Updated dependencies [618bca9] +- Updated dependencies [35682d1] +- Updated dependencies [2104f0f] + - @tiptap/pm@2.5.6 + - @tiptap/core@2.5.6 + +## 2.5.5 + +### Patch Changes + +- Updated dependencies [4cca382] +- Updated dependencies [3b67e8a] + - @tiptap/core@2.5.5 + - @tiptap/pm@2.5.5 + +## 2.5.4 + +### Patch Changes + +- dd7f9ac: There was an issue with the cjs bundling of packages and default exports, now we resolve default exports in legacy compatible way +- Updated dependencies [dd7f9ac] + - @tiptap/core@2.5.4 + - @tiptap/pm@2.5.4 + +## 2.5.3 + +### Patch Changes + +- @tiptap/core@2.5.3 +- @tiptap/pm@2.5.3 + +## 2.5.2 + +### Patch Changes + +- Updated dependencies [07f4c03] + - @tiptap/core@2.5.2 + - @tiptap/pm@2.5.2 + +## 2.5.1 + +### Patch Changes + +- @tiptap/core@2.5.1 +- @tiptap/pm@2.5.1 + +## 2.5.0 + +### Patch Changes + +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] +- Updated dependencies [fb45149] + - @tiptap/core@2.5.0 + - @tiptap/pm@2.5.0 + +## 2.5.0-pre.16 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.16 +- @tiptap/pm@2.5.0-pre.16 + +## 2.5.0-pre.15 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.15 +- @tiptap/pm@2.5.0-pre.15 + +## 2.5.0-pre.14 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.14 +- @tiptap/pm@2.5.0-pre.14 + +## 2.5.0-pre.13 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.13 + - @tiptap/pm@2.5.0-pre.13 + +## 2.5.0-pre.12 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.12 + - @tiptap/pm@2.5.0-pre.12 + +## 2.5.0-pre.11 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.11 + - @tiptap/pm@2.5.0-pre.11 + +## 2.5.0-pre.10 + +### Patch Changes + +- Updated dependencies [74a37ff] + - @tiptap/core@2.5.0-pre.10 + - @tiptap/pm@2.5.0-pre.10 + +## 2.5.0-pre.9 + +### Patch Changes + +- Updated dependencies [14a00f4] + - @tiptap/core@2.5.0-pre.9 + - @tiptap/pm@2.5.0-pre.9 + +## 2.5.0-pre.8 + +### Patch Changes + +- Updated dependencies [509676e] + - @tiptap/core@2.5.0-pre.8 + - @tiptap/pm@2.5.0-pre.8 + +## 2.5.0-pre.7 + +### Patch Changes + +- @tiptap/core@2.5.0-pre.7 +- @tiptap/pm@2.5.0-pre.7 + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.4.0](https://github.com/ueberdosis/tiptap/compare/v2.3.2...v2.4.0) (2024-05-14) + +### Features + +- added jsdocs ([#4356](https://github.com/ueberdosis/tiptap/issues/4356)) ([b941eea](https://github.com/ueberdosis/tiptap/commit/b941eea6daba09d48a5d18ccc1b9a1d84b2249dd)) + +## [2.3.2](https://github.com/ueberdosis/tiptap/compare/v2.3.1...v2.3.2) (2024-05-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.3.1](https://github.com/ueberdosis/tiptap/compare/v2.3.0...v2.3.1) (2024-04-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.3.0](https://github.com/ueberdosis/tiptap/compare/v2.2.6...v2.3.0) (2024-04-09) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.6](https://github.com/ueberdosis/tiptap/compare/v2.2.5...v2.2.6) (2024-04-06) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.5](https://github.com/ueberdosis/tiptap/compare/v2.2.4...v2.2.5) (2024-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.4](https://github.com/ueberdosis/tiptap/compare/v2.2.3...v2.2.4) (2024-02-23) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.3](https://github.com/ueberdosis/tiptap/compare/v2.2.2...v2.2.3) (2024-02-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.2](https://github.com/ueberdosis/tiptap/compare/v2.2.1...v2.2.2) (2024-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.2.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0...v2.2.1) (2024-01-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.2.0](https://github.com/ueberdosis/tiptap/compare/v2.1.16...v2.2.0) (2024-01-29) + +### Bug Fixes + +- fix imports, fix demos, unpin y-prosemirror ([681aa57](https://github.com/ueberdosis/tiptap/commit/681aa577bff500015c3f925e300c55a71c73efaf)) + +# [2.2.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.2.0-rc.8) (2024-01-08) + +# [2.2.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.6...v2.2.0-rc.7) (2023-11-27) + +# [2.2.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.5...v2.2.0-rc.6) (2023-11-23) + +# [2.2.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.2.0-rc.4) (2023-10-10) + +# [2.2.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.2...v2.2.0-rc.3) (2023-08-18) + +# [2.2.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.2.0-rc.0...v2.2.0-rc.1) (2023-08-18) + +# [2.2.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.2.0-rc.0) (2023-08-18) + +## [2.1.16](https://github.com/ueberdosis/tiptap/compare/v2.1.15...v2.1.16) (2024-01-10) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.15](https://github.com/ueberdosis/tiptap/compare/v2.1.14...v2.1.15) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.14](https://github.com/ueberdosis/tiptap/compare/v2.1.13...v2.1.14) (2024-01-08) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.13](https://github.com/ueberdosis/tiptap/compare/v2.1.12...v2.1.13) (2023-11-30) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.12](https://github.com/ueberdosis/tiptap/compare/v2.1.11...v2.1.12) (2023-10-11) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.11](https://github.com/ueberdosis/tiptap/compare/v2.1.10...v2.1.11) (2023-09-20) + +### Reverts + +- Revert "v2.2.11" ([6aa755a](https://github.com/ueberdosis/tiptap/commit/6aa755a04b9955fc175c7ab33dee527d0d5deef0)) + +## [2.1.10](https://github.com/ueberdosis/tiptap/compare/v2.1.9...v2.1.10) (2023-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.9](https://github.com/ueberdosis/tiptap/compare/v2.1.8...v2.1.9) (2023-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.8](https://github.com/ueberdosis/tiptap/compare/v2.1.7...v2.1.8) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.7](https://github.com/ueberdosis/tiptap/compare/v2.1.6...v2.1.7) (2023-09-04) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.6](https://github.com/ueberdosis/tiptap/compare/v2.1.5...v2.1.6) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.5](https://github.com/ueberdosis/tiptap/compare/v2.1.4...v2.1.5) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.4](https://github.com/ueberdosis/tiptap/compare/v2.1.3...v2.1.4) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.3](https://github.com/ueberdosis/tiptap/compare/v2.1.2...v2.1.3) (2023-08-18) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.2](https://github.com/ueberdosis/tiptap/compare/v2.1.1...v2.1.2) (2023-08-17) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.1.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0...v2.1.1) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.14...v2.1.0) (2023-08-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.14](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.13...v2.1.0-rc.14) (2023-08-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.13](https://github.com/ueberdosis/tiptap/compare/v2.0.4...v2.1.0-rc.13) (2023-08-11) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.12](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.11...v2.1.0-rc.12) (2023-07-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.11](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.10...v2.1.0-rc.11) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.10](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.9...v2.1.0-rc.10) (2023-07-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.9](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.8...v2.1.0-rc.9) (2023-06-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.8](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.7...v2.1.0-rc.8) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.7](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.6...v2.1.0-rc.7) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.6](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.5...v2.1.0-rc.6) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.5](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.4...v2.1.0-rc.5) (2023-05-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.4](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.3...v2.1.0-rc.4) (2023-04-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.3](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.2...v2.1.0-rc.3) (2023-04-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.2](https://github.com/ueberdosis/tiptap/compare/v2.0.3...v2.1.0-rc.2) (2023-04-26) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0c1bba3](https://github.com/ueberdosis/tiptap/commit/0c1bba3137b535776bcef95ff3c55e13f5a2db46)) + +# [2.1.0-rc.1](https://github.com/ueberdosis/tiptap/compare/v2.1.0-rc.0...v2.1.0-rc.1) (2023-04-12) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.1.0-rc.0](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.1.0-rc.0) (2023-04-05) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.3](https://github.com/ueberdosis/tiptap/compare/v2.0.2...v2.0.3) (2023-04-13) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.2](https://github.com/ueberdosis/tiptap/compare/v2.0.1...v2.0.2) (2023-04-03) + +**Note:** Version bump only for package @tiptap/extension-table + +## [2.0.1](https://github.com/ueberdosis/tiptap/compare/v2.0.0...v2.0.1) (2023-03-30) + +### Bug Fixes + +- Update peerDependencies to fix lerna version tasks ([#3914](https://github.com/ueberdosis/tiptap/issues/3914)) ([0534f76](https://github.com/ueberdosis/tiptap/commit/0534f76401bf5399c01ca7f39d87f7221d91b4f7)) + +# [2.0.0-beta.220](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.219...v2.0.0-beta.220) (2023-02-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.219](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.218...v2.0.0-beta.219) (2023-02-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.218](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.217...v2.0.0-beta.218) (2023-02-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.217](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.216...v2.0.0-beta.217) (2023-02-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.216](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.215...v2.0.0-beta.216) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.215](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.214...v2.0.0-beta.215) (2023-02-08) + +### Bug Fixes + +- fix builds including prosemirror ([a380ec4](https://github.com/ueberdosis/tiptap/commit/a380ec41d198ebacc80cea9e79b0a8aa3092618a)) + +# [2.0.0-beta.214](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.213...v2.0.0-beta.214) (2023-02-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.213](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.212...v2.0.0-beta.213) (2023-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.212](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.211...v2.0.0-beta.212) (2023-02-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.211](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.210...v2.0.0-beta.211) (2023-02-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.210](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.209...v2.0.0-beta.210) (2023-02-02) + +### Features + +- **pm:** new prosemirror package for dependency resolving ([f387ad3](https://github.com/ueberdosis/tiptap/commit/f387ad3dd4c2b30eaea33fb0ba0b42e0cd39263b)) + +# [2.0.0-beta.209](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.208...v2.0.0-beta.209) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.208](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.207...v2.0.0-beta.208) (2022-12-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.207](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.206...v2.0.0-beta.207) (2022-12-08) + +### Bug Fixes + +- **extension-table:** add prosemirror-tables to peerDependencies ([c187e0e](https://github.com/ueberdosis/tiptap/commit/c187e0e2586f1d0069e93ab41a144ae14d5172e0)) + +# [2.0.0-beta.206](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.205...v2.0.0-beta.206) (2022-12-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.205](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.204...v2.0.0-beta.205) (2022-12-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.204](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.203...v2.0.0-beta.204) (2022-11-25) + +### Bug Fixes + +- **core:** rename esm modules to esm.js ([c1a0c3a](https://github.com/ueberdosis/tiptap/commit/c1a0c3ae43baac9dd5ed90903d3a0d4eaeea7702)) + +# [2.0.0-beta.203](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.202...v2.0.0-beta.203) (2022-11-24) + +### Bug Fixes + +- **extension/table:** move dependency from @\_ueberdosis to [@tiptap](https://github.com/tiptap) ([#3448](https://github.com/ueberdosis/tiptap/issues/3448)) ([31c3a9a](https://github.com/ueberdosis/tiptap/commit/31c3a9aad9eb37f445eadcd27135611291178ca6)) + +# [2.0.0-beta.202](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.201...v2.0.0-beta.202) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.201](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.200...v2.0.0-beta.201) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.200](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.199...v2.0.0-beta.200) (2022-11-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.199](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.198...v2.0.0-beta.199) (2022-09-30) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.198](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.197...v2.0.0-beta.198) (2022-09-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.197](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.196...v2.0.0-beta.197) (2022-09-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.196](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.195...v2.0.0-beta.196) (2022-09-20) + +### Bug Fixes + +- **types:** fix link and table type errors ([#3208](https://github.com/ueberdosis/tiptap/issues/3208)) ([ae13cf6](https://github.com/ueberdosis/tiptap/commit/ae13cf61ad0ead942515d8c597f96a4b4d026412)) + +# [2.0.0-beta.195](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.194...v2.0.0-beta.195) (2022-09-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.194](https://github.com/ueberdosis/tiptap/compare/v2.0.0-beta.193...v2.0.0-beta.194) (2022-09-11) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.54](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.53...@tiptap/extension-table@2.0.0-beta.54) (2022-06-27) + +### Bug Fixes + +- **maintainment:** fix cjs issues with prosemirror-tables ([eb92597](https://github.com/ueberdosis/tiptap/commit/eb925976038fbf59f6ba333ccc57ea84113da00e)) + +# [2.0.0-beta.53](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.52...@tiptap/extension-table@2.0.0-beta.53) (2022-06-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.52](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.52) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.50](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.50...@tiptap/extension-table@2.0.0-beta.50) (2022-06-17) + +### Reverts + +- Revert "Publish" ([9c38d27](https://github.com/ueberdosis/tiptap/commit/9c38d2713e6feac5645ad9c1bfc57abdbf054576)) + +# [2.0.0-beta.49](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.48...@tiptap/extension-table@2.0.0-beta.49) (2022-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.48](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.47...@tiptap/extension-table@2.0.0-beta.48) (2022-01-25) + +### Bug Fixes + +- use toggleHeader from prosemirror-tables ([#2412](https://github.com/ueberdosis/tiptap/issues/2412)), fix [#548](https://github.com/ueberdosis/tiptap/issues/548) ([c6bea9a](https://github.com/ueberdosis/tiptap/commit/c6bea9aa5c4d38523f2f1095a570cdfc6936392e)) + +# [2.0.0-beta.47](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.46...@tiptap/extension-table@2.0.0-beta.47) (2022-01-25) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.46](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.45...@tiptap/extension-table@2.0.0-beta.46) (2022-01-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.45](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.44...@tiptap/extension-table@2.0.0-beta.45) (2021-12-03) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.44](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.43...@tiptap/extension-table@2.0.0-beta.44) (2021-12-02) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.43](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.42...@tiptap/extension-table@2.0.0-beta.43) (2021-11-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.42](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.41...@tiptap/extension-table@2.0.0-beta.42) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.41](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.40...@tiptap/extension-table@2.0.0-beta.41) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.40](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.39...@tiptap/extension-table@2.0.0-beta.40) (2021-11-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.39](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.38...@tiptap/extension-table@2.0.0-beta.39) (2021-11-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.38](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.37...@tiptap/extension-table@2.0.0-beta.38) (2021-11-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.37](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.36...@tiptap/extension-table@2.0.0-beta.37) (2021-10-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.36](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.35...@tiptap/extension-table@2.0.0-beta.36) (2021-10-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.35](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.34...@tiptap/extension-table@2.0.0-beta.35) (2021-10-22) + +### Features + +- Add extension storage ([#2069](https://github.com/ueberdosis/tiptap/issues/2069)) ([7ffabf2](https://github.com/ueberdosis/tiptap/commit/7ffabf251c408a652eec1931cc78a8bd43cccb67)) + +# [2.0.0-beta.34](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.33...@tiptap/extension-table@2.0.0-beta.34) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.33](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.32...@tiptap/extension-table@2.0.0-beta.33) (2021-10-14) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.32](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.31...@tiptap/extension-table@2.0.0-beta.32) (2021-10-08) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.31](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.30...@tiptap/extension-table@2.0.0-beta.31) (2021-09-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.30](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.29...@tiptap/extension-table@2.0.0-beta.30) (2021-09-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.29](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.28...@tiptap/extension-table@2.0.0-beta.29) (2021-08-20) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.28](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.27...@tiptap/extension-table@2.0.0-beta.28) (2021-08-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.27](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.26...@tiptap/extension-table@2.0.0-beta.27) (2021-08-09) + +### Bug Fixes + +- don’t resize tables if editable is set to false, fix [#1549](https://github.com/ueberdosis/tiptap/issues/1549) ([239a2e3](https://github.com/ueberdosis/tiptap/commit/239a2e36a47e4d0ad3012a54cda2d8b5c4f7a3ca)) + +# [2.0.0-beta.26](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.25...@tiptap/extension-table@2.0.0-beta.26) (2021-07-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.25](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.24...@tiptap/extension-table@2.0.0-beta.25) (2021-07-09) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.24](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.23...@tiptap/extension-table@2.0.0-beta.24) (2021-06-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.23](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.22...@tiptap/extension-table@2.0.0-beta.23) (2021-06-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.22](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.21...@tiptap/extension-table@2.0.0-beta.22) (2021-05-27) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.21](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.20...@tiptap/extension-table@2.0.0-beta.21) (2021-05-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.20](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.19...@tiptap/extension-table@2.0.0-beta.20) (2021-05-17) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.19](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.18...@tiptap/extension-table@2.0.0-beta.19) (2021-05-13) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.18](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.17...@tiptap/extension-table@2.0.0-beta.18) (2021-05-07) + +### Bug Fixes + +- revert adding exports ([bc320d0](https://github.com/ueberdosis/tiptap/commit/bc320d0b4b80b0e37a7e47a56e0f6daec6e65d98)) + +# [2.0.0-beta.17](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.16...@tiptap/extension-table@2.0.0-beta.17) (2021-05-06) + +### Bug Fixes + +- revert adding type: module ([f8d6475](https://github.com/ueberdosis/tiptap/commit/f8d6475e2151faea6f96baecdd6bd75880d50d2c)) + +# [2.0.0-beta.16](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.15...@tiptap/extension-table@2.0.0-beta.16) (2021-05-06) + +### Bug Fixes + +- add exports to package.json ([1277fa4](https://github.com/ueberdosis/tiptap/commit/1277fa47151e9c039508cdb219bdd0ffe647f4ee)) + +# [2.0.0-beta.15](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.14...@tiptap/extension-table@2.0.0-beta.15) (2021-05-06) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.14](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.13...@tiptap/extension-table@2.0.0-beta.14) (2021-05-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.13](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.12...@tiptap/extension-table@2.0.0-beta.13) (2021-05-04) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.11...@tiptap/extension-table@2.0.0-beta.12) (2021-04-27) + +### Features + +- add setCellSelection command ([eb7e92f](https://github.com/ueberdosis/tiptap/commit/eb7e92f10aff60e68cae613750903eb0adce5933)) + +# [2.0.0-beta.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.10...@tiptap/extension-table@2.0.0-beta.11) (2021-04-23) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.9...@tiptap/extension-table@2.0.0-beta.10) (2021-04-22) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.8...@tiptap/extension-table@2.0.0-beta.9) (2021-04-21) + +### Bug Fixes + +- add name to context ([a43d4c7](https://github.com/ueberdosis/tiptap/commit/a43d4c7bcb5ba5e386f268a2a71a7449bc2f658e)) + +# [2.0.0-beta.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.7...@tiptap/extension-table@2.0.0-beta.8) (2021-04-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.6...@tiptap/extension-table@2.0.0-beta.7) (2021-04-15) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.6](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.5...@tiptap/extension-table@2.0.0-beta.6) (2021-04-12) + +### Features + +- add parentConfig to extension context for more extendable extensions, fix [#259](https://github.com/ueberdosis/tiptap/issues/259) ([5e1ec5d](https://github.com/ueberdosis/tiptap/commit/5e1ec5d2a66be164f505d631f97861ab9344ba96)) + +# [2.0.0-beta.5](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.4...@tiptap/extension-table@2.0.0-beta.5) (2021-03-31) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.4](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.3...@tiptap/extension-table@2.0.0-beta.4) (2021-03-28) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.3](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.2...@tiptap/extension-table@2.0.0-beta.3) (2021-03-24) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.2](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-beta.1...@tiptap/extension-table@2.0.0-beta.2) (2021-03-18) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-beta.1](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.12...@tiptap/extension-table@2.0.0-beta.1) (2021-03-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.12](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.11...@tiptap/extension-table@2.0.0-alpha.12) (2021-02-26) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.11](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.10...@tiptap/extension-table@2.0.0-alpha.11) (2021-02-16) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.10](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.9...@tiptap/extension-table@2.0.0-alpha.10) (2021-02-07) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.9](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.8...@tiptap/extension-table@2.0.0-alpha.9) (2021-02-05) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.8](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.7...@tiptap/extension-table@2.0.0-alpha.8) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# [2.0.0-alpha.7](https://github.com/ueberdosis/tiptap/compare/@tiptap/extension-table@2.0.0-alpha.6...@tiptap/extension-table@2.0.0-alpha.7) (2021-01-29) + +**Note:** Version bump only for package @tiptap/extension-table + +# 2.0.0-alpha.6 (2021-01-28) + +**Note:** Version bump only for package @tiptap/extension-table diff --git a/packages/extension-table-plus/README.md b/packages/extension-table-plus/README.md new file mode 100755 index 0000000000..09164acab0 --- /dev/null +++ b/packages/extension-table-plus/README.md @@ -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). diff --git a/packages/extension-table-plus/package.json b/packages/extension-table-plus/package.json new file mode 100755 index 0000000000..d34c25ccd7 --- /dev/null +++ b/packages/extension-table-plus/package.json @@ -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" +} diff --git a/packages/extension-table-plus/src/cell/index.ts b/packages/extension-table-plus/src/cell/index.ts new file mode 100755 index 0000000000..cabf450700 --- /dev/null +++ b/packages/extension-table-plus/src/cell/index.ts @@ -0,0 +1 @@ +export * from './table-cell.js' diff --git a/packages/extension-table-plus/src/cell/table-cell.ts b/packages/extension-table-plus/src/cell/table-cell.ts new file mode 100755 index 0000000000..fa549d7f9f --- /dev/null +++ b/packages/extension-table-plus/src/cell/table-cell.ts @@ -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 + /** + * 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({ + 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) + } + }) + ] + } +}) diff --git a/packages/extension-table-plus/src/header/index.ts b/packages/extension-table-plus/src/header/index.ts new file mode 100755 index 0000000000..0bd179194c --- /dev/null +++ b/packages/extension-table-plus/src/header/index.ts @@ -0,0 +1 @@ +export * from './table-header.js' diff --git a/packages/extension-table-plus/src/header/table-header.ts b/packages/extension-table-plus/src/header/table-header.ts new file mode 100755 index 0000000000..50c30ac4a6 --- /dev/null +++ b/packages/extension-table-plus/src/header/table-header.ts @@ -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 +} + +/** + * This extension allows you to create table headers. + * @see https://www.tiptap.dev/api/nodes/table-header + */ +export const TableHeader = Node.create({ + 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] + } +}) diff --git a/packages/extension-table-plus/src/index.ts b/packages/extension-table-plus/src/index.ts new file mode 100755 index 0000000000..c16b6c4e46 --- /dev/null +++ b/packages/extension-table-plus/src/index.ts @@ -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' diff --git a/packages/extension-table-plus/src/kit/index.ts b/packages/extension-table-plus/src/kit/index.ts new file mode 100755 index 0000000000..00221c5bfe --- /dev/null +++ b/packages/extension-table-plus/src/kit/index.ts @@ -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 | false + /** + * If set to false, the table extension will not be registered + * @example tableCell: false + */ + tableCell: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableHeader: false + */ + tableHeader: Partial | false + /** + * If set to false, the table extension will not be registered + * @example tableRow: false + */ + tableRow: Partial | false +} + +/** + * The table kit is a collection of table editor extensions. + * + * It’s a good starting point for building your own table in Tiptap. + */ +export const TableKit = Extension.create({ + 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 + } +}) diff --git a/packages/extension-table-plus/src/row/index.ts b/packages/extension-table-plus/src/row/index.ts new file mode 100755 index 0000000000..8a3564c008 --- /dev/null +++ b/packages/extension-table-plus/src/row/index.ts @@ -0,0 +1 @@ +export * from './table-row.js' diff --git a/packages/extension-table-plus/src/row/table-row.ts b/packages/extension-table-plus/src/row/table-row.ts new file mode 100755 index 0000000000..382954397f --- /dev/null +++ b/packages/extension-table-plus/src/row/table-row.ts @@ -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 +} + +/** + * This extension allows you to create table rows. + * @see https://www.tiptap.dev/api/nodes/table-row + */ +export const TableRow = Node.create({ + name: 'tableRow', + + addOptions() { + return { + HTMLAttributes: {} + } + }, + + content: '(tableCell | tableHeader)*', + + tableRole: 'row', + + parseHTML() { + return [{ tag: 'tr' }] + }, + + renderHTML({ HTMLAttributes }) { + return ['tr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + } +}) diff --git a/packages/extension-table-plus/src/table/TableView.ts b/packages/extension-table-plus/src/table/TableView.ts new file mode 100755 index 0000000000..1a06255364 --- /dev/null +++ b/packages/extension-table-plus/src/table/TableView.ts @@ -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, // has the same prototype as + 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) + } +} diff --git a/packages/extension-table-plus/src/table/index.ts b/packages/extension-table-plus/src/table/index.ts new file mode 100755 index 0000000000..040a250704 --- /dev/null +++ b/packages/extension-table-plus/src/table/index.ts @@ -0,0 +1,3 @@ +export * from './table.js' +export * from './utilities/createColGroup.js' +export * from './utilities/createTable.js' diff --git a/packages/extension-table-plus/src/table/table.ts b/packages/extension-table-plus/src/table/table.ts new file mode 100755 index 0000000000..d0cdf8304b --- /dev/null +++ b/packages/extension-table-plus/src/table/table.ts @@ -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 + + /** + * 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 { + 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({ + 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)) + } + } +}) diff --git a/packages/extension-table-plus/src/table/utilities/colStyle.ts b/packages/extension-table-plus/src/table/utilities/colStyle.ts new file mode 100755 index 0000000000..d54a259fda --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/colStyle.ts @@ -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`] +} diff --git a/packages/extension-table-plus/src/table/utilities/createCell.ts b/packages/extension-table-plus/src/table/utilities/createCell.ts new file mode 100755 index 0000000000..2d95471c5c --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createCell.ts @@ -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 | null | undefined { + if (cellContent) { + return cellType.createChecked(null, cellContent) + } + + return cellType.createAndFill() +} diff --git a/packages/extension-table-plus/src/table/utilities/createColGroup.ts b/packages/extension-table-plus/src/table/utilities/createColGroup.ts new file mode 100755 index 0000000000..4a12d10cd8 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createColGroup.ts @@ -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 + +/** + * 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 } +} diff --git a/packages/extension-table-plus/src/table/utilities/createTable.ts b/packages/extension-table-plus/src/table/utilities/createTable.ts new file mode 100755 index 0000000000..ae6d78c412 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/createTable.ts @@ -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 { + 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) +} diff --git a/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts new file mode 100755 index 0000000000..43eceefe07 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/deleteTableWhenAllCellsSelected.ts @@ -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 +} diff --git a/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts new file mode 100644 index 0000000000..29cb80f6f9 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getBorderWidth.ts @@ -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) + } +} diff --git a/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts new file mode 100755 index 0000000000..2365f4a3ad --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/getTableNodeTypes.ts @@ -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 +} diff --git a/packages/extension-table-plus/src/table/utilities/isCellSelection.ts b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts new file mode 100755 index 0000000000..59d8919f02 --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/isCellSelection.ts @@ -0,0 +1,5 @@ +import { CellSelection } from '@tiptap/pm/tables' + +export function isCellSelection(value: unknown): value is CellSelection { + return value instanceof CellSelection +} diff --git a/packages/extension-table-plus/src/table/utilities/selectionBounds.ts b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts new file mode 100644 index 0000000000..186bfa884e --- /dev/null +++ b/packages/extension-table-plus/src/table/utilities/selectionBounds.ts @@ -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 + 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 } +} diff --git a/packages/extension-table-plus/src/types.ts b/packages/extension-table-plus/src/types.ts new file mode 100755 index 0000000000..eef697b269 --- /dev/null +++ b/packages/extension-table-plus/src/types.ts @@ -0,0 +1,19 @@ +import type { ParentConfig } from '@tiptap/core' + +declare module '@tiptap/core' { + interface NodeConfig { + /** + * 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>['tableRole'] + }) => string) + } +} diff --git a/packages/extension-table-plus/tsdown.config.ts b/packages/extension-table-plus/tsdown.config.ts new file mode 100755 index 0000000000..8e7b52e10c --- /dev/null +++ b/packages/extension-table-plus/tsdown.config.ts @@ -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: [/^[^./]/] + })) +) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 19df95332e..26e6a2764a 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 28bb4acf65..d46717b47e 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -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 +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 33d45531e0..365b6173cd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 39a16713d7..985f6dfef9 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -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 = { + 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 = 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { 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 => { + 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 => { + 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 { + 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 => { await fs.promises.rm(this.storageDir, { recursive: true }) - await this.initStorageDir() + this.initStorageDir() } public clearTemp = async (): Promise => { @@ -432,6 +730,7 @@ class FileStorage { /** * 通过相对路径打开文件,跨设备时使用 + * @param _ * @param file */ public openFileWithRelativePath = async (_: Electron.IpcMainInvokeEvent, file: FileMetadata): Promise => { @@ -443,6 +742,79 @@ class FileStorage { } } + public getDirectoryStructure = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { + 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 => { + 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 => { + 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 => { + 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) } diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts index 150a28eaca..e683f4faea 100644 --- a/src/main/utils/file.ts +++ b/src/main/utils/file.ts @@ -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 { + 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 +} diff --git a/src/preload/index.ts b/src/preload/index.ts index fb9e37c89e..0f3c358962 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke(IpcChannel.File_Get, filePath), - /** - * 创建一个空的临时文件 - * @param fileName 文件名 - * @returns 临时文件路径 - */ createTempFile: (fileName: string): Promise => 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 => ipcRenderer.invoke(IpcChannel.File_IsTextFile, filePath) + isTextFile: (filePath: string): Promise => 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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ad18a9b193..703015e30e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } diff --git a/src/renderer/src/Router.tsx b/src/renderer/src/Router.tsx index 36d045aae5..8985a1a41d 100644 --- a/src/renderer/src/Router.tsx +++ b/src/renderer/src/Router.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/assets/styles/CommandListPopover.scss b/src/renderer/src/assets/styles/CommandListPopover.scss new file mode 100644 index 0000000000..e2521c57b3 --- /dev/null +++ b/src/renderer/src/assets/styles/CommandListPopover.scss @@ -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; + } +} diff --git a/src/renderer/src/assets/styles/color.scss b/src/renderer/src/assets/styles/color.scss index 1cb7d030b0..517160e76c 100644 --- a/src/renderer/src/assets/styles/color.scss +++ b/src/renderer/src/assets/styles/color.scss @@ -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; diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 0a6696bd9b..974457dc4d 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -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'; diff --git a/src/renderer/src/assets/styles/richtext.scss b/src/renderer/src/assets/styles/richtext.scss new file mode 100644 index 0000000000..91ebf0940d --- /dev/null +++ b/src/renderer/src/assets/styles/richtext.scss @@ -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; + } +} diff --git a/src/renderer/src/components/CodeBlockView/view.tsx b/src/renderer/src/components/CodeBlockView/view.tsx index 4bb70e1b75..174327ae68 100644 --- a/src/renderer/src/components/CodeBlockView/view.tsx +++ b/src/renderer/src/components/CodeBlockView/view.tsx @@ -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); diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx index 637af96de4..084a79a439 100644 --- a/src/renderer/src/components/ContentSearch.tsx +++ b/src/renderer/src/components/ContentSearch.tsx @@ -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( - ({ 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( } return ( - + - + ( style={{ lineHeight: '20px' }} /> - - - - - + {showUserToggle && ( + + + + + + )} ( 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; diff --git a/src/renderer/src/components/Popups/RichEditPopup.tsx b/src/renderer/src/components/Popups/RichEditPopup.tsx new file mode 100644 index 0000000000..1c7b32e188 --- /dev/null +++ b/src/renderer/src/components/Popups/RichEditPopup.tsx @@ -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 = ({ + content, + modalProps, + resolve, + children, + disableCommands = ['image', 'inlineMath'] // 默认禁用 image 命令 +}) => { + const [open, setOpen] = useState(true) + const { t } = useTranslation() + const [richContent, setRichContent] = useState(content) + const editorRef = useRef(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) => { + // 禁用指定的命令 + if (disableCommands?.length) { + disableCommands.forEach((commandId) => { + commandAPI.unregisterCommand(commandId) + }) + } + } + + RichEditPopup.hide = onCancel + + return ( + + + + + {children && children({ onOk, onCancel })} + + ) +} + +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((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx index cfc190399d..f8789735e9 100644 --- a/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx +++ b/src/renderer/src/components/Popups/SaveToKnowledgePopup.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ source, title, resolve }) => { /> - - - {contentTypeOptions.map((option) => ( - handleContentTypeToggle(option.type)}> - - - {option.count} - - {option.label} - - - - - {selectedTypes.includes(option.type) && } - - ))} - - + {!isNoteMode && ( + + + {contentTypeOptions.map((option) => ( + handleContentTypeToggle(option.type)}> + + + {option.count} + + {option.label} + + + + + {selectedTypes.includes(option.type) && } + + ))} + + + )} - - {formState.selectedCount > 0 && ( - - {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 }) - } - )} - - )} - {formState.hasNoSelection && ( - - {t('chat.save.knowledge.error.no_content_selected')} - - )} - {!formState.hasNoSelection && formState.selectedCount === 0 && ( - -   - - )} - + {!isNoteMode && ( + + {formState.selectedCount > 0 && ( + + {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 }) + } + )} + + )} + {formState.hasNoSelection && ( + + {t('chat.save.knowledge.error.no_content_selected')} + + )} + {!formState.hasNoSelection && formState.selectedCount === 0 && ( + +   + + )} + + )} ) return ( { return this.show({ source: { type: 'topic', data: topic }, title }) } + + static showForNote(note: NotesTreeNode, title?: string): Promise { + return this.show({ source: { type: 'note', data: note }, title }) + } } const EmptyContainer = styled.div` diff --git a/src/renderer/src/components/RichEditor/CommandListPopover.tsx b/src/renderer/src/components/RichEditor/CommandListPopover.tsx new file mode 100644 index 0000000000..f75e651866 --- /dev/null +++ b/src/renderer/src/components/RichEditor/CommandListPopover.tsx @@ -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 { + ref?: React.RefObject +} + +export interface CommandListPopoverRef extends SuggestionProps { + updateSelectedIndex: (index: number) => void + selectCurrent: () => void + onKeyDown: (event: KeyboardEvent) => boolean +} + +const CommandListPopover = ({ + ref, + ...props +}: SuggestionProps & { ref?: React.RefObject }) => { + const { items, command } = props + const [internalSelectedIndex, setInternalSelectedIndex] = useState(0) + const listRef = useRef(null) + const virtualListRef = useRef(null) + const shouldAutoScrollRef = useRef(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 ( +
selectItem(index)} + onMouseEnter={() => handleItemMouseEnter(index)}> +
+
+ +
+
+ + {getTranslatedCommand(item, 'title')} + + + {getTranslatedCommand(item, 'description')} + +
+
+
+ ) + }, + [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 ( +
+ {items.length === 0 ? ( +
+ {t('richEditor.commands.noCommandsFound')} +
+ ) : ( + + )} +
+ ) +} + +CommandListPopover.displayName = 'CommandListPopover' + +export default CommandListPopover diff --git a/src/renderer/src/components/RichEditor/TableOfContent.tsx b/src/renderer/src/components/RichEditor/TableOfContent.tsx new file mode 100644 index 0000000000..7afc64ab3b --- /dev/null +++ b/src/renderer/src/components/RichEditor/TableOfContent.tsx @@ -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 = ({ 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 ( + + ) +} + +interface ToCProps { + items?: TableOfContentDataItem[] + editor?: Editor | null + scrollContainerRef?: React.RefObject +} + +export const ToC: React.FC = ({ 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 ( + +
+ {displayItems.map((item) => ( +
+ + {/* floating panel */} +
+ +
+ {filteredItems.map((item) => ( + + ))} +
+
+
+
+ ) +} + +export default React.memo(ToC) diff --git a/src/renderer/src/components/RichEditor/command.ts b/src/renderer/src/components/RichEditor/command.ts new file mode 100644 index 0000000000..a460e210d4 --- /dev/null +++ b/src/renderer/src/components/RichEditor/command.ts @@ -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() + +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, '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 + 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() + } + } + } +} diff --git a/src/renderer/src/components/RichEditor/components/ActionMenu.tsx b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx new file mode 100644 index 0000000000..5f35799da6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ActionMenu.tsx @@ -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 = ({ show, position, items, onClose, minWidth = 168 }) => { + const ref = useRef(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 = ( +
+ +
+ ) + + return createPortal(node, document.body) +} diff --git a/src/renderer/src/components/RichEditor/components/ImageUploader.tsx b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx new file mode 100644 index 0000000000..02a9b73885 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/ImageUploader.tsx @@ -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 => { + 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 = ({ 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: ( +
+ + {t('richEditor.imageUploader.upload')} +
+ ), + children: ( + + {}} // Prevent default upload + disabled={loading}> + {loading ? ( + <> + } /> +

{t('richEditor.imageUploader.uploading')}

+

{t('richEditor.imageUploader.processing')}

+ + ) : ( + <> +

+ +

+

{t('richEditor.imageUploader.uploadText')}

+

{t('richEditor.imageUploader.uploadHint')}

+ + )} +
+
+ ) + }, + { + key: 'url', + label: ( + + + {t('richEditor.imageUploader.embedLink')} + + ), + children: ( + + + setUrlInput(e.target.value)} + onPressEnter={handleUrlSubmit} + prefix={} + style={{ flex: 1 }} + /> + + + + + ) + } + ] + + return ( + + + + ) +} diff --git a/src/renderer/src/components/RichEditor/components/LinkEditor.tsx b/src/renderer/src/components/RichEditor/components/LinkEditor.tsx new file mode 100644 index 0000000000..74a6a149d9 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/LinkEditor.tsx @@ -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 = ({ + visible, + position, + link, + onSave, + onRemove, + onCancel, + showRemove = true +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + const [href, setHref] = useState(link.href || '') + const [text, setText] = useState(link.text || '') + const containerRef = useRef(null) + const hrefInputRef = useRef(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 ( +
+
+ + setText(e.target.value)} + size="small" + /> +
+ +
+ + setHref(e.target.value)} size="small" /> +
+ + +
+ {showRemove && ( + + )} +
+ + + + +
+
+ ) +} + +export default LinkEditor diff --git a/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx b/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx new file mode 100644 index 0000000000..93a864a3a6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/MathInputDialog.tsx @@ -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 +} + +/** + * 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 = ({ + visible, + onSubmit, + onCancel, + defaultValue = '', + onFormulaChange, + position, + scrollContainer +}) => { + const { t } = useTranslation() + const { theme } = useTheme() + const [value, setValue] = useState(defaultValue) + const containerRef = useRef(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 = (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 ( +
+ { + const newValue = e.target.value + setValue(newValue) + onFormulaChange?.(newValue) + }} + onKeyDown={handleKeyDown} + style={{ marginBottom: 12, fontFamily: 'monospace' }} + /> + + + + +
+ ) +} + +export default MathInputDialog diff --git a/src/renderer/src/components/RichEditor/components/PlusButton.tsx b/src/renderer/src/components/RichEditor/components/PlusButton.tsx new file mode 100644 index 0000000000..b1ac0b9030 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/PlusButton.tsx @@ -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 = Pick, K> & Omit + +export type PlusButtonProps = Omit, 'element'> & { + className?: string + onNodeChange?: (data: { node: Node | null; editor: Editor; pos: number }) => void + children: ReactNode +} + +export const PlusButton: React.FC = (props: PlusButtonProps) => { + const { + className = 'plus-button', + children, + editor, + pluginKey = plusButtonPluginDefaultKey, + onNodeChange, + onElementClick, + computePositionConfig = defaultComputePositionConfig + } = props + const [element, setElement] = useState(null) + const plugin = useRef(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 ( +
+ {children} +
+ ) +} + +export default PlusButton diff --git a/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx b/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx new file mode 100644 index 0000000000..bc0282aac0 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/TableActionMenu.tsx @@ -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 = ({ show, position, actions, onClose }) => { + const menuRef = useRef(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 = ( +
+ {actions.map((action, index) => ( + + ))} +
+ ) + + return createPortal(menu, document.body) +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx b/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx new file mode 100644 index 0000000000..6a17d4cdf5 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/DragContextMenu.tsx @@ -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 = { + 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 = ({ + editor, + node, + position, + visible, + menuPosition, + onClose, + customActions = [], + disabledActions = [] +}) => { + const menuRef = useRef(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 = { + 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 ( + + {GROUP_LABELS[group]} + {actions.map((action) => ( + handleMenuItemClick(action)} + disabled={!action.isEnabled(editor, node, position)} + title={action.shortcut ? `${action.label} (${action.shortcut})` : action.label}> + {action.icon && {action.icon}} + {action.label} + {action.shortcut && {action.shortcut}} + + ))} + + ) + }, + [editor, node, position, handleMenuItemClick] + ) + + // 如果菜单不可见,不渲染 + if (!visible) return null + + // 如果没有可用操作,显示空状态 + if (allActions.length === 0) { + const emptyMenu = ( + + No actions available for this block + + ) + + return createPortal(emptyMenu, document.body) + } + + // 渲染完整菜单 + const menu = ( + + {GROUP_ORDER.map((group, index) => { + const actions = finalActionsByGroup[group] + const groupElement = renderMenuGroup(group, actions) + + if (!groupElement) return null + + return ( + + {groupElement} + {/* 在组之间添加分隔线,除了最后一个组 */} + {index < GROUP_ORDER.length - 1 && + actions.length > 0 && + GROUP_ORDER.slice(index + 1).some((g) => finalActionsByGroup[g].length > 0) && } + + ) + })} + + ) + + return createPortal(menu, document.body) +} + +DragContextMenu.displayName = 'DragContextMenu' + +export default DragContextMenu diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts new file mode 100644 index 0000000000..85d3219d50 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/block.ts @@ -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 + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts new file mode 100644 index 0000000000..13083131e8 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/formatting.ts @@ -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 + } + } + } + + // 注意:更多格式化操作可以在后续版本中添加 +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts new file mode 100644 index 0000000000..4ebba7722f --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/index.ts @@ -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' +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts new file mode 100644 index 0000000000..38b1b78d1d --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/insert.ts @@ -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, '

') + .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, '

') + .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 + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts new file mode 100644 index 0000000000..9ec34640ca --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/actions/transform.ts @@ -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) + } + } + } +] diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts new file mode 100644 index 0000000000..b2bc68ed71 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useDragContextMenu.ts @@ -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(null) + const timeoutRef = useRef(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 => { + 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 + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts new file mode 100644 index 0000000000..332c41ebcb --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/hooks/useMenuActionVisibility.ts @@ -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.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 +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts new file mode 100644 index 0000000000..4dd9e042d6 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/index.ts @@ -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 + } +} diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts new file mode 100644 index 0000000000..6f6bebba62 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/styles.ts @@ -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; +` diff --git a/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts new file mode 100644 index 0000000000..32932c7646 --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/dragContextMenu/types.ts @@ -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 + /** 是否保留内容 */ + 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 +} + +/** + * 钩子返回值 - useMenuActionVisibility + */ +export interface UseMenuActionVisibilityReturn { + /** 可见的操作列表 */ + visibleActions: MenuAction[] + /** 按组分类的操作 */ + actionsByGroup: Record + /** 刷新可见性 */ + 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 +} diff --git a/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx new file mode 100644 index 0000000000..a838782ece --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/ImagePlaceholderNodeView.tsx @@ -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) => void + deleteNode: () => void + editor: Editor +} + +const ImagePlaceholderNodeView: React.FC = ({ 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 ( + + } + message={t('richEditor.image.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default ImagePlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx new file mode 100644 index 0000000000..95c1b0f98e --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/MathPlaceholderNodeView.tsx @@ -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 = ({ node, deleteNode, editor }) => { + const { t } = useTranslation() + const wrapperRef = useRef(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 ( + + } + message={t('richEditor.math.placeholder')} + onClick={handleClick} + /> + + ) +} + +export default MathPlaceholderNodeView diff --git a/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx new file mode 100644 index 0000000000..c33cdb78cd --- /dev/null +++ b/src/renderer/src/components/RichEditor/components/placeholder/PlaceholderBlock.tsx @@ -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 = ({ 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 ( +
{ + 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} + {message} +
+ ) +} + +export default PlaceholderBlock diff --git a/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx new file mode 100644 index 0000000000..75ccf6befb --- /dev/null +++ b/src/renderer/src/components/RichEditor/extensions/code-block-shiki/CodeBlockNodeView.tsx @@ -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 = (props) => { + const { node, updateAttributes } = props + const [languageOptions, setLanguageOptions] = useState(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 ( + +
+ setTempPath(e.target.value)} + placeholder={t('notes.settings.data.work_directory_placeholder')} + readOnly + /> + + + + + + + + + {t('notes.settings.data.work_directory_description')} + + + + {/* Editor Settings */} + + {t('notes.settings.editor.title')} + + + {t('notes.settings.editor.view_mode.title')} + updateSettings({ defaultViewMode: value })} + /> + + {t('notes.settings.editor.view_mode.description')} + + + {t('notes.settings.editor.edit_mode.title')} + ) => updateSettings({ defaultEditMode: value })} + /> + + {t('notes.settings.editor.edit_mode.description')} + + + {/* Display Settings */} + + {t('notes.settings.display.title')} + + + {t('notes.settings.display.compress_content')} + updateSettings({ isFullWidth: checked })} /> + + {t('notes.settings.display.compress_content_description')} + + + ) +} + +const WorkDirectorySection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +` + +const PathInputContainer = styled.div` + display: flex; + align-items: center; + width: 100%; +` + +const ActionButtons = styled.div` + display: flex; + gap: 8px; + align-self: flex-start; +` + +export default NotesSettings diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 1f7f8d7a85..6886d5f035 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -12,6 +12,7 @@ import { HardDrive, Info, MonitorCog, + NotebookPen, Package, PictureInPicture2, Settings2, @@ -30,6 +31,7 @@ import DocProcessSettings from './DocProcessSettings' import GeneralSettings from './GeneralSettings' import MCPSettings from './MCPSettings' import MemorySettings from './MemorySettings' +import NotesSettings from './NotesSettings' import { ProviderList } from './ProviderSettings' import QuickAssistantSettings from './QuickAssistantSettings' import QuickPhraseSettings from './QuickPhraseSettings' @@ -88,6 +90,12 @@ const SettingsPage: FC = () => { {t('settings.mcp.title')} + + + + {t('notes.settings.title')} + + @@ -154,6 +162,7 @@ const SettingsPage: FC = () => { } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/services/FileManager.ts b/src/renderer/src/services/FileManager.ts index 4b780cfa94..ec32535da7 100644 --- a/src/renderer/src/services/FileManager.ts +++ b/src/renderer/src/services/FileManager.ts @@ -10,8 +10,7 @@ const logger = loggerService.withContext('FileManager') class FileManager { static async selectFiles(options?: Electron.OpenDialogOptions): Promise { - const files = await window.api.file.select(options) - return files + return await window.api.file.select(options) } static async addFile(file: FileMetadata): Promise { diff --git a/src/renderer/src/services/NotesService.ts b/src/renderer/src/services/NotesService.ts new file mode 100644 index 0000000000..0b150a25a5 --- /dev/null +++ b/src/renderer/src/services/NotesService.ts @@ -0,0 +1,370 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' +import { + findNodeInTree, + findParentNode, + getNotesTree, + insertNodeIntoTree, + isParentNode, + moveNodeInTree, + removeNodeFromTree, + renameNodeFromTree +} from '@renderer/services/NotesTreeService' +import { NotesSortType, NotesTreeNode } from '@renderer/types/note' +import { getFileDirectory } from '@renderer/utils' +import { v4 as uuidv4 } from 'uuid' + +const MARKDOWN_EXT = '.md' +const NOTES_TREE_ID = 'notes-tree-structure' + +const logger = loggerService.withContext('NotesService') + +/** + * 初始化/同步笔记树结构 + */ +export async function initWorkSpace(folderPath: string): Promise { + const tree = await window.api.file.getDirectoryStructure(folderPath) + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) +} + +/** + * 创建新文件夹 + */ +export async function createFolder(name: string, folderPath: string): Promise { + const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, false) + if (exists) { + logger.warn(`Folder already exists: ${safeName}`) + } + + const tree = await getNotesTree() + const folderId = uuidv4() + + const targetPath = await window.api.file.mkdir(`${folderPath}/${safeName}`) + + // 查找父节点ID + const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) + + const folder: NotesTreeNode = { + id: folderId, + name: safeName, + treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, + externalPath: targetPath, + type: 'folder', + children: [], + expanded: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + insertNodeIntoTree(tree, folder, parentNode?.id) + + return folder +} + +/** + * 创建新笔记文件 + */ +export async function createNote(name: string, content: string = '', folderPath: string): Promise { + const { safeName, exists } = await window.api.file.checkFileName(folderPath, name, true) + if (exists) { + logger.warn(`Note already exists: ${safeName}`) + } + + const tree = await getNotesTree() + const noteId = uuidv4() + const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` + + await window.api.file.write(notePath, content) + + // 查找父节点ID + const parentNode = tree.find((node) => node.externalPath === folderPath) || findNodeByExternalPath(tree, folderPath) + + const note: NotesTreeNode = { + id: noteId, + name: safeName, + treePath: parentNode ? `${parentNode.treePath}/${safeName}` : `/${safeName}`, + externalPath: notePath, + type: 'file', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + insertNodeIntoTree(tree, note, parentNode?.id) + + return note +} + +/** + * 上传笔记 + */ +export async function uploadNote(file: File, folderPath: string): Promise { + const tree = await getNotesTree() + const fileName = file.name.toLowerCase() + if (!fileName.endsWith(MARKDOWN_EXT)) { + throw new Error('Only markdown files are allowed') + } + + const noteId = uuidv4() + const nameWithoutExt = fileName.replace(MARKDOWN_EXT, '') + + const { safeName, exists } = await window.api.file.checkFileName(folderPath, nameWithoutExt, true) + if (exists) { + logger.warn(`Note already exists: ${safeName}`) + } + + const notePath = `${folderPath}/${safeName}${MARKDOWN_EXT}` + + const note: NotesTreeNode = { + id: noteId, + name: safeName, + treePath: `/${safeName}`, + externalPath: notePath, + type: 'file', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + + const content = await file.text() + await window.api.file.write(notePath, content) + insertNodeIntoTree(tree, note) + + return note +} + +/** + * 删除笔记或文件夹 + */ +export async function deleteNode(nodeId: string): Promise { + const tree = await getNotesTree() + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + if (node.type === 'folder') { + await window.api.file.deleteExternalDir(node.externalPath) + } else if (node.type === 'file') { + await window.api.file.deleteExternalFile(node.externalPath) + } + + removeNodeFromTree(tree, nodeId) +} + +/** + * 重命名笔记或文件夹 + */ +export async function renameNode(nodeId: string, newName: string): Promise { + const tree = await getNotesTree() + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + + const dirPath = getFileDirectory(node.externalPath) + const { safeName, exists } = await window.api.file.checkFileName(dirPath, newName, node.type === 'file') + + if (exists) { + logger.warn(`Target name already exists: ${safeName}`) + throw new Error(`Target name already exists: ${safeName}`) + } + + if (node.type === 'file') { + await window.api.file.rename(node.externalPath, safeName) + } else if (node.type === 'folder') { + await window.api.file.renameDir(node.externalPath, safeName) + } + return renameNodeFromTree(tree, nodeId, safeName) +} + +/** + * 移动节点 + */ +export async function moveNode( + sourceNodeId: string, + targetNodeId: string, + position: 'before' | 'after' | 'inside' +): Promise { + try { + const tree = await getNotesTree() + + // 找到源节点和目标节点 + const sourceNode = findNodeInTree(tree, sourceNodeId) + const targetNode = findNodeInTree(tree, targetNodeId) + + if (!sourceNode || !targetNode) { + logger.error(`Move nodes failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) + return false + } + + // 不允许文件夹被放入文件中 + if (position === 'inside' && targetNode.type === 'file' && sourceNode.type === 'folder') { + logger.error('Move nodes failed: cannot move a folder inside a file') + return false + } + + // 不允许将节点移动到自身内部 + if (position === 'inside' && isParentNode(tree, sourceNodeId, targetNodeId)) { + logger.error('Move nodes failed: cannot move a node inside itself or its descendants') + return false + } + + let targetPath: string = '' + + if (position === 'inside') { + // 目标是文件夹内部 + if (targetNode.type === 'folder') { + targetPath = targetNode.externalPath + } else { + logger.error('Cannot move node inside a file node') + return false + } + } else { + const targetParent = findParentNode(tree, targetNodeId) + if (targetParent) { + targetPath = targetParent.externalPath + } else { + targetPath = getFileDirectory(targetNode.externalPath!) + } + } + + // 构建新的文件路径 + const sourceName = sourceNode.externalPath!.split('/').pop()! + const sourceNameWithoutExt = sourceName.replace(sourceNode.type === 'file' ? MARKDOWN_EXT : '', '') + + const { safeName } = await window.api.file.checkFileName( + targetPath, + sourceNameWithoutExt, + sourceNode.type === 'file' + ) + + const baseName = safeName + (sourceNode.type === 'file' ? MARKDOWN_EXT : '') + const newPath = `${targetPath}/${baseName}` + + if (sourceNode.externalPath !== newPath) { + try { + if (sourceNode.type === 'folder') { + await window.api.file.moveDir(sourceNode.externalPath, newPath) + } else { + await window.api.file.move(sourceNode.externalPath, newPath) + } + sourceNode.externalPath = newPath + logger.debug(`Moved external ${sourceNode.type} to: ${newPath}`) + } catch (error) { + logger.error(`Failed to move external ${sourceNode.type}:`, error as Error) + return false + } + } + + return await moveNodeInTree(tree, sourceNodeId, targetNodeId, position) + } catch (error) { + logger.error('Move nodes failed:', error as Error) + return false + } +} + +/** + * 对节点数组进行排序 + */ +function sortNodesArray(nodes: NotesTreeNode[], sortType: NotesSortType): void { + // 首先分离文件夹和文件 + const folders: NotesTreeNode[] = nodes.filter((node) => node.type === 'folder') + const files: NotesTreeNode[] = nodes.filter((node) => node.type === 'file') + + // 根据排序类型对文件夹和文件分别进行排序 + const sortFunction = getSortFunction(sortType) + folders.sort(sortFunction) + files.sort(sortFunction) + + // 清空原数组并重新填入排序后的节点 + nodes.length = 0 + nodes.push(...folders, ...files) +} + +/** + * 根据排序类型获取相应的排序函数 + */ +function getSortFunction(sortType: NotesSortType): (a: NotesTreeNode, b: NotesTreeNode) => number { + switch (sortType) { + case 'sort_a2z': + return (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'accent' }) + + case 'sort_z2a': + return (a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'accent' }) + + case 'sort_updated_desc': + return (a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 + const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 + return timeB - timeA + } + + case 'sort_updated_asc': + return (a, b) => { + const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 + const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 + return timeA - timeB + } + + case 'sort_created_desc': + return (a, b) => { + const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return timeB - timeA + } + + case 'sort_created_asc': + return (a, b) => { + const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return timeA - timeB + } + + default: + return (a, b) => a.name.localeCompare(b.name) + } +} + +/** + * 递归排序笔记树中的所有层级 + */ +export async function sortAllLevels(sortType: NotesSortType): Promise { + try { + const tree = await getNotesTree() + sortNodesArray(tree, sortType) + recursiveSortNodes(tree, sortType) + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + logger.info(`Sorted all levels of notes successfully: ${sortType}`) + } catch (error) { + logger.error('Failed to sort all levels of notes:', error as Error) + throw error + } +} + +/** + * 递归对节点中的子节点进行排序 + */ +function recursiveSortNodes(nodes: NotesTreeNode[], sortType: NotesSortType): void { + for (const node of nodes) { + if (node.type === 'folder' && node.children && node.children.length > 0) { + sortNodesArray(node.children, sortType) + recursiveSortNodes(node.children, sortType) + } + } +} + +/** + * 根据外部路径查找节点(递归查找) + */ +function findNodeByExternalPath(nodes: NotesTreeNode[], externalPath: string): NotesTreeNode | null { + for (const node of nodes) { + if (node.externalPath === externalPath) { + return node + } + if (node.children && node.children.length > 0) { + const found = findNodeByExternalPath(node.children, externalPath) + if (found) { + return found + } + } + } + return null +} diff --git a/src/renderer/src/services/NotesTreeService.ts b/src/renderer/src/services/NotesTreeService.ts new file mode 100644 index 0000000000..5ce0bc0d5c --- /dev/null +++ b/src/renderer/src/services/NotesTreeService.ts @@ -0,0 +1,285 @@ +import { loggerService } from '@logger' +import db from '@renderer/databases' +import { NotesTreeNode } from '@renderer/types/note' + +const MARKDOWN_EXT = '.md' +const NOTES_TREE_ID = 'notes-tree-structure' + +const logger = loggerService.withContext('NotesTreeService') + +/** + * 获取树结构 + */ +export const getNotesTree = async (): Promise => { + const record = await db.notes_tree.get(NOTES_TREE_ID) + return record?.tree || [] +} + +/** + * 在树中插入节点 + */ +export async function insertNodeIntoTree( + tree: NotesTreeNode[], + node: NotesTreeNode, + parentId?: string +): Promise { + try { + if (!parentId) { + tree.push(node) + } else { + const parent = findNodeInTree(tree, parentId) + if (parent && parent.type === 'folder') { + if (!parent.children) { + parent.children = [] + } + parent.children.push(node) + } + } + + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + return tree + } catch (error) { + logger.error('Failed to insert node into tree:', error as Error) + throw error + } +} + +/** + * 从树中删除节点 + */ +export async function removeNodeFromTree(tree: NotesTreeNode[], nodeId: string): Promise { + const removed = removeNodeFromTreeInMemory(tree, nodeId) + if (removed) { + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + } + return removed +} + +/** + * 从树中删除节点(仅在内存中操作,不保存数据库) + */ +function removeNodeFromTreeInMemory(tree: NotesTreeNode[], nodeId: string): boolean { + for (let i = 0; i < tree.length; i++) { + if (tree[i].id === nodeId) { + tree.splice(i, 1) + return true + } + if (tree[i].children) { + const removed = removeNodeFromTreeInMemory(tree[i].children!, nodeId) + if (removed) { + return true + } + } + } + return false +} + +export async function moveNodeInTree( + tree: NotesTreeNode[], + sourceNodeId: string, + targetNodeId: string, + position: 'before' | 'after' | 'inside' +): Promise { + try { + const sourceNode = findNodeInTree(tree, sourceNodeId) + const targetNode = findNodeInTree(tree, targetNodeId) + + if (!sourceNode || !targetNode) { + logger.error(`Move nodes in tree failed: node not found (source: ${sourceNodeId}, target: ${targetNodeId})`) + return false + } + + // 先保存源节点的副本,以防操作失败需要恢复(暂未实现恢复逻辑) + // const sourceNodeCopy = { ...sourceNode } + + // 从原位置移除节点(不保存数据库,只在内存中操作) + const removed = removeNodeFromTreeInMemory(tree, sourceNodeId) + if (!removed) { + logger.error('Move nodes in tree failed: could not remove source node') + return false + } + + try { + // 根据位置进行放置 + if (position === 'inside' && targetNode.type === 'folder') { + if (!targetNode.children) { + targetNode.children = [] + } + targetNode.children.push(sourceNode) + targetNode.expanded = true + + sourceNode.treePath = `${targetNode.treePath}/${sourceNode.name}` + } else { + const targetParent = findParentNode(tree, targetNodeId) + const targetList = targetParent ? targetParent.children! : tree + const targetIndex = targetList.findIndex((node) => node.id === targetNodeId) + + if (targetIndex === -1) { + logger.error('Move nodes in tree failed: target position not found') + return false + } + + // 根据position确定插入位置 + const insertIndex = position === 'before' ? targetIndex : targetIndex + 1 + targetList.splice(insertIndex, 0, sourceNode) + + // 更新节点路径 + if (targetParent) { + sourceNode.treePath = `${targetParent.treePath}/${sourceNode.name}` + } else { + sourceNode.treePath = `/${sourceNode.name}` + } + } + + // 更新修改时间 + sourceNode.updatedAt = new Date().toISOString() + + // 只有在所有操作成功后才保存到数据库 + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + + return true + } catch (error) { + logger.error('Move nodes in tree failed during placement, attempting to restore:', error as Error) + // 如果放置失败,尝试恢复原始节点到原位置 + // 这里需要重新实现恢复逻辑,暂时返回false + return false + } + } catch (error) { + logger.error('Move nodes in tree failed:', error as Error) + return false + } +} + +/** + * 重命名节点 + */ +export async function renameNodeFromTree( + tree: NotesTreeNode[], + nodeId: string, + newName: string +): Promise { + const node = findNodeInTree(tree, nodeId) + + if (!node) { + throw new Error('Node not found') + } + + node.name = newName + + const dirPath = node.treePath.substring(0, node.treePath.lastIndexOf('/') + 1) + node.treePath = dirPath + newName + + const externalDirPath = node.externalPath.substring(0, node.externalPath.lastIndexOf('/') + 1) + node.externalPath = node.type === 'file' ? externalDirPath + newName + MARKDOWN_EXT : externalDirPath + newName + + node.updatedAt = new Date().toISOString() + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + return node +} + +/** + * 修改节点键值 + */ +export async function updateNodeInTree( + tree: NotesTreeNode[], + nodeId: string, + updates: Partial +): Promise { + const node = findNodeInTree(tree, nodeId) + if (!node) { + throw new Error('Node not found') + } + + Object.assign(node, updates) + node.updatedAt = new Date().toISOString() + await db.notes_tree.put({ id: NOTES_TREE_ID, tree }) + + return node +} + +/** + * 在树中查找节点 + */ +export function findNodeInTree(tree: NotesTreeNode[], nodeId: string): NotesTreeNode | null { + for (const node of tree) { + if (node.id === nodeId) { + return node + } + if (node.children) { + const found = findNodeInTree(node.children, nodeId) + if (found) { + return found + } + } + } + return null +} + +/** + * 根据路径查找节点 + */ +export function findNodeByPath(tree: NotesTreeNode[], path: string): NotesTreeNode | null { + for (const node of tree) { + if (node.treePath === path) { + return node + } + if (node.children) { + const found = findNodeByPath(node.children, path) + if (found) { + return found + } + } + } + return null +} + +// --- +// 辅助函数 +// --- + +/** + * 查找节点的父节点 + */ +export function findParentNode(tree: NotesTreeNode[], targetNodeId: string): NotesTreeNode | null { + for (const node of tree) { + if (node.children) { + const isDirectChild = node.children.some((child) => child.id === targetNodeId) + if (isDirectChild) { + return node + } + + const parent = findParentNode(node.children, targetNodeId) + if (parent) { + return parent + } + } + } + return null +} + +/** + * 判断节点是否为另一个节点的父节点 + */ +export function isParentNode(tree: NotesTreeNode[], parentId: string, childId: string): boolean { + const childNode = findNodeInTree(tree, childId) + if (!childNode) { + return false + } + + const parentNode = findNodeInTree(tree, parentId) + if (!parentNode || parentNode.type !== 'folder' || !parentNode.children) { + return false + } + + if (parentNode.children.some((child) => child.id === childId)) { + return true + } + + for (const child of parentNode.children) { + if (isParentNode(tree, child.id, childId)) { + return true + } + } + + return false +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 26b19f60a5..01bb7d217c 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -19,6 +19,7 @@ import messageBlocksReducer from './messageBlock' import migrate from './migrate' import minapps from './minapps' import newMessagesReducer from './newMessage' +import note from './note' import nutstore from './nutstore' import ocr from './ocr' import paintings from './paintings' @@ -57,14 +58,15 @@ const rootReducer = combineReducers({ messageBlocks: messageBlocksReducer, inputTools: inputToolsReducer, translate, - ocr + ocr, + note }) const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 140, + version: 142, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, @@ -83,7 +85,7 @@ const persistedReducer = persistReducer( * Call storeSyncService.subscribe() in the window's entryPoint.tsx */ storeSyncService.setOptions({ - syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/'] + syncList: ['assistants/', 'settings/', 'llm/', 'selectionStore/', 'note/'] }) const store = configureStore({ diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 005ad75517..05cbd3c9c3 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -40,6 +40,7 @@ import { RootState } from '.' import { DEFAULT_TOOL_ORDER } from './inputTools' import { initialState as llmInitialState, moveProvider } from './llm' import { mcpSlice } from './mcp' +import { initialState as notesInitialState } from './note' import { defaultActionItems } from './selectionStore' import { initialState as settingsInitialState } from './settings' import { initialState as shortcutsInitialState } from './shortcuts' @@ -2287,6 +2288,32 @@ const migrateConfig = { logger.error('migrate 140 error', error as Error) return state } + }, + '141': (state: RootState) => { + try { + if (state.settings && state.settings.sidebarIcons) { + // Check if 'notes' is not already in visible icons + if (!state.settings.sidebarIcons.visible.includes('notes')) { + state.settings.sidebarIcons.visible = [...state.settings.sidebarIcons.visible, 'notes'] + } + } + return state + } catch (error) { + logger.error('migrate 141 error', error as Error) + return state + } + }, + '142': (state: RootState) => { + try { + // Initialize notes settings if not present + if (!state.note) { + state.note = notesInitialState + } + return state + } catch (error) { + logger.error('migrate 142 error', error as Error) + return state + } } } diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts new file mode 100644 index 0000000000..4d481ed391 --- /dev/null +++ b/src/renderer/src/store/note.ts @@ -0,0 +1,59 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { RootState } from '@renderer/store/index' +import { EditorView } from '@renderer/types' + +export interface NotesSettings { + isFullWidth: boolean + fontFamily: 'default' | 'serif' + defaultViewMode: 'edit' | 'read' + defaultEditMode: Omit + showTabStatus: boolean +} + +export interface NoteState { + activeNodeId: string | undefined + activeFilePath: string | undefined // 使用文件路径而不是nodeId + settings: NotesSettings + notesPath: string +} + +export const initialState: NoteState = { + activeNodeId: undefined, + activeFilePath: undefined, + settings: { + isFullWidth: true, + fontFamily: 'default', + defaultViewMode: 'edit', + defaultEditMode: 'preview', + showTabStatus: true + }, + notesPath: '' +} + +const noteSlice = createSlice({ + name: 'note', + initialState, + reducers: { + setActiveNodeId: (state, action: PayloadAction) => { + state.activeNodeId = action.payload + }, + setActiveFilePath: (state, action: PayloadAction) => { + state.activeFilePath = action.payload + }, + updateNotesSettings: (state, action: PayloadAction>) => { + state.settings = { ...state.settings, ...action.payload } + }, + setNotesPath: (state, action: PayloadAction) => { + state.notesPath = action.payload + } + } +}) + +export const { setActiveNodeId, setActiveFilePath, updateNotesSettings, setNotesPath } = noteSlice.actions + +export const selectActiveNodeId = (state: RootState) => state.note.activeNodeId +export const selectActiveFilePath = (state: RootState) => state.note.activeFilePath +export const selectNotesSettings = (state: RootState) => state.note.settings +export const selectNotesPath = (state: RootState) => state.note.notesPath + +export default noteSlice.reducer diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 510d287187..446a69ec7f 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -183,6 +183,7 @@ export interface SettingsState { siyuan: boolean docx: boolean plain_text: boolean + notes: boolean } // OpenAI openAI: { @@ -212,6 +213,8 @@ export interface SettingsState { // API Server apiServer: ApiServerConfig showMessageOutline?: boolean + // Notes Related + showWorkspace: boolean } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -357,7 +360,8 @@ export const initialState: SettingsState = { obsidian: true, siyuan: true, docx: true, - plain_text: true + plain_text: true, + notes: true }, // OpenAI openAI: { @@ -389,6 +393,7 @@ export const initialState: SettingsState = { maxBackups: 0, skipBackupFile: false }, + // Developer mode enableDeveloperMode: false, // UI @@ -400,7 +405,9 @@ export const initialState: SettingsState = { port: 23333, apiKey: `cs-sk-${uuid()}` }, - showMessageOutline: undefined + showMessageOutline: undefined, + // Notes Related + showWorkspace: true } const settingsSlice = createSlice({ @@ -832,6 +839,12 @@ const settingsSlice = createSlice({ }, setShowMessageOutline: (state, action: PayloadAction) => { state.showMessageOutline = action.payload + }, + setShowWorkspace: (state, action: PayloadAction) => { + state.showWorkspace = action.payload + }, + toggleShowWorkspace: (state) => { + state.showWorkspace = !state.showWorkspace } } }) @@ -961,7 +974,9 @@ export const { // API Server actions setApiServerEnabled, setApiServerPort, - setApiServerApiKey + setApiServerApiKey, + setShowWorkspace, + toggleShowWorkspace } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index aa96232a07..bbefd622f2 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -5,6 +5,7 @@ import type { CSSProperties } from 'react' import * as z from 'zod/v4' export * from './file' +export * from './note' import type { FileMetadata } from './file' import type { Message } from './newMessage' @@ -734,6 +735,7 @@ export type SidebarIcon = | 'knowledge' | 'files' | 'code_tools' + | 'notes' export type ExternalToolResult = { mcpTools?: MCPTool[] @@ -1183,6 +1185,8 @@ export interface MemoryListOptions extends MemoryEntity { } export interface MemoryDeleteAllOptions extends MemoryEntity {} + +export type EditorView = 'preview' | 'source' | 'read' // 实时,源码,预览 // ======================================================================== /** diff --git a/src/renderer/src/types/note.ts b/src/renderer/src/types/note.ts new file mode 100644 index 0000000000..fda85e63d8 --- /dev/null +++ b/src/renderer/src/types/note.ts @@ -0,0 +1,24 @@ +export type NotesSortType = + | 'sort_a2z' // 文件名(A-Z) + | 'sort_z2a' // 文件名(Z-A) + | 'sort_updated_desc' // 更新时间(从新到旧) + | 'sort_updated_asc' // 更新时间(从旧到新) + | 'sort_created_desc' // 创建时间(从新到旧) + | 'sort_created_asc' // 创建时间(从旧到新) + +/** + * @interface + * @description 笔记树节点接口 + */ +export interface NotesTreeNode { + id: string + name: string // 不包含扩展名 + type: 'folder' | 'file' + treePath: string // 相对路径 + externalPath: string // 绝对路径 + children?: NotesTreeNode[] + isStarred?: boolean + expanded?: boolean + createdAt: string + updatedAt: string +} diff --git a/src/renderer/src/utils/__tests__/markdownConverter.test.ts b/src/renderer/src/utils/__tests__/markdownConverter.test.ts new file mode 100644 index 0000000000..9f42f61830 --- /dev/null +++ b/src/renderer/src/utils/__tests__/markdownConverter.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, it } from 'vitest' + +import { htmlToMarkdown, markdownToHtml, markdownToSafeHtml, sanitizeHtml } from '../markdownConverter' + +describe('markdownConverter', () => { + describe('htmlToMarkdown', () => { + it('should convert HTML to Markdown', () => { + const html = '

Hello World

' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello World') + }) + + it('should keep
to
', () => { + const html = '

Text with
\nindentation
\nand without indentation

' + const result = htmlToMarkdown(html) + expect(result).toBe('Text with
indentation
and without indentation') + }) + + it('should convert task list HTML back to Markdown', () => { + const html = + '
  • abcd
  • efgh
' + const result = htmlToMarkdown(html) + expect(result).toContain('- [ ] abcd') + expect(result).toContain('- [x] efgh') + }) + + it('should convert task list HTML back to Markdown with label', () => { + const html = + '
' + const result = htmlToMarkdown(html) + expect(result).toBe('- [ ] abcd\n\n- [x] efgh') + }) + + it('should handle empty HTML', () => { + const result = htmlToMarkdown('') + expect(result).toBe('') + }) + + it('should handle null/undefined input', () => { + expect(htmlToMarkdown(null as any)).toBe('') + expect(htmlToMarkdown(undefined as any)).toBe('') + }) + + it('should keep math block containers intact', () => { + const html = '
' + const result = htmlToMarkdown(html) + expect(result).toBe('$$a+b+c$$') + }) + + it('should convert multiple math blocks to Markdown', () => { + const html = + '
' + const result = htmlToMarkdown(html) + expect(result).toBe( + '$$\\begin{array}{c}\n\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} &\n= \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\\n\n\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\\n\n\\nabla \\cdot \\vec{\\mathbf{B}} & = 0\n\n\\end{array}$$' + ) + }) + + it('should convert math inline syntax to Markdown', () => { + const html = '' + const result = htmlToMarkdown(html) + expect(result).toBe('$a+b+c$') + }) + + it('shoud convert multiple math blocks and inline math to Markdown', () => { + const html = + '

' + const result = htmlToMarkdown(html) + expect(result).toBe('$$a+b+c$$\n\n$d+e+f$') + }) + + it('should convert heading and img to Markdown', () => { + const html = '

Hello

\n

alt text

\n' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello\n\n![alt text](https://example.com/image.png)') + }) + + it('should convert heading and paragraph to Markdown', () => { + const html = '

Hello

\n

Hello

\n' + const result = htmlToMarkdown(html) + expect(result).toBe('# Hello\n\nHello') + }) + + it('should convert code block to Markdown', () => { + const html = '
console.log("Hello, world!");
' + const result = htmlToMarkdown(html) + expect(result).toBe('```\nconsole.log("Hello, world!");\n```') + }) + + it('should convert code block with language to Markdown', () => { + const html = '
console.log("Hello, world!");
' + const result = htmlToMarkdown(html) + expect(result).toBe('```javascript\nconsole.log("Hello, world!");\n```') + }) + + it('should convert table to Markdown', () => { + const html = + '

f

f

f

' + const result = htmlToMarkdown(html) + expect(result).toBe('| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |') + }) + }) + + describe('markdownToHtml', () => { + it('should convert
to
', () => { + const markdown = 'Text with
\nindentation
\nand without indentation' + const result = markdownToHtml(markdown) + expect(result).toBe('

Text with
\nindentation
\nand without indentation

\n') + }) + + it('should handle indentation in blockquotes', () => { + const markdown = '> Quote line 1\n> Quote line 2 with indentation' + const result = markdownToHtml(markdown) + // This should preserve indentation within the blockquote + expect(result).toContain('Quote line 1') + expect(result).toContain('Quote line 2 with indentation') + }) + + it('should preserve indentation in nested lists', () => { + const markdown = '- Item 1\n - Nested item\n - Double nested\n with continued line' + const result = markdownToHtml(markdown) + // Should create proper nested list structure + expect(result).toContain('
    ') + expect(result).toContain('
  • ') + }) + + it('should handle poetry or formatted text with indentation', () => { + const markdown = 'Roses are red\n Violets are blue\n Sugar is sweet\n And so are you' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Roses are red\nViolets are blue\nSugar is sweet\nAnd so are you

    \n') + }) + + it('should preserve indentation after line breaks with multiple paragraphs', () => { + const markdown = 'First paragraph\n\n with indentation\n\n Second paragraph\n\nwith different indentation' + const result = markdownToHtml(markdown) + expect(result).toBe( + '

    First paragraph

    \n
    with indentation\n\nSecond paragraph\n

    with different indentation

    \n' + ) + }) + + it('should handle zero-width indentation (just line break)', () => { + const markdown = 'Hello\n\nWorld' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Hello

    \n

    World

    \n') + }) + + it('should preserve indentation in mixed content', () => { + const markdown = + 'Normal text\n Indented continuation\n\n- List item\n List continuation\n\n> Quote\n> Indented quote' + const result = markdownToHtml(markdown) + expect(result).toBe( + '

    Normal text\nIndented continuation

    \n
      \n
    • List item\nList continuation
    • \n
    \n
    \n

    Quote\nIndented quote

    \n
    \n' + ) + }) + + it('should convert Markdown to HTML', () => { + const markdown = '# Hello World' + const result = markdownToHtml(markdown) + expect(result).toContain('

    Hello World

    ') + }) + + it('should convert math block syntax to HTML', () => { + const markdown = '$$a+b+c$$' + const result = markdownToHtml(markdown) + expect(result).toContain('
    ') + }) + + it('should convert math inline syntax to HTML', () => { + const markdown = '$a+b+c$' + const result = markdownToHtml(markdown) + expect(result).toContain('') + }) + + it('should convert multiple math blocks to HTML', () => { + const markdown = `$$\\begin{array}{c} +\\nabla \\times \\vec{\\mathbf{B}} -\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{E}}}{\\partial t} & += \\frac{4\\pi}{c}\\vec{\\mathbf{j}} \\nabla \\cdot \\vec{\\mathbf{E}} & = 4 \\pi \\rho \\\\ + +\\nabla \\times \\vec{\\mathbf{E}}\\, +\\, \\frac1c\\, \\frac{\\partial\\vec{\\mathbf{B}}}{\\partial t} & = \\vec{\\mathbf{0}} \\\\ + +\\nabla \\cdot \\vec{\\mathbf{B}} & = 0 + +\\end{array}$$` + const result = markdownToHtml(markdown) + expect(result).toContain( + '
    ' + ) + }) + + it('should convert task list syntax to proper HTML', () => { + const markdown = '- [ ] abcd\n\n- [x] efgh\n\n' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('data-type="taskItem"') + expect(result).toContain('data-checked="false"') + expect(result).toContain('data-checked="true"') + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('abcd') + expect(result).toContain('efgh') + }) + + it('should convert mixed task list with checked and unchecked items', () => { + const markdown = '- [ ] First task\n\n- [x] Second task\n\n- [ ] Third task' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('First task') + expect(result).toContain('Second task') + expect(result).toContain('Third task') + expect(result.match(/data-checked="false"/g)).toHaveLength(2) + expect(result.match(/data-checked="true"/g)).toHaveLength(1) + }) + + it('should NOT convert standalone task syntax to task list', () => { + const markdown = '[x] abcd' + const result = markdownToHtml(markdown) + expect(result).toContain('

    [x] abcd

    ') + expect(result).not.toContain('data-type="taskList"') + }) + + it('should handle regular list items alongside task lists', () => { + const markdown = '- Regular item\n\n- [ ] Task item\n\n- Another regular item' + const result = markdownToHtml(markdown) + expect(result).toContain('data-type="taskList"') + expect(result).toContain('Regular item') + expect(result).toContain('Task item') + expect(result).toContain('Another regular item') + }) + + it('should handle empty Markdown', () => { + const result = markdownToHtml('') + expect(result).toBe('') + }) + + it('should handle null/undefined input', () => { + expect(markdownToHtml(null as any)).toBe('') + expect(markdownToHtml(undefined as any)).toBe('') + }) + + it('should handle heading and img', () => { + const markdown = `# 🌠 Screenshot + +![](https://example.com/image.png)` + const result = markdownToHtml(markdown) + expect(result).toBe('

    🌠 Screenshot

    \n

    \n') + }) + + it('should handle heading and paragraph', () => { + const markdown = '# Hello\n\nHello' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Hello

    \n

    Hello

    \n') + }) + + it('should convert code block to HTML', () => { + const markdown = '```\nconsole.log("Hello, world!");\n```' + const result = markdownToHtml(markdown) + expect(result).toBe('
    console.log("Hello, world!");\n
    ') + }) + + it('should convert code block with language to HTML', () => { + const markdown = '```javascript\nconsole.log("Hello, world!");\n```' + const result = markdownToHtml(markdown) + expect(result).toBe( + '
    console.log("Hello, world!");\n
    ' + ) + }) + + it('should convert table to HTML', () => { + const markdown = '| f | | |\n| --- | --- | --- |\n| | f | |\n| | | f |' + const result = markdownToHtml(markdown) + expect(result).toBe( + '\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    f
    f
    f
    \n' + ) + }) + + it('should escape XML-like tags in code blocks', () => { + const markdown = '```jsx\nconst component = <>
    content
    \n```' + const result = markdownToHtml(markdown) + expect(result).toBe( + '
    const component = <><div>content</div></>\n
    ' + ) + }) + + it('should escape XML-like tags in inline code', () => { + const markdown = 'Use `<>` for fragments' + const result = markdownToHtml(markdown) + expect(result).toBe('

    Use <> for fragments

    \n') + }) + + it('shoud convert XML-like tags in paragraph', () => { + const markdown = '' + const result = markdownToHtml(markdown) + expect(result).toBe('

    \n') + }) + }) + + describe('sanitizeHtml', () => { + it('should sanitize HTML content and remove scripts', () => { + const html = '

    Hello

    ' + const result = sanitizeHtml(html) + expect(result).toContain('

    Hello

    ') + expect(result).not.toContain('