From f547301923a897b552601fceb89359e1923c9b20 Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Wed, 28 Oct 2026 18:15:45 +0000 Subject: [PATCH 1/2] Add bookmarks, Mark All, token styles and smart highlighting --- .claude/skills/ui-system/SKILL.md | 1 + README.md | 8 +- resources/Themes/Dark.css | 8 + resources/Themes/Light.css | 8 + resources/Themes/Matrix.css | 8 + resources/Themes/Orange Dark.css | 8 + resources/Themes/Soft Light.css | 8 + src/BaseTheme.css | 54 +++++- src/domain/Bookmarks.ts | 66 +++++++ src/domain/EditorTab.ts | 3 + src/domain/Marks.ts | 57 ++++++ src/domain/Settings.ts | 3 + src/domain/__tests__/Bookmarks.test.ts | 45 +++++ src/domain/__tests__/Marks.test.ts | 35 ++++ src/domain/__tests__/Settings.test.ts | 2 + src/enums/index.ts | 23 +++ src/ipc/ContextMenuHandlers.ts | 9 +- src/react/EditorSearch.ts | 58 +++--- src/react/OpenDocuments.ts | 8 +- src/react/components/EditorContainer.tsx | 8 +- src/react/components/SearchBar.tsx | 1 + src/react/components/SettingsDialog.tsx | 5 + src/react/components/editor/Editor.tsx | 2 +- src/react/hooks/useEditorSearch.ts | 44 +++-- src/react/hooks/useMarks.ts | 219 +++++++++++++++++++++++ src/utils/SearchMenu.ts | 68 ++++++- src/utils/Shortcuts.ts | 46 +++++ src/utils/UndoHistory.ts | 18 +- 28 files changed, 773 insertions(+), 50 deletions(-) create mode 100644 src/domain/Bookmarks.ts create mode 100644 src/domain/Marks.ts create mode 100644 src/domain/__tests__/Bookmarks.test.ts create mode 100644 src/domain/__tests__/Marks.test.ts create mode 100644 src/react/hooks/useMarks.ts diff --git a/.claude/skills/ui-system/SKILL.md b/.claude/skills/ui-system/SKILL.md index dfafc33..7571222 100644 --- a/.claude/skills/ui-system/SKILL.md +++ b/.claude/skills/ui-system/SKILL.md @@ -49,6 +49,7 @@ Components never pick their own values. Every color, spacing, radius, font, shad | Scrollbar | `--scrollbar-thumb-color`, `--scrollbar-thumb-hover-color`, `--scrollbar-track-color` | theme | | Text selection (editor, preview, inputs) | `--selection-bg` | theme | | Search match highlight / search-in-selection scope in the editor | `--search-match-bg`, `--search-scope-bg` | theme | +| Mark All highlight, the five token styles, smart highlighting and the bookmark dot in the gutter | `--mark-found-bg`, `--mark-1-bg` … `--mark-5-bg`, `--smart-highlight-bg`, `--bookmark-color` | theme | | Code tokens | `--keyword-color`, `--type-color`, `--attribute-color`, `--string-color`, `--number-color`, `--comment-color`, `--variable-color`, `--block-color`, `--xml-tag-color` | theme | | Tag palette | `--tag-1-color` … `--tag-8-color` (theme), `--tag-1-bg` … `--tag-8-bg` (derived in base) | theme + base | | Spacing scale | `--space-1` … `--space-6` | base | diff --git a/README.md b/README.md index 9b9e239..8fc3582 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A lightweight, fast and multi-functional desktop text editor for Windows, macOS - Your tabs, including unsaved content, cursor position, scroll position and undo history, are saved automatically and restored on the next start. - Right-click a tab for Close, Close Other Tabs, Close Unmodified Tabs, Close All Tabs, Pin/Unpin, Open Containing Folder and Reopen Closed Tab. -- Right-click in the editor for Undo/Redo, Cut/Copy/Paste/Delete, Select All, Base64, Format, line and case tools, Find, Replace, Go To, Search on the Web, Markdown/HTML preview, Open Containing Folder and Copy File Path. User plugin actions appear there too. +- Right-click in the editor for Undo/Redo, Cut/Copy/Paste/Delete, Select All, Base64, Format, line and case tools, Find, Replace, Go To, Search on the Web, Markdown/HTML preview, Toggle Bookmark, Style Token / Clear Style, Open Containing Folder and Copy File Path. User plugin actions appear there too. - Pinned tabs stay at the front and cannot be closed by accident. - Drag tabs to reorder them. - Reopen up to 20 recently closed tabs with their content. @@ -55,6 +55,10 @@ A lightweight, fast and multi-functional desktop text editor for Windows, macOS - Replace All with "Folder" chosen asks first, then changes the files on disk and keeps each file's encoding and line endings. Files that are open in tabs are changed in the editor instead, so the change can still be undone. A file whose encoding cannot hold the replacement is left alone and listed in a notice. - Go To (`Ctrl+G`) jumps to a line (`120`), a line and column (`120:15`) or a character offset (`#3456`). Go to Matching Brace is `Ctrl+B`. - Search on the Web opens the selection in your browser; the search address is set in Settings > Search. +- Mark All (in the search bar or the Search menu) highlights every match of the search in the current file and keeps the highlight after the bar is closed; Clear Marks removes it. "Mark All and Bookmark Lines" also bookmarks each matching line. +- Style Token colors every occurrence of the selection, or the word at the cursor, in one of five styles (Search > Style Token, or right-click). Clear Style removes one style or all of them. Marks and styles follow your edits and are kept with the tab. +- Bookmarks: `Ctrl+F2` toggles a bookmark on the current line, `F2` / `Shift+F2` go to the next and previous bookmark. Search > Bookmark can also clear or invert them and cut, copy or remove the bookmarked (or the other) lines in a single undo step. Bookmarks move with the text and are kept with the tab. +- Smart highlighting: select a whole word (for example with a double-click) and its other occurrences are highlighted. It can be turned off in Settings > Search. **Encoding and line endings** @@ -98,6 +102,8 @@ On macOS, use `Cmd` instead of `Ctrl`. All of these can be changed in Settings > | Find in files | `Ctrl+Alt+F` (`Cmd+Alt+Shift+F` on macOS) | | Next / previous search result | `F4` / `Shift+F4` | | Show or hide search results | `F7` | +| Toggle bookmark | `Ctrl+F2` | +| Next / previous bookmark | `F2` / `Shift+F2` | | Go to line, column or offset | `Ctrl+G` | | Go to matching brace | `Ctrl+B` | | Format | `Ctrl+Shift+F` | diff --git a/resources/Themes/Dark.css b/resources/Themes/Dark.css index f263093..903053e 100644 --- a/resources/Themes/Dark.css +++ b/resources/Themes/Dark.css @@ -27,6 +27,14 @@ --selection-bg: #264f78; --search-match-bg: rgba(209, 154, 42, 0.4); --search-scope-bg: rgba(30, 144, 255, 0.12); + --mark-found-bg: rgba(229, 83, 75, 0.35); + --mark-1-bg: rgba(57, 168, 176, 0.35); + --mark-2-bg: rgba(224, 130, 61, 0.35); + --mark-3-bg: rgba(198, 144, 38, 0.35); + --mark-4-bg: rgba(163, 113, 247, 0.35); + --mark-5-bg: rgba(219, 97, 162, 0.35); + --smart-highlight-bg: rgba(63, 164, 91, 0.25); + --bookmark-color: #4493f8; --active-line-bg: rgba(0, 0, 0, 0.48); --action-bg: rgb(57, 57, 57); diff --git a/resources/Themes/Light.css b/resources/Themes/Light.css index f863b24..798739f 100644 --- a/resources/Themes/Light.css +++ b/resources/Themes/Light.css @@ -27,6 +27,14 @@ --selection-bg: #add6ff; --search-match-bg: rgba(255, 196, 0, 0.45); --search-scope-bg: rgba(30, 144, 255, 0.1); + --mark-found-bg: rgba(207, 34, 46, 0.3); + --mark-1-bg: rgba(27, 124, 131, 0.3); + --mark-2-bg: rgba(188, 76, 0, 0.3); + --mark-3-bg: rgba(154, 103, 0, 0.3); + --mark-4-bg: rgba(130, 80, 223, 0.3); + --mark-5-bg: rgba(191, 57, 137, 0.3); + --smart-highlight-bg: rgba(26, 127, 55, 0.22); + --bookmark-color: #0969da; --active-line-bg: rgba(147, 147, 147, 0.28); --action-bg: rgb(220, 220, 220); diff --git a/resources/Themes/Matrix.css b/resources/Themes/Matrix.css index 600b7be..4e7f58d 100644 --- a/resources/Themes/Matrix.css +++ b/resources/Themes/Matrix.css @@ -27,6 +27,14 @@ --selection-bg: rgba(0, 255, 65, 0.22); --search-match-bg: rgba(212, 225, 87, 0.35); --search-scope-bg: rgba(0, 212, 58, 0.1); + --mark-found-bg: rgba(255, 79, 79, 0.35); + --mark-1-bg: rgba(61, 255, 216, 0.35); + --mark-2-bg: rgba(255, 165, 61, 0.35); + --mark-3-bg: rgba(230, 255, 61, 0.35); + --mark-4-bg: rgba(185, 139, 255, 0.35); + --mark-5-bg: rgba(255, 95, 210, 0.35); + --smart-highlight-bg: rgba(0, 255, 65, 0.25); + --bookmark-color: #3db4ff; --active-line-bg: rgba(93, 126, 76, 0.3); --action-bg: rgb(40, 43, 32); diff --git a/resources/Themes/Orange Dark.css b/resources/Themes/Orange Dark.css index 15d9601..10779de 100644 --- a/resources/Themes/Orange Dark.css +++ b/resources/Themes/Orange Dark.css @@ -27,6 +27,14 @@ --selection-bg: rgba(227, 119, 42, 0.3); --search-match-bg: rgba(242, 179, 61, 0.38); --search-scope-bg: rgba(227, 119, 42, 0.12); + --mark-found-bg: rgba(229, 83, 75, 0.35); + --mark-1-bg: rgba(79, 179, 165, 0.35); + --mark-2-bg: rgba(255, 140, 66, 0.35); + --mark-3-bg: rgba(242, 179, 61, 0.35); + --mark-4-bg: rgba(196, 140, 230, 0.35); + --mark-5-bg: rgba(224, 103, 154, 0.35); + --smart-highlight-bg: rgba(139, 191, 79, 0.25); + --bookmark-color: #5d9bd5; --active-line-bg: rgb(71, 58, 51); --action-bg: #5a2e0b; diff --git a/resources/Themes/Soft Light.css b/resources/Themes/Soft Light.css index 9f5401b..afc6df7 100644 --- a/resources/Themes/Soft Light.css +++ b/resources/Themes/Soft Light.css @@ -27,6 +27,14 @@ --selection-bg: #b5e3d8; --search-match-bg: rgba(232, 168, 0, 0.4); --search-scope-bg: rgba(31, 138, 120, 0.1); + --mark-found-bg: rgba(196, 49, 75, 0.3); + --mark-1-bg: rgba(19, 114, 100, 0.3); + --mark-2-bg: rgba(181, 76, 23, 0.3); + --mark-3-bg: rgba(138, 90, 0, 0.3); + --mark-4-bg: rgba(123, 82, 201, 0.3); + --mark-5-bg: rgba(176, 58, 122, 0.3); + --smart-highlight-bg: rgba(46, 125, 50, 0.22); + --bookmark-color: #165caf; --active-line-bg: rgba(147, 147, 147, 0.28); --action-bg: #b8d8d0; diff --git a/src/BaseTheme.css b/src/BaseTheme.css index 7860650..34b903a 100644 --- a/src/BaseTheme.css +++ b/src/BaseTheme.css @@ -69,6 +69,14 @@ --selection-bg: var(--action-bg); --search-match-bg: color-mix(in srgb, var(--warning-color) 40%, transparent); --search-scope-bg: color-mix(in srgb, var(--positive-color) 10%, transparent); + --mark-found-bg: color-mix(in srgb, var(--tag-1-color) 35%, transparent); + --mark-1-bg: color-mix(in srgb, var(--tag-5-color) 35%, transparent); + --mark-2-bg: color-mix(in srgb, var(--tag-2-color) 35%, transparent); + --mark-3-bg: color-mix(in srgb, var(--tag-3-color) 35%, transparent); + --mark-4-bg: color-mix(in srgb, var(--tag-7-color) 35%, transparent); + --mark-5-bg: color-mix(in srgb, var(--tag-8-color) 35%, transparent); + --smart-highlight-bg: color-mix(in srgb, var(--tag-4-color) 25%, transparent); + --bookmark-color: var(--positive-color); --scrollbar-thumb-hover-color: var(--scrollbar-thumb-color); --scrollbar-track-color: transparent; @@ -300,7 +308,7 @@ body .cet-menubar-menu-container .cet-action-menu-item { z-index: var(--z-tooltip) !important; } -.ace_marker-layer .searchMatch, .ace_marker-layer .searchScope { +.ace_marker-layer .searchMatch, .ace_marker-layer .searchScope, .ace_marker-layer .mark, .ace_marker-layer .smartHighlight { position: absolute; z-index: var(--z-editor-marker); } @@ -314,6 +322,50 @@ body .cet-menubar-menu-container .cet-action-menu-item { background-color: var(--search-scope-bg); } +.ace_marker-layer .mark, .ace_marker-layer .smartHighlight { + border-radius: var(--radius-sm); +} + +.ace_marker-layer .smartHighlight { + background-color: var(--smart-highlight-bg); +} + +.ace_marker-layer .mark.found { + background-color: var(--mark-found-bg); +} + +.ace_marker-layer .mark.style-1 { + background-color: var(--mark-1-bg); +} + +.ace_marker-layer .mark.style-2 { + background-color: var(--mark-2-bg); +} + +.ace_marker-layer .mark.style-3 { + background-color: var(--mark-3-bg); +} + +.ace_marker-layer .mark.style-4 { + background-color: var(--mark-4-bg); +} + +.ace_marker-layer .mark.style-5 { + background-color: var(--mark-5-bg); +} + +.ace_gutter-cell.bookmark::before { + content: ""; + position: absolute; + top: 50%; + left: var(--space-1); + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--bookmark-color); + transform: translateY(-50%); +} + .ace_line-hover { background-color: var(--selected-highlight-color) !important; } diff --git a/src/domain/Bookmarks.ts b/src/domain/Bookmarks.ts new file mode 100644 index 0000000..aedcfc7 --- /dev/null +++ b/src/domain/Bookmarks.ts @@ -0,0 +1,66 @@ +export interface LineChange { + action: "insert" | "remove", + start: {row: number, column: number}, + end: {row: number, column: number}, +} + +export function toggleRow(rows: Array, row: number): Array { + return rows.includes(row) ? rows.filter(r => r !== row) : sortRows([...rows, row]); +} + +export function findAdjacentRow(rows: Array, current: number, isBackwards: boolean): number | undefined { + if (rows.length === 0) return undefined; + + if (isBackwards) return [...rows].reverse().find(row => row < current) ?? rows[rows.length - 1]; + + return rows.find(row => row > current) ?? rows[0]; +} + +export function shiftRows(rows: Array, change: LineChange): Array { + const {start, end} = change; + const lineCount = end.row - start.row; + + if (lineCount === 0 || rows.length === 0) return rows; + + if (change.action === "insert") return rows.map(row => row > start.row || (row === start.row && start.column === 0) ? row + lineCount : row); + + const removesWholeLines = start.column === 0 && end.column === 0; + + return sortRows(rows.flatMap(row => { + if (row > end.row || (removesWholeLines && row === end.row)) return [row - lineCount]; + if (row < start.row || (!removesWholeLines && row === start.row)) return [row]; + + return removesWholeLines ? [] : [start.row]; + })); +} + +export function invertRows(rows: Array, lineCount: number): Array { + const marked = new Set(rows); + + return Array.from({length: lineCount}, (_, row) => row).filter(row => !marked.has(row)); +} + +export function pickLines(lines: Array, rows: Array, isMarked: boolean): Array { + const marked = new Set(rows); + + return lines.filter((line, row) => marked.has(row) === isMarked); +} + +export function groupConsecutiveRows(rows: Array): Array<[number, number]> { + return rows.reduce>((blocks, row) => { + const last = blocks[blocks.length - 1]; + + if (last && last[1] === row - 1) last[1] = row; + else blocks.push([row, row]); + + return blocks; + }, []); +} + +export function normalizeRows(raw: unknown): Array { + return Array.isArray(raw) ? sortRows(raw.filter((row): row is number => Number.isInteger(row) && row >= 0)) : []; +} + +function sortRows(rows: Array): Array { + return [...new Set(rows)].sort((a, b) => a - b); +} diff --git a/src/domain/EditorTab.ts b/src/domain/EditorTab.ts index 0322342..e6bceb5 100644 --- a/src/domain/EditorTab.ts +++ b/src/domain/EditorTab.ts @@ -1,5 +1,6 @@ import {SupportedLanguage} from "./SupportedLanguage"; import {EolType} from "./Encoding"; +import {MarkRule} from "./Marks"; export interface EditorTab { id?:string, @@ -21,5 +22,7 @@ export interface EditorTab { }, selection?: any, undoHistory?: any, + bookmarks?: Array, + marks?: Array, } } \ No newline at end of file diff --git a/src/domain/Marks.ts b/src/domain/Marks.ts new file mode 100644 index 0000000..bc22099 --- /dev/null +++ b/src/domain/Marks.ts @@ -0,0 +1,57 @@ +import {normalizeSearchOptions, SearchMode, SearchQuery} from "./Search"; + +export enum MarkStyle { + FOUND = "found", + STYLE_1 = "style-1", + STYLE_2 = "style-2", + STYLE_3 = "style-3", + STYLE_4 = "style-4", + STYLE_5 = "style-5", +} + +export interface MarkRule { + style: MarkStyle, + query: SearchQuery, +} + +export const TOKEN_STYLES = [MarkStyle.STYLE_1, MarkStyle.STYLE_2, MarkStyle.STYLE_3, MarkStyle.STYLE_4, MarkStyle.STYLE_5]; + +export function createTokenQuery(text: string): SearchQuery { + return {mode: SearchMode.NORMAL, matchCase: true, wholeWord: false, dotAll: false, wrap: true, text: text}; +} + +export function createSmartHighlightQuery(text: string): SearchQuery { + return {...createTokenQuery(text), matchCase: false, wholeWord: true}; +} + +export function addMarkRule(rules: Array, rule: MarkRule): Array { + const key = JSON.stringify(rule); + + return [...rules.filter(existing => JSON.stringify(existing) !== key), rule]; +} + +export function clearMarkStyles(rules: Array, styles: Array): Array { + return rules.filter(rule => !styles.includes(rule.style)); +} + +export function normalizeMarkRules(raw: unknown): Array { + if (!Array.isArray(raw)) return []; + + return raw + .filter(rule => Object.values(MarkStyle).includes(rule?.style) && typeof rule?.query?.text === "string" && rule.query.text !== "") + .map(rule => ({style: rule.style, query: {...normalizeSearchOptions(rule.query), text: rule.query.text}})); +} + +const WORD_CHARACTER = /[\p{L}\p{N}_]/u; +const WORD = /^[\p{L}\p{N}_]+$/u; + +export function readWholeWord(line: string, start: number, end: number): string | undefined { + const text = line.substring(start, end); + const isBoundary = (character: string | undefined) => !character || !WORD_CHARACTER.test(character); + + return WORD.test(text) && isBoundary(line[start - 1]) && isBoundary(line[end]) ? text : undefined; +} + +export function getMarkClassName(style: MarkStyle): string { + return "mark " + style; +} diff --git a/src/domain/Settings.ts b/src/domain/Settings.ts index 3ff131d..f266b8e 100644 --- a/src/domain/Settings.ts +++ b/src/domain/Settings.ts @@ -32,6 +32,7 @@ export interface FileSettings { export interface SearchSettings { webSearchUrl: string, + smartHighlight: boolean, } export interface Settings { @@ -67,6 +68,7 @@ export const DEFAULT_SETTINGS: Settings = { }, search: { webSearchUrl: DEFAULT_WEB_SEARCH_URL, + smartHighlight: true, }, keyBindings: {}, }; @@ -191,6 +193,7 @@ export function normalizeSettings(raw: any): Settings { }, search: { webSearchUrl: isWebSearchTemplate(search.webSearchUrl) ? search.webSearchUrl : defaults.search.webSearchUrl, + smartHighlight: toBoolean(search.smartHighlight, defaults.search.smartHighlight), }, keyBindings: keyBindings, }; diff --git a/src/domain/__tests__/Bookmarks.test.ts b/src/domain/__tests__/Bookmarks.test.ts new file mode 100644 index 0000000..92846b4 --- /dev/null +++ b/src/domain/__tests__/Bookmarks.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from "vitest"; +import {findAdjacentRow, groupConsecutiveRows, invertRows, normalizeRows, pickLines, shiftRows, toggleRow} from "../Bookmarks"; + +const change = (action: "insert" | "remove", startRow: number, startColumn: number, endRow: number, endColumn: number) => + ({action: action, start: {row: startRow, column: startColumn}, end: {row: endRow, column: endColumn}}); + +describe("Bookmarks", () => { + it("toggles rows and keeps them sorted", () => { + expect(toggleRow([5, 1], 3)).toEqual([1, 3, 5]); + expect(toggleRow([1, 3], 3)).toEqual([1]); + }); + + it("finds the next and previous bookmark and wraps", () => { + expect(findAdjacentRow([2, 8], 2, false)).toBe(8); + expect(findAdjacentRow([2, 8], 8, false)).toBe(2); + expect(findAdjacentRow([2, 8], 5, true)).toBe(2); + expect(findAdjacentRow([2, 8], 2, true)).toBe(8); + expect(findAdjacentRow([], 0, false)).toBeUndefined(); + }); + + it("moves bookmarks when lines are inserted", () => { + expect(shiftRows([1, 4, 6], change("insert", 4, 3, 6, 0))).toEqual([1, 4, 8]); + expect(shiftRows([1, 4, 6], change("insert", 4, 0, 5, 0))).toEqual([1, 5, 7]); + expect(shiftRows([1, 4], change("insert", 4, 2, 4, 5))).toEqual([1, 4]); + }); + + it("moves and merges bookmarks when text is removed", () => { + expect(shiftRows([1, 4, 5, 9], change("remove", 3, 2, 5, 1))).toEqual([1, 3, 7]); + expect(shiftRows([1, 4, 5, 9], change("remove", 4, 0, 6, 0))).toEqual([1, 7]); + expect(shiftRows([1, 4, 6], change("remove", 4, 0, 6, 0))).toEqual([1, 4]); + }); + + it("inverts, picks lines and normalizes stored rows", () => { + expect(invertRows([0, 2], 4)).toEqual([1, 3]); + expect(pickLines(["a", "b", "c"], [1], true)).toEqual(["b"]); + expect(pickLines(["a", "b", "c"], [1], false)).toEqual(["a", "c"]); + expect(normalizeRows([3, "x", -1, 1.5, 0, 3])).toEqual([0, 3]); + expect(normalizeRows(undefined)).toEqual([]); + }); + + it("groups consecutive rows into blocks", () => { + expect(groupConsecutiveRows([0, 1, 2, 5, 7, 8])).toEqual([[0, 2], [5, 5], [7, 8]]); + expect(groupConsecutiveRows([])).toEqual([]); + }); +}); diff --git a/src/domain/__tests__/Marks.test.ts b/src/domain/__tests__/Marks.test.ts new file mode 100644 index 0000000..1bdb153 --- /dev/null +++ b/src/domain/__tests__/Marks.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, it} from "vitest"; +import {addMarkRule, clearMarkStyles, createSmartHighlightQuery, createTokenQuery, MarkStyle, normalizeMarkRules, readWholeWord} from "../Marks"; +import {SearchMode} from "../Search"; + +describe("Marks", () => { + it("adds a rule once and clears styles", () => { + const rule = {style: MarkStyle.STYLE_1, query: createTokenQuery("foo")}; + const rules = addMarkRule(addMarkRule([rule], {style: MarkStyle.STYLE_2, query: createTokenQuery("bar")}), rule); + + expect(rules.map(r => r.query.text)).toEqual(["bar", "foo"]); + expect(clearMarkStyles(rules, [MarkStyle.STYLE_1])).toEqual([{style: MarkStyle.STYLE_2, query: createTokenQuery("bar")}]); + }); + + it("styles tokens literally and smart highlights whole words", () => { + expect(createTokenQuery("a.b")).toMatchObject({mode: SearchMode.NORMAL, matchCase: true, wholeWord: false}); + expect(createSmartHighlightQuery("word")).toMatchObject({matchCase: false, wholeWord: true, text: "word"}); + }); + + it("keeps only valid stored rules", () => { + expect(normalizeMarkRules([ + {style: "style-3", query: {text: "x", mode: "regex"}}, + {style: "style-9", query: {text: "x"}}, + {style: "found", query: {text: ""}}, + null, + ])).toEqual([{style: MarkStyle.STYLE_3, query: {...createTokenQuery("x"), mode: SearchMode.REGEX, matchCase: false}}]); + expect(normalizeMarkRules("x")).toEqual([]); + }); + + it("reads a selection only when it is a whole word", () => { + expect(readWholeWord("say şeker now", 4, 9)).toBe("şeker"); + expect(readWholeWord("say şeker now", 4, 7)).toBeUndefined(); + expect(readWholeWord("a b", 0, 3)).toBeUndefined(); + expect(readWholeWord("x_1", 0, 3)).toBe("x_1"); + }); +}); diff --git a/src/domain/__tests__/Settings.test.ts b/src/domain/__tests__/Settings.test.ts index 43244ee..cea03d9 100644 --- a/src/domain/__tests__/Settings.test.ts +++ b/src/domain/__tests__/Settings.test.ts @@ -29,6 +29,8 @@ describe("Settings", () => { it("keeps a valid web search address and drops invalid ones", () => { expect(normalizeSettings({search: {webSearchUrl: "https://duckduckgo.com/?q=%s"}}).search.webSearchUrl).toBe("https://duckduckgo.com/?q=%s"); expect(normalizeSettings({search: {webSearchUrl: "javascript:alert(%s)"}}).search).toEqual(DEFAULT_SETTINGS.search); + expect(normalizeSettings({search: {smartHighlight: false}}).search.smartHighlight).toBe(false); + expect(normalizeSettings({search: {smartHighlight: "no"}}).search.smartHighlight).toBe(true); }); it("passes the file options to detection", () => { diff --git a/src/enums/index.ts b/src/enums/index.ts index ba0c649..5403195 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -115,5 +115,28 @@ export enum EditorCommand { PREVIOUS_SEARCH_RESULT = "previous-search-result", TOGGLE_SEARCH_RESULTS = "toggle-search-results", FIND_IN_FILES = "find-in-files", + TOGGLE_BOOKMARK = "toggle-bookmark", + NEXT_BOOKMARK = "next-bookmark", + PREVIOUS_BOOKMARK = "previous-bookmark", + CLEAR_BOOKMARKS = "clear-bookmarks", + INVERSE_BOOKMARKS = "inverse-bookmarks", + CUT_BOOKMARKED_LINES = "cut-bookmarked-lines", + COPY_BOOKMARKED_LINES = "copy-bookmarked-lines", + REMOVE_BOOKMARKED_LINES = "remove-bookmarked-lines", + REMOVE_UNBOOKMARKED_LINES = "remove-unbookmarked-lines", + MARK_ALL = "mark-all", + BOOKMARK_MATCHING_LINES = "bookmark-matching-lines", + CLEAR_MARKS = "clear-marks", + STYLE_1 = "style-1", + STYLE_2 = "style-2", + STYLE_3 = "style-3", + STYLE_4 = "style-4", + STYLE_5 = "style-5", + CLEAR_STYLE_1 = "clear-style-1", + CLEAR_STYLE_2 = "clear-style-2", + CLEAR_STYLE_3 = "clear-style-3", + CLEAR_STYLE_4 = "clear-style-4", + CLEAR_STYLE_5 = "clear-style-5", + CLEAR_ALL_STYLES = "clear-all-styles", TOGGLE_PREVIEW = "toggle-preview", } diff --git a/src/ipc/ContextMenuHandlers.ts b/src/ipc/ContextMenuHandlers.ts index 776777c..144ec8c 100644 --- a/src/ipc/ContextMenuHandlers.ts +++ b/src/ipc/ContextMenuHandlers.ts @@ -4,7 +4,7 @@ import {isKnownEol} from "../domain/Encoding"; import {createEolMenu} from "../utils/EncodingMenu"; import {ShortcutId} from "../utils/Shortcuts"; import {getAccelerator} from "../utils/Accelerators"; -import {createSearchMenuItem} from "../utils/SearchMenu"; +import {CLEAR_STYLE_MENU, createSearchMenuItem, STYLE_MENU} from "../utils/SearchMenu"; import {popupMenu, send, Sender, sendPluginAction} from "../utils/WindowUtils"; import {ContextMenuGroup} from "../react/components/editor/plugins"; import {EditorMenuRequest, TabMenuRequest} from "../domain/IpcContract"; @@ -93,6 +93,13 @@ function createEditorMenu(sender: WebContents, data: EditorMenuRequest): Array send(sender, EventType.TAB_ACTION, {action: TabAction.REVEAL_IN_FOLDER})}, diff --git a/src/react/EditorSearch.ts b/src/react/EditorSearch.ts index 1444e7c..0371fa5 100644 --- a/src/react/EditorSearch.ts +++ b/src/react/EditorSearch.ts @@ -17,13 +17,18 @@ export interface MatchHighlight { remove: () => void, } -const BUILT_IN_SEARCH_COMMANDS = ["find", "replace", "replaceall", "gotoline", "findnext", "findprevious", "selectOrFindNext", "selectOrFindPrevious", "findAll", "jumptomatching", "selecttomatching"]; +export interface MatchLayer { + result: SearchResult, + className: string, +} + +const OVERRIDDEN_COMMANDS = ["find", "replace", "replaceall", "gotoline", "findnext", "findprevious", "selectOrFindNext", "selectOrFindPrevious", "findAll", "jumptomatching", "selecttomatching", "toggleFoldWidget"]; const MATCH_CLASS = "searchMatch"; const SCOPE_CLASS = "searchScope"; const CENTER = 0.5; -export function removeBuiltInSearchCommands(editor: Ace.Editor) { - BUILT_IN_SEARCH_COMMANDS.forEach(name => editor.commands.removeCommand(name)); +export function removeOverriddenCommands(editor: Ace.Editor) { + OVERRIDDEN_COMMANDS.forEach(name => editor.commands.removeCommand(name)); } export function readSearchDocument(session: Ace.EditSession): SearchDocument { @@ -106,28 +111,20 @@ export function addScopeMarker(session: Ace.EditSession, scope: Ace.Range): () = } export function addMatchHighlight(editor: Ace.Editor, getResult: () => SearchResult | undefined): MatchHighlight { + return addMatchLayers(editor, () => { + const result = getResult(); + + return result ? [{result: result, className: MATCH_CLASS}] : []; + }); +} + +export function addMatchLayers(editor: Ace.Editor, getLayers: () => Array): MatchHighlight { const session = editor.session; const marker = session.addDynamicMarker({ - clazz: MATCH_CLASS, + clazz: "", type: "text", update: (html: Array, markerLayer: any, markerSession: Ace.EditSession, config: any) => { - const result = getResult(); - - if (!result || result.matches.length === 0) return; - - const {document, matches} = result; - const firstVisible = positionToOffset(document.lineStarts, {row: config.firstRow, column: 0}); - const lastVisibleRow = Math.min(config.lastRow, document.lineStarts.length - 1); - const lastVisible = positionToOffset(document.lineStarts, {row: lastVisibleRow, column: markerSession.getLine(lastVisibleRow).length}); - - for (let i = findFirstAtOrAfter(matches, firstVisible); i < matches.length && matches[i].start <= lastVisible; i++) { - if (matches[i].start === matches[i].end) continue; - - const range = toRange(document, matches[i]).toScreenRange(markerSession); - - if (range.isMultiLine()) markerLayer.drawTextMarker(html, range, MATCH_CLASS, config); - else markerLayer.drawSingleLineMarker(html, range, MATCH_CLASS, config); - } + getLayers().forEach(layer => drawVisibleMatches(html, markerLayer, markerSession, config, layer)); }, }, false); @@ -138,3 +135,22 @@ export function addMatchHighlight(editor: Ace.Editor, getResult: () => SearchRes }, }; } + +function drawVisibleMatches(html: Array, markerLayer: any, session: Ace.EditSession, config: any, {result, className}: MatchLayer) { + const {document, matches} = result; + + if (matches.length === 0) return; + + const firstVisible = positionToOffset(document.lineStarts, {row: config.firstRow, column: 0}); + const lastVisibleRow = Math.min(config.lastRow, document.lineStarts.length - 1); + const lastVisible = positionToOffset(document.lineStarts, {row: lastVisibleRow, column: session.getLine(lastVisibleRow).length}); + + for (let i = findFirstAtOrAfter(matches, firstVisible); i < matches.length && matches[i].start <= lastVisible; i++) { + if (matches[i].start === matches[i].end) continue; + + const range = toRange(document, matches[i]).toScreenRange(session); + + if (range.isMultiLine()) markerLayer.drawTextMarker(html, range, className, config); + else markerLayer.drawSingleLineMarker(html, range, className, config); + } +} diff --git a/src/react/OpenDocuments.ts b/src/react/OpenDocuments.ts index 92d8b8e..287fd54 100644 --- a/src/react/OpenDocuments.ts +++ b/src/react/OpenDocuments.ts @@ -5,7 +5,8 @@ import {SearchQuery, SearchTarget} from "../domain/Search"; import {FolderSearch, isSearchedFile, parseFileFilters, relativePath} from "../domain/FindInFiles"; import {createSearchDocument, offsetToPosition, replaceMatches, SearchResult, searchText, splitLines} from "../utils/TextSearch"; import {DocumentHits, SearchRun} from "../utils/SearchResults"; -import {appendReplacement} from "../utils/UndoHistory"; +import {appendReplacement, createReplacementDeltas} from "../utils/UndoHistory"; +import {normalizeRows, shiftRows} from "../domain/Bookmarks"; import {refreshIsChanged} from "../utils/TabUtils"; import {searchSession, toRange} from "./EditorSearch"; @@ -101,11 +102,14 @@ async function replaceInTab(tab: EditorTab, query: SearchQuery, template: string if (!replaced) return 0; const text = result.document.text; + const start = offsetToPosition(result.document.lineStarts, replaced.start); + const removed = text.substring(replaced.start, replaced.end); tab.content = toEditorText(text.substring(0, replaced.start) + replaced.text + text.substring(replaced.end), tab.eol ?? EolType.LF); tab.state = { ...tab.state, - undoHistory: appendReplacement(tab.state?.undoHistory, offsetToPosition(result.document.lineStarts, replaced.start), text.substring(replaced.start, replaced.end), replaced.text), + undoHistory: appendReplacement(tab.state?.undoHistory, start, removed, replaced.text), + bookmarks: createReplacementDeltas(start, removed, replaced.text).reduce(shiftRows, normalizeRows(tab.state?.bookmarks)), }; await refreshIsChanged(tab); diff --git a/src/react/components/EditorContainer.tsx b/src/react/components/EditorContainer.tsx index 15368f9..81f269a 100644 --- a/src/react/components/EditorContainer.tsx +++ b/src/react/components/EditorContainer.tsx @@ -27,6 +27,7 @@ import {useActiveTabEditing} from "../hooks/useActiveTabEditing"; import {useEditorCommands} from "../hooks/useEditorCommands"; import {useEditorSearch} from "../hooks/useEditorSearch"; import {useSearchResults} from "../hooks/useSearchResults"; +import {useMarks} from "../hooks/useMarks"; import {readSearchTerm} from "../EditorSearch"; import {previewTool, toolbarItems} from "../../utils/PluginLoader"; import {refreshIsChanged} from "../../utils/TabUtils"; @@ -53,7 +54,8 @@ export default function EditorContainer() { const runEncodingAction = useActiveTabEditing(editorRef, updateEditorData); const results = useSearchResults(editorRef); - const search = useEditorSearch(editorRef, results.addRun); + const marks = useMarks(editorRef); + const search = useEditorSearch(editorRef, results.addRun, marks.markAll); useEditorCommands(editorRef, { [EditorCommand.TOGGLE_PREVIEW]: () => toggleTool(previewTool), @@ -70,6 +72,9 @@ export default function EditorContainer() { [EditorCommand.NEXT_SEARCH_RESULT]: results.nextHit, [EditorCommand.PREVIOUS_SEARCH_RESULT]: results.previousHit, [EditorCommand.TOGGLE_SEARCH_RESULTS]: results.toggle, + [EditorCommand.MARK_ALL]: () => search.markAll(false), + [EditorCommand.BOOKMARK_MATCHING_LINES]: () => search.markAll(true), + ...marks.commands, [EditorCommand.SEARCH_ON_WEB]: editor => fireEvent(EventType.SEARCH_ON_WEB, {text: readSearchTerm(editor).text}).catch(logError("cannot search on the web")), }); @@ -108,6 +113,7 @@ export default function EditorContainer() { editorRef.current = editor; search.onEditorLoad(editor); + marks.onEditorLoad(editor, activeTab); updateEditorData(); restoreEditorState(editor, activeTab); diff --git a/src/react/components/SearchBar.tsx b/src/react/components/SearchBar.tsx index bd970bf..582a2e8 100644 --- a/src/react/components/SearchBar.tsx +++ b/src/react/components/SearchBar.tsx @@ -86,6 +86,7 @@ export default function SearchBar({search}: {search: EditorSearch}) { + {search.target === SearchTarget.FOLDER && } diff --git a/src/react/components/SettingsDialog.tsx b/src/react/components/SettingsDialog.tsx index 2dcfe34..a4c557e 100644 --- a/src/react/components/SettingsDialog.tsx +++ b/src/react/components/SettingsDialog.tsx @@ -347,6 +347,11 @@ export default function SettingsDialog({onClose}: {onClose: () => void}) { onChange={e => setSearch({webSearchUrl: e.target.value})}/>
An http or https address. %s is replaced with the selected text, for example https://duckduckgo.com/?q=%s
+

Highlighting

+ } diff --git a/src/react/components/editor/Editor.tsx b/src/react/components/editor/Editor.tsx index 474ae48..eb4e3be 100644 --- a/src/react/components/editor/Editor.tsx +++ b/src/react/components/editor/Editor.tsx @@ -113,7 +113,7 @@ export default function Editor({content, language, newLineMode, onEditorLoad, ch animatedScroll: !isLargeContent, highlightActiveLine: true, highlightGutterLine: true, - highlightSelectedWord: !isLargeContent, + highlightSelectedWord: false, newLineMode: newLineMode, showPrintMargin: false, useWorker: false, diff --git a/src/react/hooks/useEditorSearch.ts b/src/react/hooks/useEditorSearch.ts index 5816cc3..f33f09e 100644 --- a/src/react/hooks/useEditorSearch.ts +++ b/src/react/hooks/useEditorSearch.ts @@ -11,7 +11,7 @@ import { createAnchoredRange, MatchHighlight, readSearchTerm, - removeBuiltInSearchCommands, + removeOverriddenCommands, searchSession, selectRange, toRange, @@ -42,6 +42,7 @@ const MESSAGES = { CHOOSE_FOLDER: "Choose a folder to search in first", replaced: ({count, documentCount}: ReplaceSummary, target: SearchTarget) => `Replaced ${countOf(count, "match", "matches")}` + (target === SearchTarget.OPEN_DOCUMENTS ? ` in ${countOf(documentCount, "document")}` : ""), + marked: (count: number) => `Marked ${countOf(count, "match", "matches")}`, found: ({hitCount, documents}: SearchRun) => `Found ${countOf(hitCount, "match", "matches")} in ${countOf(documents.length, "document")}`, folderResult: (verb: string, {count, fileCount, isCancelled, error}: FolderSearchResult) => error ?? `${isCancelled ? "Stopped. " : ""}${verb} ${countOf(count, "match", "matches")} in ${countOf(fileCount, "file")}`, @@ -51,7 +52,7 @@ const MESSAGES = { const debounceRefresh = createDebouncer(REFRESH_DELAY); -export function useEditorSearch(editorRef: MutableRefObject, onFindAll: (run: SearchRun) => void) { +export function useEditorSearch(editorRef: MutableRefObject, onFindAll: (run: SearchRun) => void, onMarkAll: (query: SearchQuery, withBookmarks: boolean) => SearchResult | undefined) { const {tabs, activeTab, setTabs} = useEditorContext(); const [isOpen, setIsOpen] = useState(false); const [isReplace, setIsReplace] = useState(false); @@ -117,7 +118,7 @@ export function useEditorSearch(editorRef: MutableRefObject { - removeBuiltInSearchCommands(editor); + removeOverriddenCommands(editor); removeScopeMarker.current = undefined; setScope(undefined); @@ -319,21 +320,32 @@ export function useEditorSearch(editorRef: MutableRefObject { + const term = text || readSearchTerm(editor).text; + + if (!term) { + open(isReplace); + return undefined; + } + + setText(term); + rememberFind(term); + + return {...options, text: term}; + }; + const findAll = (searchTarget: SearchTarget = target) => { const editor = editorRef.current; const source = editor && createSource(editor, searchTarget); - const term = editor && (text || readSearchTerm(editor).text); + const searchQuery = source && resolveQuery(source.editor); - if (!source) return; - if (!term) return open(isReplace); + if (!source || !searchQuery) return; - setText(term); setTarget(searchTarget); - rememberFind(term); - if (searchTarget === SearchTarget.FOLDER) return findAllInFolder(source, {...options, text: term}); + if (searchTarget === SearchTarget.FOLDER) return findAllInFolder(source, searchQuery); - const run = findInDocuments(source, {...options, text: term}, searchTarget); + const run = findInDocuments(source, searchQuery, searchTarget); if ("error" in run) return showMessage(run.error); @@ -341,6 +353,17 @@ export function useEditorSearch(editorRef: MutableRefObject { + const editor = editorRef.current; + const searchQuery = editor && resolveQuery(editor); + const result = searchQuery && onMarkAll(searchQuery, withBookmarks); + + if (!result) return; + if (!result.regex) return showMessage(result.error); + + showMessage(result.matches.length > 0 ? MESSAGES.marked(result.matches.length) : MESSAGES.NO_RESULTS); + }; + const findAllInFolder = (source: SearchSource, searchQuery: SearchQuery) => { if (!folder.directory) return showMessage(MESSAGES.CHOOSE_FOLDER); @@ -447,6 +470,7 @@ export function useEditorSearch(editorRef: MutableRefObject; + +const BOOKMARK_CLASS = "bookmark"; +const SMART_HIGHLIGHT_CLASS = "smartHighlight"; +const STYLE_COMMANDS: Array<{apply: EditorCommand, clear: EditorCommand, style: MarkStyle}> = [ + {apply: EditorCommand.STYLE_1, clear: EditorCommand.CLEAR_STYLE_1, style: MarkStyle.STYLE_1}, + {apply: EditorCommand.STYLE_2, clear: EditorCommand.CLEAR_STYLE_2, style: MarkStyle.STYLE_2}, + {apply: EditorCommand.STYLE_3, clear: EditorCommand.CLEAR_STYLE_3, style: MarkStyle.STYLE_3}, + {apply: EditorCommand.STYLE_4, clear: EditorCommand.CLEAR_STYLE_4, style: MarkStyle.STYLE_4}, + {apply: EditorCommand.STYLE_5, clear: EditorCommand.CLEAR_STYLE_5, style: MarkStyle.STYLE_5}, +]; + +export function useMarks(editorRef: MutableRefObject) { + const {settings} = useEditorContext(); + + const tab = useRef(); + const rules = useRef>([]); + const bookmarks = useRef>([]); + const smartHighlight = useRef(); + const results = useRef(new Map()); + const isSmartHighlightOn = useRef(settings.search.smartHighlight); + + isSmartHighlightOn.current = settings.search.smartHighlight; + + useEffect(() => { + editorRef.current?.renderer.updateBackMarkers(); + }, [settings.search.smartHighlight]); + + const saveState = (changes: Partial) => { + if (!tab.current) return; + + markStateChanged(); + tab.current.state = {...tab.current.state, ...changes}; + }; + + const readLayers = (session: Ace.EditSession): Array => { + const previous = results.current; + const next = new Map(); + + const layerOf = (query: SearchQuery, className: string): MatchLayer => { + const key = JSON.stringify(query); + const result = previous.get(key) ?? next.get(key) ?? searchSession(session, query); + + next.set(key, result); + + return {result: result, className: className}; + }; + + const smart = isSmartHighlightOn.current && smartHighlight.current ? [layerOf(smartHighlight.current, SMART_HIGHLIGHT_CLASS)] : []; + const marked = rules.current.map(rule => layerOf(rule.query, getMarkClassName(rule.style))); + + results.current = next; + + return [...smart, ...marked]; + }; + + const showBookmarks = (session: Ace.EditSession, rows: Array) => { + bookmarks.current.forEach(row => session.removeGutterDecoration(row, BOOKMARK_CLASS)); + bookmarks.current = rows; + rows.forEach(row => session.addGutterDecoration(row, BOOKMARK_CLASS)); + }; + + const setBookmarks = (session: Ace.EditSession, rows: Array) => { + showBookmarks(session, rows); + saveState({bookmarks: rows}); + }; + + const setRules = (editor: Ace.Editor, next: Array) => { + rules.current = next; + saveState({marks: next}); + editor.renderer.updateBackMarkers(); + }; + + const refreshSmartHighlight = (editor: Ace.Editor) => { + const range = editor.getSelectionRange(); + const word = range.isMultiLine() ? undefined : readWholeWord(editor.session.getLine(range.start.row), range.start.column, range.end.column); + + if (word === smartHighlight.current?.text) return; + + smartHighlight.current = word ? createSmartHighlightQuery(word) : undefined; + editor.renderer.updateBackMarkers(); + }; + + const onEditorLoad = (editor: Ace.Editor, loadedTab: EditorTab) => { + const session = editor.session; + + tab.current = loadedTab; + results.current = new Map(); + smartHighlight.current = undefined; + rules.current = normalizeMarkRules(loadedTab.state?.marks); + bookmarks.current = []; + + showBookmarks(session, normalizeRows(loadedTab.state?.bookmarks).filter(row => row < session.getLength())); + + session.on("change", delta => { + const shifted = shiftRows(bookmarks.current, delta); + + results.current = new Map(); + + if (shifted !== bookmarks.current) setBookmarks(session, shifted); + }); + editor.on("changeSelection", () => refreshSmartHighlight(editor)); + + addMatchLayers(editor, () => readLayers(session)); + }; + + const goToBookmark = (editor: Ace.Editor, isBackwards: boolean) => { + const row = findAdjacentRow(bookmarks.current, editor.getCursorPosition().row, isBackwards); + + if (row !== undefined) goToTarget(editor, {line: row + 1, column: 1}); + }; + + const copyBookmarkedLines = (editor: Ace.Editor) => { + const document = editor.session.getDocument(); + const newLine = document.getNewLineCharacter(); + const text = pickLines(document.getAllLines(), bookmarks.current, true).map(line => line + newLine).join(""); + + navigator.clipboard.writeText(text).catch(logError("cannot copy the bookmarked lines")); + }; + + const removeLines = (editor: Ace.Editor, isBookmarked: boolean) => { + const session = editor.session; + const rows = isBookmarked ? bookmarks.current : invertRows(bookmarks.current, session.getLength()); + + session.markUndoGroup(); + groupConsecutiveRows(rows).reverse().forEach(([first, last]) => session.getDocument().removeFullLines(first, last)); + session.markUndoGroup(); + }; + + const markAll = (query: SearchQuery, withBookmarks: boolean): SearchResult | undefined => { + const editor = editorRef.current; + + if (!editor) return undefined; + + const result = searchSession(editor.session, query); + + if (!result.regex) return result; + + const remaining = clearMarkStyles(rules.current, [MarkStyle.FOUND]); + + setRules(editor, result.matches.length > 0 ? addMarkRule(remaining, {style: MarkStyle.FOUND, query: query}) : remaining); + + if (withBookmarks && result.matches.length > 0) { + const matchedRows = result.matches.map(match => offsetToPosition(result.document.lineStarts, match.start).row); + + setBookmarks(editor.session, normalizeRows([...bookmarks.current, ...matchedRows])); + } + + return result; + }; + + const styleToken = (editor: Ace.Editor, style: MarkStyle) => { + const text = readSearchTerm(editor).text; + + if (text) setRules(editor, addMarkRule(rules.current, {style: style, query: createTokenQuery(text)})); + }; + + const commands: EditorCommandHandlers = { + [EditorCommand.TOGGLE_BOOKMARK]: editor => setBookmarks(editor.session, toggleRow(bookmarks.current, editor.getCursorPosition().row)), + [EditorCommand.NEXT_BOOKMARK]: editor => goToBookmark(editor, false), + [EditorCommand.PREVIOUS_BOOKMARK]: editor => goToBookmark(editor, true), + [EditorCommand.CLEAR_BOOKMARKS]: editor => setBookmarks(editor.session, []), + [EditorCommand.INVERSE_BOOKMARKS]: editor => setBookmarks(editor.session, invertRows(bookmarks.current, editor.session.getLength())), + [EditorCommand.COPY_BOOKMARKED_LINES]: copyBookmarkedLines, + [EditorCommand.CUT_BOOKMARKED_LINES]: editor => { + copyBookmarkedLines(editor); + removeLines(editor, true); + }, + [EditorCommand.REMOVE_BOOKMARKED_LINES]: editor => removeLines(editor, true), + [EditorCommand.REMOVE_UNBOOKMARKED_LINES]: editor => removeLines(editor, false), + [EditorCommand.CLEAR_MARKS]: editor => setRules(editor, clearMarkStyles(rules.current, [MarkStyle.FOUND])), + [EditorCommand.CLEAR_ALL_STYLES]: editor => setRules(editor, clearMarkStyles(rules.current, TOKEN_STYLES)), + ...Object.fromEntries(STYLE_COMMANDS.flatMap(({apply, clear, style}) => [ + [apply, (editor: Ace.Editor) => styleToken(editor, style)], + [clear, (editor: Ace.Editor) => setRules(editor, clearMarkStyles(rules.current, [style]))], + ])), + }; + + return { + onEditorLoad: onEditorLoad, + markAll: markAll, + commands: commands, + }; +} + +export type EditorMarks = ReturnType; diff --git a/src/utils/SearchMenu.ts b/src/utils/SearchMenu.ts index 02ee5d1..b9b37dd 100644 --- a/src/utils/SearchMenu.ts +++ b/src/utils/SearchMenu.ts @@ -21,26 +21,82 @@ const SEARCH_ITEMS: Partial>; +type SearchMenuEntry = EditorCommand | {label: string, groups: MenuGroups}; + +export const BOOKMARK_MENU: SearchMenuEntry = { + label: "Bookmark", + groups: [ + [EditorCommand.TOGGLE_BOOKMARK, EditorCommand.NEXT_BOOKMARK, EditorCommand.PREVIOUS_BOOKMARK], + [EditorCommand.CLEAR_BOOKMARKS, EditorCommand.INVERSE_BOOKMARKS], + [EditorCommand.CUT_BOOKMARKED_LINES, EditorCommand.COPY_BOOKMARKED_LINES, EditorCommand.REMOVE_BOOKMARKED_LINES, EditorCommand.REMOVE_UNBOOKMARKED_LINES], + ], +}; + +export const STYLE_MENU: SearchMenuEntry = { + label: "Style Token", + groups: [[EditorCommand.STYLE_1, EditorCommand.STYLE_2, EditorCommand.STYLE_3, EditorCommand.STYLE_4, EditorCommand.STYLE_5]], +}; + +export const CLEAR_STYLE_MENU: SearchMenuEntry = { + label: "Clear Style", + groups: [ + [EditorCommand.CLEAR_STYLE_1, EditorCommand.CLEAR_STYLE_2, EditorCommand.CLEAR_STYLE_3, EditorCommand.CLEAR_STYLE_4, EditorCommand.CLEAR_STYLE_5], + [EditorCommand.CLEAR_ALL_STYLES], + ], +}; + +const SEARCH_MENU_GROUPS: MenuGroups = [ [EditorCommand.FIND, EditorCommand.REPLACE, EditorCommand.FIND_IN_FILES], [EditorCommand.FIND_NEXT, EditorCommand.FIND_PREVIOUS, EditorCommand.SELECT_AND_FIND_NEXT, EditorCommand.SELECT_AND_FIND_PREVIOUS], [EditorCommand.FIND_ALL_IN_DOCUMENT, EditorCommand.FIND_ALL_IN_OPEN_DOCUMENTS], [EditorCommand.NEXT_SEARCH_RESULT, EditorCommand.PREVIOUS_SEARCH_RESULT, EditorCommand.TOGGLE_SEARCH_RESULTS], + [EditorCommand.MARK_ALL, EditorCommand.BOOKMARK_MATCHING_LINES, EditorCommand.CLEAR_MARKS, STYLE_MENU, CLEAR_STYLE_MENU], + [BOOKMARK_MENU], [EditorCommand.GO_TO, EditorCommand.GO_TO_MATCHING_BRACE], [EditorCommand.SEARCH_ON_WEB], ]; -export function createSearchMenuItem(command: EditorCommand, run: CommandRunner, enabled = true): MenuItem { - const item = SEARCH_ITEMS[command]; +export function createSearchMenuItem(entry: SearchMenuEntry, run: CommandRunner, enabled = true): MenuItem { + if (typeof entry === "object") return {label: entry.label, enabled: enabled, submenu: createMenuGroups(entry.groups, run)}; + + const item = SEARCH_ITEMS[entry]; - return {label: item?.label, accelerator: item && getAccelerator(item.shortcut), enabled: enabled, click: () => run(command)}; + return {label: item?.label, accelerator: item && getAccelerator(item.shortcut), enabled: enabled, click: () => run(entry)}; } export function createSearchMenu(run: CommandRunner): Array { - return SEARCH_MENU_GROUPS.flatMap((group, index) => [ + return createMenuGroups(SEARCH_MENU_GROUPS, run); +} + +function createMenuGroups(groups: MenuGroups, run: CommandRunner): Array { + return groups.flatMap((group, index) => [ ...(index > 0 ? [{type: "separator"} as MenuItem] : []), - ...group.map(command => createSearchMenuItem(command, run)), + ...group.map(entry => createSearchMenuItem(entry, run)), ]); } diff --git a/src/utils/Shortcuts.ts b/src/utils/Shortcuts.ts index 8c003b6..5749381 100644 --- a/src/utils/Shortcuts.ts +++ b/src/utils/Shortcuts.ts @@ -28,6 +28,29 @@ export enum ShortcutId { PREVIOUS_SEARCH_RESULT = "search.previousResult", TOGGLE_SEARCH_RESULTS = "search.toggleResults", FIND_IN_FILES = "search.findInFiles", + TOGGLE_BOOKMARK = "bookmark.toggle", + NEXT_BOOKMARK = "bookmark.next", + PREVIOUS_BOOKMARK = "bookmark.previous", + CLEAR_BOOKMARKS = "bookmark.clear", + INVERSE_BOOKMARKS = "bookmark.inverse", + CUT_BOOKMARKED_LINES = "bookmark.cutLines", + COPY_BOOKMARKED_LINES = "bookmark.copyLines", + REMOVE_BOOKMARKED_LINES = "bookmark.removeLines", + REMOVE_UNBOOKMARKED_LINES = "bookmark.removeOtherLines", + MARK_ALL = "mark.all", + BOOKMARK_MATCHING_LINES = "mark.bookmarkLines", + CLEAR_MARKS = "mark.clear", + STYLE_1 = "style.apply1", + STYLE_2 = "style.apply2", + STYLE_3 = "style.apply3", + STYLE_4 = "style.apply4", + STYLE_5 = "style.apply5", + CLEAR_STYLE_1 = "style.clear1", + CLEAR_STYLE_2 = "style.clear2", + CLEAR_STYLE_3 = "style.clear3", + CLEAR_STYLE_4 = "style.clear4", + CLEAR_STYLE_5 = "style.clear5", + CLEAR_ALL_STYLES = "style.clearAll", SETTINGS = "preferences.settings", } @@ -54,6 +77,29 @@ const BUILT_IN_SHORTCUTS: Array = [ {id: ShortcutId.PREVIOUS_SEARCH_RESULT, label: "Search > Previous Search Result", defaultAccelerator: "Shift+F4"}, {id: ShortcutId.TOGGLE_SEARCH_RESULTS, label: "Search > Search Results", defaultAccelerator: "F7"}, {id: ShortcutId.FIND_IN_FILES, label: "Search > Find in Files", defaultAccelerator: IS_MAC ? "Cmd+Alt+Shift+F" : "Ctrl+Alt+F"}, + {id: ShortcutId.TOGGLE_BOOKMARK, label: "Search > Bookmark > Toggle Bookmark", defaultAccelerator: "CmdOrCtrl+F2"}, + {id: ShortcutId.NEXT_BOOKMARK, label: "Search > Bookmark > Next Bookmark", defaultAccelerator: "F2"}, + {id: ShortcutId.PREVIOUS_BOOKMARK, label: "Search > Bookmark > Previous Bookmark", defaultAccelerator: "Shift+F2"}, + {id: ShortcutId.CLEAR_BOOKMARKS, label: "Search > Bookmark > Clear All Bookmarks"}, + {id: ShortcutId.INVERSE_BOOKMARKS, label: "Search > Bookmark > Inverse Bookmarks"}, + {id: ShortcutId.CUT_BOOKMARKED_LINES, label: "Search > Bookmark > Cut Bookmarked Lines"}, + {id: ShortcutId.COPY_BOOKMARKED_LINES, label: "Search > Bookmark > Copy Bookmarked Lines"}, + {id: ShortcutId.REMOVE_BOOKMARKED_LINES, label: "Search > Bookmark > Remove Bookmarked Lines"}, + {id: ShortcutId.REMOVE_UNBOOKMARKED_LINES, label: "Search > Bookmark > Remove Unbookmarked Lines"}, + {id: ShortcutId.MARK_ALL, label: "Search > Mark All"}, + {id: ShortcutId.BOOKMARK_MATCHING_LINES, label: "Search > Bookmark Matching Lines"}, + {id: ShortcutId.CLEAR_MARKS, label: "Search > Clear Marks"}, + {id: ShortcutId.STYLE_1, label: "Search > Style Token > Style 1"}, + {id: ShortcutId.STYLE_2, label: "Search > Style Token > Style 2"}, + {id: ShortcutId.STYLE_3, label: "Search > Style Token > Style 3"}, + {id: ShortcutId.STYLE_4, label: "Search > Style Token > Style 4"}, + {id: ShortcutId.STYLE_5, label: "Search > Style Token > Style 5"}, + {id: ShortcutId.CLEAR_STYLE_1, label: "Search > Clear Style > Style 1"}, + {id: ShortcutId.CLEAR_STYLE_2, label: "Search > Clear Style > Style 2"}, + {id: ShortcutId.CLEAR_STYLE_3, label: "Search > Clear Style > Style 3"}, + {id: ShortcutId.CLEAR_STYLE_4, label: "Search > Clear Style > Style 4"}, + {id: ShortcutId.CLEAR_STYLE_5, label: "Search > Clear Style > Style 5"}, + {id: ShortcutId.CLEAR_ALL_STYLES, label: "Search > Clear Style > All Styles"}, {id: ShortcutId.SETTINGS, label: "Preferences > Settings", defaultAccelerator: "CmdOrCtrl+,"}, ]; diff --git a/src/utils/UndoHistory.ts b/src/utils/UndoHistory.ts index 8628cbd..fdd4f03 100644 --- a/src/utils/UndoHistory.ts +++ b/src/utils/UndoHistory.ts @@ -15,19 +15,25 @@ export interface UndoHistory { export function appendReplacement(history: unknown, start: TextPosition, removed: string, inserted: string): UndoHistory { const undoStack = readUndoStack(history); - const removedLines = splitLines(removed); - const insertedLines = splitLines(inserted); + const [removal, insertion] = createReplacementDeltas(start, removed, inserted); const id = undoStack.flat().reduce((max, delta) => Math.max(max, delta.id ?? 0), 0) + 1; return { - $undoStack: [...undoStack, [ - {action: "remove", start: start, end: endOf(start, removedLines), lines: removedLines, id: id}, - {action: "insert", start: start, end: endOf(start, insertedLines), lines: insertedLines}, - ]], + $undoStack: [...undoStack, [{...removal, id: id}, insertion]], $redoStack: [], }; } +export function createReplacementDeltas(start: TextPosition, removed: string, inserted: string): [UndoDelta, UndoDelta] { + const removedLines = splitLines(removed); + const insertedLines = splitLines(inserted); + + return [ + {action: "remove", start: start, end: endOf(start, removedLines), lines: removedLines}, + {action: "insert", start: start, end: endOf(start, insertedLines), lines: insertedLines}, + ]; +} + function readUndoStack(history: unknown): Array> { const stack = (history as Partial | undefined)?.$undoStack; From 3ae88ea4170f311ade66d52ec143b6ff577c391d Mon Sep 17 00:00:00 2001 From: hasanyildiz Date: Wed, 28 Oct 2026 18:15:45 +0000 Subject: [PATCH 2/2] Bump version to 1.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40ac1fb..f63a87b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "note-manager", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "note-manager", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "workspaces": [ "tools/shims/glob", diff --git a/package.json b/package.json index 9f24160..a8f966f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "note-manager", "productName": "Note Manager", - "version": "1.4.0", + "version": "1.5.0", "description": "My Electron application description", "main": ".webpack/main/index.cjs", "scripts": {