diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f17e127 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Codex Changelog + +## Recent Feature Updates + +### ✨ Features & Enhancements + +- **Revision Notes System** + + - Added the ability to mark specific text selections as "Revision Notes" using the `CTRL+SHIFT+R` (`Cmd+Shift+R` on Mac) shortcut. + - A new "Revision Notes" panel has been added to the editor's sidebar (accessible via a dedicated icon). + - Clicking on a note in the sidebar will automatically scroll the editor and focus on the exact position of the revision. + - The visual style of these notes (background color, borders, etc.) can now be dynamically configured via the `Prefs.ts` file under `revisionNoteStyle`. + +- **Section & Paragraph Reordering** + + - You can now seamlessly reorganize your document's structure without breaking formatting. + - Hovering over any heading (`H1`, `H2`, `H3`) will reveal discrete "Up" and "Down" arrows on the left side. + - Clicking these arrows will instantly move the entire section (the heading along with all of its inner paragraphs and content) up or down, automatically swapping places with adjacent sections. + +- **Text Finder (Search in Page)** + + - Added a fully functional "Find in Document" overlay accessible via `CTRL+F` / `CMD+F`. + - Integrated a custom Tiptap Search extension leveraging ProseMirror Decorations to highlight text seamlessly without hijacking the DOM string selection or the user's cursor focus. + - Added auto-scroll to the current match using smooth browser native transitions. + - Supported Keyboard Shortcuts within the Finder input box (`Enter` for next, `Shift+Enter` for previous, `Escape` to close). + - Safely escaped all RegEx characters to support searching for wildcards and punctuation marks without performance bottlenecks. + +- **Image Cropper Modal** + + - Integrated a robust Image Cropping sub-routine directly into the Editor. + - When selecting an image inside the editor, a new "Crop" button dynamically appears in the toolbar. + - The modal supports freeform cropping using an HTML5 Canvas interface and elegantly replaces the old image with the newly cropped Base64 preview. + +- **Dynamic 'Word-Style' Text Styling** + + - Transitioned from hardcoded tags to a fully dynamic Custom Styles configuration powered by JSON (`Prefs.ts`). + - Users can now define entirely customized text and heading rules globally mapped within Codex. + - The Editor Toolbar dropdown automatically reads and maps these styles into selectable headings and paragraphs seamlessly. + +- **Auto-Save System** + + - Implemented an internal interval-based tracker managing the background saving operations of the active note. + - Introduced fully granular controls under Settings to disable Auto-Save or configure its intervals (ranging from 1 to 30 minutes). + +- **Settings Menu Redesign & Organization** + + - Overhauled `SettingsView.tsx` utilizing Mantine's modern Tab-based interface. + - Split configurations into three accessible macro-categories: **General**, **Editor**, and **Saving**. + - Added a brand new scaling preference (`toolbarSize`) that dynamically alters the size of the Editor Toolbar for accessibility and better clicks. + +- **Table of Contents (TOC) UI Uplift** + + - Redesigned the TOC container to be scrollable and properly constrained inside the view window, resolving overlap issues for documents spanning many headings. + +- **Localization Coverage** + - Added and integrated all strings relating to Auto-Save, Cropper, Editor Width/Border, Text Finder, and Toolbar sizing across all language dictionaries (English `en_US`, Russian `ru_RU`, Simplified Chinese `zh_CN`). + +### 🐛 Bug Fixes + +- Prevented `CTRL+F` from causing duplicate component mounts if the finder was already active. +- Resolved Z-Index overlapping conflicts between the floating Text Finder and the sticky Editor Toolbar. +- Fixed an issue where the generic Auto-Save feature could crash if trigged while switching components globally. diff --git a/MISSING_FILES_FIX.md b/MISSING_FILES_FIX.md new file mode 100644 index 0000000..dd49814 --- /dev/null +++ b/MISSING_FILES_FIX.md @@ -0,0 +1,32 @@ +### 🛠️ How to migrate/restore old notes if they don't show up in the app + +If you manually moved your old note files into the `~/.config/codex/notes/` folder but Codex isn't showing them, **don't panic, your data is safe.** This happens because Codex uses an internal database/registry to keep track of notes. If you just drop a file into the folder, the app won't recognize it because it's not registered in the database. + +To fix this, we can do a quick "file transplant" using the terminal. Here is the step-by-step workaround: + +#### Step 1: Register a new note in the app +1. Open the new version of Codex. +2. Create a **New Note** (you can name it something like *"Recovered Note"*). +3. Close the app completely. This forces Codex to register the new note in its database. + +#### Step 2: Find the internal file name +1. Open your terminal. +2. Run the following command to find the newly created file (it will be at the top of the list and will have a random string name): +```bash +ls -lt ~/.config/codex/notes/ | head -n 5 +``` +3. Copy that random filename. + +#### Step 3: Overwrite the file (The Transplant) +Now, we will overwrite the empty registered note with your old note data. +Run the `cp` (copy) command in your terminal like this: + +```bash +cp /path/to/your/OLD_NOTE_FILE ~/.config/codex/notes/THE_NEW_RANDOM_NAME +``` +*(Make sure to replace `/path/to/your/OLD_NOTE_FILE` with the actual path of your old file, and `THE_NEW_RANDOM_NAME` with the file name you found in Step 2).* + +#### Step 4: You're done! +Open Codex again. Click on the *"Recovered Note"* you created in Step 1, and you will see all your old content perfectly restored! + +> **Note on Custom Styles:** If you are upgrading to the new version containing the `CustomStyle` extension, you don't need to worry about formatting crashes. The extension is built with backward compatibility (`default: null`), so it will automatically read and safely upgrade your old files without any errors. diff --git a/package.json b/package.json index 3e820d5..6862436 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "lowlight": "^3.1.0", "mathlive": "^0.104.0", "palettey": "^1.0.4", + "react-image-crop": "^11.0.10", "react-lowlight": "^3.0.0", "sanitize-filename": "^1.6.3", "semver": "^7.5.4", diff --git a/packages/common/Locales.ts b/packages/common/Locales.ts index 5a17cc3..634ffe0 100644 --- a/packages/common/Locales.ts +++ b/packages/common/Locales.ts @@ -43,10 +43,13 @@ export type Locale = { open_pdf_on_export: string; saving_section: string; autosave_page_on_switch: string; + autosave: string; + autosave_interval: string; general: string; editor: string; save_folder: string; editor_width: string; + toolbar_size: string; editor_border: string; editor_spellcheck: string; save_folder_location: string; @@ -191,6 +194,7 @@ export type Locale = { align_center: string; align_justified: string; image: string; + crop: string; paragraph: string; blockquote: string; heading: string; @@ -258,7 +262,22 @@ export type Locale = { create_link: string; url: string; }; + cropModal: { + title: string; + cancel: string; + save: string; + }; + textFinder: { + find: string; + next: string; + previous: string; + no_results: string; + }; code_block_collapse: string; + revision_notes: string; + no_revision_notes: string; + move_up: string; + move_down: string; }; unsavedChangesDialog: { title: (name: string) => string; @@ -316,13 +335,16 @@ export const locales: Record = { use_typography_extension: "Use Typography extension in the Editor", use_typography_description: 'This enables turning things like "(c)" into "©".', open_pdf_on_export: "Automatically open PDF after exporting", - saving_section: "Saving Pages", + saving_section: "Saving", autosave_page_on_switch: "Automatically save the current page when switching between pages/exiting the editor", + autosave: "Auto Save", + autosave_interval: "Auto Save Interval (minutes)", general: "General", editor: "Editor", save_folder: "Save Folder", editor_width: "Editor Width", + toolbar_size: "Toolbar Size (requires restart)", editor_border: "Editor Border", editor_spellcheck: "Editor Spellcheck", save_folder_location: "Save Folder Location", @@ -486,6 +508,7 @@ export const locales: Record = { align_center: "Align Center", align_justified: "Align Justified", image: "Insert/Replace Image", + crop: "Crop Image", paragraph: "Set to Paragraph", blockquote: "Set to Block Quote", heading: "Heading", @@ -554,7 +577,22 @@ export const locales: Record = { create_link: "Create Link", url: "URL" }, - code_block_collapse: "Collapse" + cropModal: { + title: "Crop Image", + cancel: "Cancel", + save: "Save Crop" + }, + textFinder: { + find: "Find in document...", + next: "Next", + previous: "Previous", + no_results: "No results" + }, + code_block_collapse: "Collapse", + revision_notes: "Revision Notes", + no_revision_notes: "No revision notes found", + move_up: "Move Up", + move_down: "Move Down" }, unsavedChangesDialog: { title: (name: string) => `You have unsaved changes to "${name}"`, @@ -609,10 +647,13 @@ export const locales: Record = { open_pdf_on_export: "完成导出后自动打开PDF文件", saving_section: "保存页面", autosave_page_on_switch: "页面切换/退出编辑器时自动保存当前页面", + autosave: "自动保存", + autosave_interval: "自动保存间隔(分钟)", general: "基础设置", editor: "编辑器", save_folder: "保存文件夹", editor_width: "编辑器宽度", + toolbar_size: "Toolbar Size (requires restart)", editor_border: "编辑器边框", editor_spellcheck: "拼写检查", save_folder_location: "保存位置", @@ -775,6 +816,7 @@ export const locales: Record = { align_center: "居中对齐", align_justified: "两端对齐", image: "插入/替换图片", + crop: "Crop Image", paragraph: "设置为段落", blockquote: "设置为块引用", heading: "标题", @@ -842,7 +884,22 @@ export const locales: Record = { create_link: "创建超链接", url: "URL" }, - code_block_collapse: "折叠" + cropModal: { + title: "Crop Image", + cancel: "Cancel", + save: "Save" + }, + textFinder: { + find: "Find in document...", + next: "Next", + previous: "Previous", + no_results: "No results" + }, + code_block_collapse: "折叠", + revision_notes: "Revision Notes", + no_revision_notes: "No revision notes found", + move_up: "Move Up", + move_down: "Move Down" }, unsavedChangesDialog: { title: (name: string) => `您对 "${name}" 有未保存的更改`, @@ -897,10 +954,13 @@ export const locales: Record = { saving_section: "Сохранение страниц", autosave_page_on_switch: "Автоматически сохранять текущую страницу при переключении между страницами или выходе из редактора", + autosave: "Auto Save", + autosave_interval: "Auto Save Interval (minutes)", general: "Главная", editor: "Редактор", save_folder: "Сохранить папку", editor_width: "Ширина редактора", + toolbar_size: "Toolbar Size (requires restart)", editor_border: "Граница редактора", editor_spellcheck: "Проверка орфографии в редакторе", save_folder_location: "Место сохранения папок", @@ -1063,6 +1123,7 @@ export const locales: Record = { align_center: "Выровнять по центру", align_justified: "Выравнивание по ширине", image: "Вставить/заменить изображение", + crop: "Crop Image", paragraph: "Установить на абзац", blockquote: "Установить на блок цитаты", heading: "Заголовок", @@ -1131,7 +1192,22 @@ export const locales: Record = { create_link: "Создать ссылку", url: "URL" }, - code_block_collapse: "Свернуть" + cropModal: { + title: "Crop Image", + cancel: "Cancel", + save: "Save" + }, + textFinder: { + find: "Find in document...", + next: "Next", + previous: "Previous", + no_results: "No results" + }, + code_block_collapse: "Свернуть", + revision_notes: "Revision Notes", + no_revision_notes: "No revision notes found", + move_up: "Move Up", + move_down: "Move Down" }, unsavedChangesDialog: { title: (name: string) => `Вы имеете несохранённые изменения в "${name}"`, diff --git a/packages/common/Prefs.ts b/packages/common/Prefs.ts index c824bb5..7f73d61 100644 --- a/packages/common/Prefs.ts +++ b/packages/common/Prefs.ts @@ -8,6 +8,8 @@ class GeneralPrefs { theme: "light" | "dark" = "light"; titlebarStyle: "custom" | "native" = "custom"; autoSaveOnPageSwitch = true; + autoSave = false; + autoSaveInterval = 5; } class EditorPrefs { @@ -21,6 +23,49 @@ class EditorPrefs { recentCodeLangs: string[] = []; codeWordWrap = false; tabSize = 4; + toolbarSize: "sm" | "md" | "lg" = "md"; + customTextStyles: Record> = { + Normal: { + "tag": "p", + "font-size": "default", + "font-weight": "normal", + "font-style": "normal", + "color": "inherit" + }, + "Heading 1": { + "tag": "h1", + "font-size": "32px", + "font-weight": "bold", + }, + "Heading 2": { + "tag": "h2", + "font-size": "24px", + "font-weight": "bold", + }, + Subtitle: { + "tag": "p", + "font-size": "18px", + "color": "gray", + "font-style": "italic", + }, + Quote: { + "tag": "blockquote", + "font-size": "16px", + "font-style": "italic", + "background-color": "#f0f0f0", + "padding": "4px 8px", + "border-left": "4px solid #ccc" + }, + Highlight: { + "background-color": "#ffff00", + "color": "#000000" + } + }; + revisionNoteStyle: Record = { + "background-color": "rgba(255, 235, 59, 0.3)", + "color": "inherit", + "border-bottom": "2px dashed #fbc02d" + }; } class MiscPrefs { diff --git a/packages/renderer/src/App.tsx b/packages/renderer/src/App.tsx index a2b0912..0ceabe2 100644 --- a/packages/renderer/src/App.tsx +++ b/packages/renderer/src/App.tsx @@ -35,7 +35,7 @@ export function App() { const fakeEditor = useRef( new Editor({ editable: false, - extensions: extensions({ useTypography: false, tabSize: 4 }) + extensions: extensions({ useTypography: false, tabSize: 4, customStyles: {} }) }) ); @@ -381,6 +381,30 @@ export function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!prefs.current.general.autoSave) return; + const intervalMs = prefs.current.general.autoSaveInterval * 60 * 1000; + const interval = setInterval(() => { + if (unsavedChanges.current && activePage) { + saveActivePage(); + notifications.show({ + id: activePage.id + "-autosave", + message: ( + + {locale.notifications.saved} {activePage.name} (Auto) + + ), + color: "blue", + icon: , + autoClose: 2000, + withBorder: true + }); + } + }, intervalMs); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefs.current.general.autoSave, prefs.current.general.autoSaveInterval, activePage]); + return ( void; +}; + +export function EditorCropModal({ state, onClose }: Props) { + const appContext = useContext(AppContext); + const texts = locales[appContext.prefs.general.locale].editor.cropModal; + + const [crop, setCrop] = useState(); + const imageRef = useRef(null); + + useEffect(() => { + if (state.opened) setCrop(undefined); + }, [state.opened]); + + const getCroppedImage = () => { + if (!imageRef.current || !crop) return; + const canvas = document.createElement("canvas"); + const scaleX = imageRef.current.naturalWidth / imageRef.current.width; + const scaleY = imageRef.current.naturalHeight / imageRef.current.height; + canvas.width = Math.floor(crop.width * scaleX); + canvas.height = Math.floor(crop.height * scaleY); + const ctx = canvas.getContext("2d"); + + if (ctx) { + ctx.drawImage( + imageRef.current, + crop.x * scaleX, + crop.y * scaleY, + crop.width * scaleX, + crop.height * scaleY, + 0, + 0, + crop.width * scaleX, + crop.height * scaleY + ); + + const base64Image = canvas.toDataURL("image/png"); + if (state.editor) { + state.editor.chain().focus().setImage({ src: base64Image }).run(); + } + } + onClose(); + }; + + return ( + +
+ setCrop(c)}> + {state.src && Crop preview} + +
+ + + + +
+ ); +} diff --git a/packages/renderer/src/components/Modals/ModalProvider.tsx b/packages/renderer/src/components/Modals/ModalProvider.tsx index ef95544..6709d24 100644 --- a/packages/renderer/src/components/Modals/ModalProvider.tsx +++ b/packages/renderer/src/components/Modals/ModalProvider.tsx @@ -5,6 +5,8 @@ import { EditModalState, EditorImageModal, EditorImageModalState, + EditorCropModal, + EditorCropModalState, EditorMathModal, EditorMathModalState, NewModal, @@ -25,6 +27,7 @@ class ModalStore { // Editor modals openEditorImageModal: (options: Omit) => void = () => {}; + openEditorCropModal: (options: Omit) => void = () => {}; openEditorMathModal: (options: Omit) => void = () => {}; openEditorLinkModal: (options: Omit) => void = () => {}; } @@ -64,6 +67,12 @@ export function ModalProvider(props: { children?: React.ReactNode }) { editor: null }); + const [editorCropModalState, setEditorCropModalState] = useState({ + opened: false, + editor: null, + src: "" + }); + const [editorMathModalState, setEditorMathModalState] = useState({ opened: false, editor: null, @@ -101,6 +110,9 @@ export function ModalProvider(props: { children?: React.ReactNode }) { openEditorImageModal: (options) => { setEditorImageModalState({ opened: true, editor: options.editor }); }, + openEditorCropModal: (options) => { + setEditorCropModalState({ opened: true, editor: options.editor, src: options.src }); + }, openEditorMathModal: (options) => { setEditorMathModalState({ opened: true, @@ -143,6 +155,10 @@ export function ModalProvider(props: { children?: React.ReactNode }) { setEditorImageModalState({ ...editorImageModalState, opened: false }) } /> + setEditorCropModalState({ ...editorCropModalState, opened: false })} + /> setEditorMathModalState({ ...editorMathModalState, opened: false })} diff --git a/packages/renderer/src/components/Modals/index.ts b/packages/renderer/src/components/Modals/index.ts index 811c7dc..ffa3cbd 100644 --- a/packages/renderer/src/components/Modals/index.ts +++ b/packages/renderer/src/components/Modals/index.ts @@ -7,4 +7,5 @@ export * from "./EditModal"; export * from "./ContextMenu"; export * from "./EditorImageModal"; +export * from "./EditorCropModal"; export * from "./EditorMathModal"; diff --git a/packages/renderer/src/components/Views/Editor/EditorExtensions.ts b/packages/renderer/src/components/Views/Editor/EditorExtensions.ts index 96dd6dc..0bc542b 100644 --- a/packages/renderer/src/components/Views/Editor/EditorExtensions.ts +++ b/packages/renderer/src/components/Views/Editor/EditorExtensions.ts @@ -26,8 +26,11 @@ import { CustomLink } from "./extensions/CustomLink"; import { CustomCode } from "./extensions/CustomCode"; import { CustomTable } from "./extensions/CustomTable"; import { ResizableImage } from "./extensions/ResizableImage/ResizableImage"; +import { CustomStyle } from "./extensions/CustomStyle"; +import { Search } from "./extensions/Search"; +import { RevisionNote } from "./extensions/RevisionNote"; -export function extensions(options: { useTypography: boolean; tabSize: number }) { +export function extensions(options: { useTypography: boolean; tabSize: number; customStyles: Record> }) { const e = [ StarterKit.configure({ codeBlock: false, @@ -85,7 +88,12 @@ export function extensions(options: { useTypography: boolean; tabSize: number }) Markdown.configure({ html: true }), - FontSize + FontSize, + Search, + RevisionNote, + CustomStyle.configure({ + customStyles: options.customStyles + }) ] as Extensions; if (options.useTypography) e.push(Typography); diff --git a/packages/renderer/src/components/Views/Editor/EditorView.tsx b/packages/renderer/src/components/Views/Editor/EditorView.tsx index f18be5e..756c627 100644 --- a/packages/renderer/src/components/Views/Editor/EditorView.tsx +++ b/packages/renderer/src/components/Views/Editor/EditorView.tsx @@ -1,13 +1,15 @@ import "./styles.scss"; import { Container, Paper } from "@mantine/core"; import { Page } from "common/Save"; -import { useContext, useEffect, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { AppContext } from "types/AppStore"; import { Editor, EditorContent, useEditor } from "@tiptap/react"; import Toolbar from "./Toolbar/Toolbar"; import { extensions } from "./EditorExtensions"; import { TableOfContents } from "./TableOfContents"; +import { RevisionNotesList } from "./RevisionNotesList"; import { EditorStyles } from "./EditorStyles"; +import { TextFinder } from "./TextFinder"; type Props = { page: Page; @@ -16,14 +18,16 @@ type Props = { export function EditorView({ page, setEditorRef }: Props) { const appContext = useContext(AppContext); + const [showTextFinder, setShowTextFinder] = useState(false); const _extensions = useMemo( () => extensions({ useTypography: appContext.prefs.editor.useTypographyExtension, - tabSize: appContext.prefs.editor.tabSize + tabSize: appContext.prefs.editor.tabSize, + customStyles: appContext.prefs.editor.customTextStyles }), - [appContext.prefs.editor.tabSize, appContext.prefs.editor.useTypographyExtension] + [appContext.prefs.editor.tabSize, appContext.prefs.editor.useTypographyExtension, appContext.prefs.editor.customTextStyles] ); const content = useMemo(() => JSON.parse(window.api.loadPage(page.fileName)), [page.fileName]); @@ -58,12 +62,29 @@ export function EditorView({ page, setEditorRef }: Props) { ); }, [appContext]); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "f") { + e.preventDefault(); + setShowTextFinder(true); + setTimeout(() => { + const input = document.getElementById("textfinder-input"); + if (input) input.focus(); + }, 0); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + if (editor != null) { return ( + <>{showTextFinder && setShowTextFinder(false)} />} + ({ + icon: { + color: theme.colors.gray[6], + position: "absolute", + top: "64px" // Positioned below TOC + }, + body: { + padding: theme.spacing.md, + position: "absolute", + top: "118px", // Positioned below TOC body + zIndex: 3000, + width: "250px", + height: "300px", + maxWidth: "600px", + minWidth: "150px", + minHeight: "150px", + resize: "both", + overflow: "hidden", + display: "flex", + flexDirection: "column" + }, + item: { + display: "block", + padding: "8px", + marginBottom: "8px", + border: `${rem(1)} solid ${ + theme.colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[3] + }`, + borderRadius: theme.radius.sm, + cursor: "pointer", + textDecoration: "none", + color: theme.colorScheme === "dark" ? theme.colors.dark[0] : theme.black, + backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], + + "&:hover": { + backgroundColor: + theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1] + } + }, + emptyState: { + color: theme.colors.gray[5], + textAlign: "center", + marginTop: theme.spacing.xl + } +})); + +type NoteItem = { + id: string; + text: string; + pos: number; +}; + +function RevisionNotesListComponent(props: { editor: Editor }) { + const [open, setOpen] = useState(false); + const [items, setItems] = useState([]); + + const { classes } = useStyles(); + const appContext = useContext(AppContext); + const texts = locales[appContext.prefs.general.locale].editor; + const revisionNoteStyle = appContext.prefs.editor.revisionNoteStyle; + + const handleUpdate = useCallback(() => { + setTimeout(() => { + const notes: NoteItem[] = []; + + props.editor.state.doc.descendants((node, pos) => { + const mark = node.marks.find(m => m.type.name === "revisionNote"); + if (mark) { + const id = mark.attrs.id; + // If we already have a note with this id, we can skip or append text + if (!notes.find(n => n.id === id)) { + notes.push({ + id, + text: truncate(node.textContent, 50), + pos + }); + } + } + }); + + setItems(notes); + }, 1); + }, [props.editor]); + + useEffect(handleUpdate, [props.editor.state.doc, handleUpdate]); + + useEffect(() => { + props.editor.on("update", handleUpdate); + return () => { + props.editor.off("update", handleUpdate); + }; + }, [props.editor, handleUpdate]); + + useEffect(() => { + const handleClose = () => setOpen(false); + window.addEventListener("close-notes-list", handleClose); + return () => window.removeEventListener("close-notes-list", handleClose); + }, []); + + const toggleOpen = () => { + if (!open) window.dispatchEvent(new Event("close-toc")); + setOpen(!open); + }; + + const scrollToNote = (pos: number) => { + props.editor.commands.focus(); + props.editor.commands.setTextSelection(pos); + + // Scroll into view manually + const domNode = props.editor.view.domAtPos(pos).node; + if (domNode instanceof Element) { + domNode.scrollIntoView({ behavior: "smooth", block: "center" }); + } else if (domNode.parentElement) { + domNode.parentElement.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + return ( + <> + + + + + + + + + + {(styles) => ( + + {texts.revision_notes} + + + + {items.length === 0 ? ( + + {texts.no_revision_notes} + + ) : ( + items.map((item) => ( +
scrollToNote(item.pos)} + > + + {item.text} + +
+ )) + )} +
+
+ )} +
+ + ); +} + +export const RevisionNotesList = memo(RevisionNotesListComponent); diff --git a/packages/renderer/src/components/Views/Editor/TableOfContents.tsx b/packages/renderer/src/components/Views/Editor/TableOfContents.tsx index dd5e1ec..8fea052 100644 --- a/packages/renderer/src/components/Views/Editor/TableOfContents.tsx +++ b/packages/renderer/src/components/Views/Editor/TableOfContents.tsx @@ -4,6 +4,7 @@ import { createStyles, Paper, rem, + ScrollArea, Space, Text, Tooltip, @@ -27,7 +28,15 @@ const useStyles = createStyles((theme) => ({ position: "absolute", top: "78px", zIndex: 3000, - maxWidth: "300px" + width: "250px", + height: "400px", + maxWidth: "600px", + minWidth: "150px", + minHeight: "150px", + resize: "both", + overflow: "hidden", + display: "flex", + flexDirection: "column" }, link: { display: "block", @@ -97,10 +106,21 @@ export function TableOfContents(props: { editor: Editor }) { }; }, [props.editor, handleUpdate]); + useEffect(() => { + const handleClose = () => setOpen(false); + window.addEventListener("close-toc", handleClose); + return () => window.removeEventListener("close-toc", handleClose); + }, []); + + const toggleOpen = () => { + if (!open) window.dispatchEvent(new Event("close-notes-list")); + setOpen(!open); + }; + return ( <> - setOpen(!open)}> + @@ -111,17 +131,19 @@ export function TableOfContents(props: { editor: Editor }) { {texts.table_of_contents} - {items.map((item: any, index: any) => ( - - {item.text} - - ))} + + {items.map((item: any, index: any) => ( + + {item.text} + + ))} + )} diff --git a/packages/renderer/src/components/Views/Editor/TextFinder.tsx b/packages/renderer/src/components/Views/Editor/TextFinder.tsx new file mode 100644 index 0000000..7bacea0 --- /dev/null +++ b/packages/renderer/src/components/Views/Editor/TextFinder.tsx @@ -0,0 +1,135 @@ +import { ActionIcon, Flex, Paper, Text, TextInput } from "@mantine/core"; +import { Editor } from "@tiptap/react"; +import { Icon } from "components/Icon"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { AppContext } from "types/AppStore"; +import { locales } from "common/Locales"; + +type Props = { + editor: Editor; + onClose: () => void; +}; + +export function TextFinder({ editor, onClose }: Props) { + const appContext = useContext(AppContext); + const texts = locales[appContext.prefs.general.locale].editor.textFinder; + + const [query, setQuery] = useState(""); + const [currentIndex, setCurrentIndex] = useState(0); + + const matches = useMemo(() => { + if (!query) return []; + const found: { from: number; to: number }[] = []; + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedQuery, "gi"); + + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text) { + let match; + regex.lastIndex = 0; + while ((match = regex.exec(node.text)) !== null) { + found.push({ + from: pos + match.index, + to: pos + match.index + match[0].length + }); + } + } + }); + + return found; + }, [query, editor.state.doc.content]); + + useEffect(() => { + if (matches.length > 0) { + const index = Math.min(currentIndex, matches.length - 1); + + if (editor.commands.setSearchTerm) { + editor.commands.setSearchTerm(query, index); + } + + // Scroll to the current match via standard DOM to avoid stealing focus + setTimeout(() => { + const el = document.querySelector(".search-match-current"); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 50); + + if (index !== currentIndex) { + setCurrentIndex(index); + } + } else { + if (editor.commands.setSearchTerm) { + editor.commands.setSearchTerm("", 0); + } + } + }, [matches, currentIndex, editor, query]); + + const handleClose = () => { + if (editor.commands.setSearchTerm) { + editor.commands.setSearchTerm("", 0); + } + onClose(); + }; + + return ( + + + { + setQuery(e.currentTarget.value); + setCurrentIndex(0); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (e.shiftKey) { + setCurrentIndex((c) => (c > 0 ? c - 1 : matches.length - 1)); + } else { + setCurrentIndex((c) => (c < matches.length - 1 ? c + 1 : 0)); + } + } else if (e.key === "Escape") { + handleClose(); + editor.commands.focus(); + } + }} + style={{ flexGrow: 1 }} + /> + + {matches.length > 0 ? `${currentIndex + 1}/${matches.length}` : "0/0"} + + setCurrentIndex((c) => (c > 0 ? c - 1 : matches.length - 1))} + title={texts.previous} + > + + + setCurrentIndex((c) => (c < matches.length - 1 ? c + 1 : 0))} + title={texts.next} + > + + + + + + + + ); +} diff --git a/packages/renderer/src/components/Views/Editor/Toolbar/Toolbar.tsx b/packages/renderer/src/components/Views/Editor/Toolbar/Toolbar.tsx index 66f5552..d886641 100644 --- a/packages/renderer/src/components/Views/Editor/Toolbar/Toolbar.tsx +++ b/packages/renderer/src/components/Views/Editor/Toolbar/Toolbar.tsx @@ -73,6 +73,7 @@ export default function Toolbar({ editor }: Props) { return ( <> setShow(!show)} /> + {/* scale/zoom changes according to toolbarSize setting */}
modalContext.openEditorImageModal({ editor: editor })} /> + { + const imageAttrs = editor.getAttributes("image"); + if (imageAttrs.src) { + modalContext.openEditorCropModal({ + editor, + src: imageAttrs.src + }); + } + }} + disabled={() => !editor.isActive("image")} + /> editor.isActive("blockQuote")} /> - editor.chain().focus().toggleHeading({ level: 1 }).run()} - > - {[1, 2, 3, 4, 5, 6].map((i) => ( - - editor - .chain() - .focus() - .toggleHeading({ level: i as 1 | 2 | 3 | 4 | 5 | 6 }) - .run() - } - icon={} - > - {texts.heading_level} {i} - - ))} - + editor.chain().focus().toggleRevisionNote().run()} + isActive={() => editor.isActive("revisionNote")} + /> + >; +}; + +declare module "@tiptap/core" { + interface Commands { + customStyle: { + setCustomStyle: (styleName: string) => ReturnType; + unsetCustomStyle: () => ReturnType; + }; + } +} + +export const CustomStyle = Extension.create({ + name: "customStyle", + + addOptions() { + return { + types: ["textStyle", "paragraph", "heading"], + customStyles: {} + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + customStyleName: { + default: null, + parseHTML: (element) => element.getAttribute("data-custom-style"), + renderHTML: (attributes) => { + const stylesConfig = this.options.customStyles; + if (!attributes.customStyleName || !stylesConfig[attributes.customStyleName]) { + return {}; + } + const cssObj = stylesConfig[attributes.customStyleName]; + const styleString = Object.keys(cssObj) + .filter(v => v !== "tag" && v !== "type") + .map((v) => `${v}: ${cssObj[v]}`) + .join("; "); + return { + style: styleString, + "data-custom-style": attributes.customStyleName + }; + } + } + } + } + ]; + }, + + addCommands() { + return { + setCustomStyle: + (styleName) => + ({ chain, state }) => { + const { selection } = state; + if (selection.empty) { + return chain() + .updateAttributes("paragraph", { customStyleName: styleName }) + .updateAttributes("heading", { customStyleName: styleName }) + .run(); + } else { + return chain() + .setMark("textStyle", { customStyleName: styleName }) + .run(); + } + }, + unsetCustomStyle: + () => + ({ chain, state }) => { + const { selection } = state; + if (selection.empty) { + return chain() + .updateAttributes("paragraph", { customStyleName: null }) + .updateAttributes("heading", { customStyleName: null }) + .run(); + } else { + return chain() + .setMark("textStyle", { customStyleName: null }) + .removeEmptyTextStyle() + .run(); + } + } + }; + } +}); diff --git a/packages/renderer/src/components/Views/Editor/extensions/HeadingNodeView.tsx b/packages/renderer/src/components/Views/Editor/extensions/HeadingNodeView.tsx new file mode 100644 index 0000000..b12504e --- /dev/null +++ b/packages/renderer/src/components/Views/Editor/extensions/HeadingNodeView.tsx @@ -0,0 +1,54 @@ +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { ActionIcon, createStyles } from "@mantine/core"; +import { Icon } from "components/Icon"; + +const useStyles = createStyles((theme) => ({ + wrapper: { + position: "relative", + "&:hover .controls": { + opacity: 1 + } + }, + controls: { + position: "absolute", + left: "-30px", + top: "50%", + transform: "translateY(-50%)", + display: "flex", + flexDirection: "column", + opacity: 0, + transition: "opacity 0.2s ease" + }, + icon: { + color: theme.colors.gray[5], + "&:hover": { + color: theme.colorScheme === "dark" ? theme.white : theme.black, + backgroundColor: "transparent" + } + } +})); + +export function HeadingNodeView(props: NodeViewProps) { + const { classes } = useStyles(); + + const toggleFold = () => { + props.updateAttributes({ + collapsed: !props.node.attrs.collapsed + }); + }; + + const Tag = `h${props.node.attrs.level}` as keyof JSX.IntrinsicElements; + + return ( + +
+ + + +
+ + + +
+ ); +} diff --git a/packages/renderer/src/components/Views/Editor/extensions/HeadingWithId.ts b/packages/renderer/src/components/Views/Editor/extensions/HeadingWithId.ts index da95dc5..1837c4f 100644 --- a/packages/renderer/src/components/Views/Editor/extensions/HeadingWithId.ts +++ b/packages/renderer/src/components/Views/Editor/extensions/HeadingWithId.ts @@ -1,4 +1,8 @@ import { Heading } from "@tiptap/extension-heading"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { HeadingNodeView } from "./HeadingNodeView"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const HeadingWithId = Heading.extend({ addAttributes() { @@ -8,7 +12,60 @@ export const HeadingWithId = Heading.extend({ }, level: { default: 1 + }, + collapsed: { + default: false, + parseHTML: (element) => element.hasAttribute("data-collapsed"), + renderHTML: (attributes) => { + if (!attributes.collapsed) return {}; + return { "data-collapsed": "true" }; + } } }; + }, + + addNodeView() { + return ReactNodeViewRenderer(HeadingNodeView); + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("heading-fold"), + props: { + decorations(state) { + const { doc } = state; + const decorations: Decoration[] = []; + let currentFoldLevel: number | null = null; + + doc.forEach((node, offset) => { + const isHeading = node.type.name === "heading"; + + if (isHeading) { + if (currentFoldLevel !== null && node.attrs.level <= currentFoldLevel) { + // End of fold + currentFoldLevel = null; + } + } + + if (currentFoldLevel !== null) { + // This node is inside a fold + decorations.push( + Decoration.node(offset, offset + node.nodeSize, { + style: "display: none;" + }) + ); + } + + if (isHeading && node.attrs.collapsed && currentFoldLevel === null) { + currentFoldLevel = node.attrs.level; + } + }); + + return DecorationSet.create(doc, decorations); + } + } + }) + ]; } }); diff --git a/packages/renderer/src/components/Views/Editor/extensions/RevisionNote.ts b/packages/renderer/src/components/Views/Editor/extensions/RevisionNote.ts new file mode 100644 index 0000000..59047f0 --- /dev/null +++ b/packages/renderer/src/components/Views/Editor/extensions/RevisionNote.ts @@ -0,0 +1,84 @@ +import { Mark, mergeAttributes } from "@tiptap/core"; + +export interface RevisionNoteOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + revisionNote: { + setRevisionNote: () => ReturnType; + toggleRevisionNote: () => ReturnType; + unsetRevisionNote: () => ReturnType; + }; + } +} + +export const RevisionNote = Mark.create({ + name: "revisionNote", + + addOptions() { + return { + HTMLAttributes: { + class: "revision-note", + }, + }; + }, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-revision-note-id"), + renderHTML: (attributes) => { + if (!attributes.id) { + return {}; + } + + return { + "data-revision-note-id": attributes.id, + }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "span", + getAttrs: (node) => (node as HTMLElement).hasAttribute("data-revision-note-id") && null, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addCommands() { + return { + setRevisionNote: + () => + ({ commands }) => { + return commands.setMark(this.name, { id: crypto.randomUUID() }); + }, + toggleRevisionNote: + () => + ({ commands }) => { + return commands.toggleMark(this.name, { id: crypto.randomUUID() }); + }, + unsetRevisionNote: + () => + ({ commands }) => { + return commands.unsetMark(this.name); + }, + }; + }, + + addKeyboardShortcuts() { + return { + "Mod-Shift-r": () => this.editor.commands.toggleRevisionNote(), + }; + }, +}); diff --git a/packages/renderer/src/components/Views/Editor/extensions/Search.ts b/packages/renderer/src/components/Views/Editor/extensions/Search.ts new file mode 100644 index 0000000..fe618e6 --- /dev/null +++ b/packages/renderer/src/components/Views/Editor/extensions/Search.ts @@ -0,0 +1,96 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, Transaction, EditorState } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Node } from "@tiptap/pm/model"; + +declare module "@tiptap/core" { + interface Commands { + search: { + setSearchTerm: (searchTerm: string, currentIndex: number) => ReturnType; + }; + } +} + +const searchPluginKey = new PluginKey("search"); + +export const Search = Extension.create({ + name: "search", + + addCommands() { + return { + setSearchTerm: + (searchTerm: string, currentIndex: number) => + ({ tr }) => { + tr.setMeta(searchPluginKey, { searchTerm, currentIndex }); + return true; + } + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: searchPluginKey, + state: { + init() { + return { searchTerm: "", currentIndex: 0, decorations: DecorationSet.empty }; + }, + apply(tr: Transaction, oldState: any) { + const meta = tr.getMeta(searchPluginKey); + if (!tr.docChanged && !meta) { + return { + ...oldState, + decorations: oldState.decorations.map(tr.mapping, tr.doc) + }; + } + + const searchTerm = meta ? meta.searchTerm : oldState.searchTerm; + const currentIndex = meta ? meta.currentIndex : oldState.currentIndex; + + if (!searchTerm) { + return { searchTerm, currentIndex, decorations: DecorationSet.empty }; + } + + const decorations: Decoration[] = []; + const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedTerm, "gi"); + let matchIndex = 0; + + tr.doc.descendants((node: Node, pos: number) => { + if (node.isText && node.text) { + regex.lastIndex = 0; + let match; + while ((match = regex.exec(node.text)) !== null) { + const from = pos + match.index; + const to = from + match[0].length; + const isCurrent = matchIndex === currentIndex; + + decorations.push( + Decoration.inline(from, to, { + class: isCurrent ? "search-match-current" : "search-match", + style: isCurrent + ? "background-color: #ff9632; color: #000; border-radius: 2px;" + : "background-color: #ffe082; color: #000; border-radius: 2px;" + }) + ); + matchIndex++; + } + } + }); + + return { + searchTerm, + currentIndex, + decorations: DecorationSet.create(tr.doc, decorations) + }; + } + }, + props: { + decorations(state: EditorState) { + return searchPluginKey.getState(state)?.decorations || DecorationSet.empty; + } + } + }) + ]; + } +}); diff --git a/packages/renderer/src/components/Views/SettingsView.tsx b/packages/renderer/src/components/Views/SettingsView.tsx index 118fe70..d05ce8b 100644 --- a/packages/renderer/src/components/Views/SettingsView.tsx +++ b/packages/renderer/src/components/Views/SettingsView.tsx @@ -10,14 +10,16 @@ import { Paper, Select, Space, + Tabs, TextInput, + Textarea, Title, Tooltip, Transition, useMantineTheme } from "@mantine/core"; import { Icon } from "components/Icon"; -import { useContext, useMemo } from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { hljsStyles } from "types/hljsStyles"; import { SupportedLocales, locales } from "common/Locales"; import { AppContext } from "types/AppStore"; @@ -41,6 +43,11 @@ export function SettingsView(props: { startPrefs: Prefs }) { const exampleCode = '#include \n\nusing namespace std;\n\nint main(int argc, char* argv[]) {\n\n /* An annoying "Hello World" example */\n cout << "Hello, World!" << endl;\n\n}'; + const [customStylesJson, setCustomStylesJson] = useState(() => + JSON.stringify(prefs.editor.customTextStyles, null, 4) + ); + const [customStylesError, setCustomStylesError] = useState(""); + const codeBlockThemes = useMemo(() => { const arr = new Array