diff --git a/README.md b/README.md index 365b895..0f1dd11 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ Press `Ctrl+/` (`⌘/` on Mac) for the full list, grouped by topic. Every button |---|---|---| | New note | `Alt+N` | `⌥N` | | Open Sprints | `Alt+S` | `⌥S` | -| Search notes | `Ctrl+F` | `⌘F` | +| Find in the note (Enter / Shift+Enter: next / previous, Esc: done) | `Ctrl+F` | `⌘F` | +| Search notes; the note you open shows the matches | `Ctrl+Shift+F` | `⇧⌘F` | | Go to the notes list / the note | `Alt+1` / `Alt+3` | `⌥1` / `⌥3` | | Text styles menu | `Alt+A` | `⌥A` | | Checklist | `Ctrl+Shift+L` | `⇧⌘L` | diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 08e219e..868ae56 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notelet" -version = "0.9.0" +version = "0.10.0" description = "Lightweight notes with sprint tracking" edition = "2021" publish = false diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index b69a338..648198c 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Notelet", - "version": "0.9.0", + "version": "0.10.0", "identifier": "com.ttncode.notelet", "build": { "frontendDist": "../extension" diff --git a/extension/app.js b/extension/app.js index 3147143..9d5f189 100644 --- a/extension/app.js +++ b/extension/app.js @@ -5,6 +5,7 @@ import { mergeTrackers, newBoardNote } from "./board-merge.js"; import { boardSearchText, findSprint, findTask, mapTasks, removeSprint, updateSprint } from "./board.js"; import { createBoardView } from "./board-view.js"; import { NoteEditor } from "./editor.js"; +import { createFind } from "./find.js"; import { formatEditedDate } from "./format.js"; import { applyShortcutTitles, matchHotkey, setCustomKeys } from "./hotkeys.js"; import { setupLayout } from "./layout.js"; @@ -48,6 +49,7 @@ const written = new Map(); const saves = createSaveScheduler({ delayMs: SAVE_DELAY_MS, maxWaitMs: SAVE_MAX_WAIT_MS, save: saveNow }); const state = { currentId: null, mode: "notes", query: "", ui: null, settings: null }; let editor; +let find; let taskEditor; let layout; let boardView; @@ -84,6 +86,7 @@ async function main() { showTheme(state.ui.theme); await removeStaleNotes(); editor = new NoteEditor({ element: byId("editor"), isMac: IS_MAC, onChange: onEditorChange }); + find = createFind({ bar: byId("find-bar"), editorElement: byId("editor"), scroller: byId("editor-scroll") }); layout = setupLayout({ resizer: byId("resizer"), ui: state.ui, onUiChange: saveUiSoon }); const taskNote = byId("task-note"); taskEditor = new NoteEditor({ element: taskNote, isMac: IS_MAC, onChange: onTaskNoteChange }); @@ -208,6 +211,7 @@ function selectNote(id, { scrollTop = 0, reveal = true } = {}) { loadEditor(notes.get(id)); renderAll(); byId("editor-scroll").scrollTop = scrollTop; + find.showSearchMatches(state.query); if (reveal) layout.showEditor(); saveUiSoon({ lastNoteId: id, scrollTop }); navigation.replace(); @@ -282,9 +286,14 @@ function renderEditorPane({ whenIdle = false } = {}) { document.body.classList.toggle("has-note", Boolean(note)); document.body.classList.toggle("viewing-deleted", inDeleted); editor.setReadOnly(inDeleted || !note); + if (isPlainNote(note)) find.refresh(); + else find.close(); renderNoteDetails(note, { whenIdle }); } +// The Sprints note shows its board, which find does not search. +const isPlainNote = (note) => Boolean(note) && !note.board; + function renderNoteDetails(note, { whenIdle = false } = {}) { const board = note?.board ?? null; document.body.classList.toggle("board-view", board !== null); @@ -413,6 +422,7 @@ function openCreated(note) { function clearSearch() { state.query = ""; byId("search").value = ""; + find.clearSearchMatches(); } function togglePin() { @@ -653,6 +663,7 @@ function wireSidebar() { wireNoteDragging(); byId("search").addEventListener("input", (event) => { state.query = event.target.value.trim().toLowerCase(); + find.clearSearchMatches(); renderList(); }); } @@ -749,6 +760,10 @@ function clearDropMarks() { // Each returns false when it does not apply here, so the key keeps its normal meaning. const APP_HOTKEYS = { + findInNote: () => isPlainNote(notes.get(state.currentId)) && openNote(() => { + layout.showEditor(); + find.open(); + }), search: () => showListFrom(() => { layout.showList(); byId("search").focus(); diff --git a/extension/find.js b/extension/find.js new file mode 100644 index 0000000..564317a --- /dev/null +++ b/extension/find.js @@ -0,0 +1,214 @@ +import { LINE_SELECTOR } from "./lines.js"; + +const LOZENGE_PADDING_PX = 3; +const BOUNCE_DURATION_MS = 900; +// Grow, settle, hold, then fade, like the macOS find indicator. +const BOUNCE_KEYFRAMES = [ + { transform: "scale(1)", opacity: 1 }, + { transform: "scale(1.25)", opacity: 1, offset: 0.15 }, + { transform: "scale(1)", opacity: 1, offset: 0.35 }, + { transform: "scale(1)", opacity: 1, offset: 0.75 }, + { transform: "scale(1)", opacity: 0 }, +]; +const REDUCED_MOTION = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)"); + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const TEXT_NODE = 3; + +const textNodes = (node) => (node.nodeType === TEXT_NODE ? [node] : [...node.childNodes].flatMap(textNodes)); + +/** + * Every case-insensitive occurrence of `query` in the text under `root`, in document order. + * A match never crosses from one line into the next. Each is `{ start, end }`, both + * `{ node, offset }` positions in text nodes, ready for a Range. + */ +export function findMatches(root, query) { + if (query === "") return []; + const pattern = new RegExp(escapeRegExp(query), "giu"); + return lineRuns(root).flatMap((run) => matchesInRun(run, pattern)); +} + +// Consecutive text nodes on the same line, so a match can span bold or a link but not a line break. +function lineRuns(root) { + return textNodes(root).reduce((runs, node) => { + const line = node.parentElement.closest(LINE_SELECTOR); + const last = runs.at(-1); + if (last?.line === line) last.nodes.push(node); + else runs.push({ line, nodes: [node] }); + return runs; + }, []); +} + +function matchesInRun(run, pattern) { + const text = run.nodes.map((node) => node.data).join(""); + return [...text.matchAll(pattern)].map((match) => ({ + start: locate(run.nodes, { index: match.index, atEnd: false }), + end: locate(run.nodes, { index: match.index + match[0].length, atEnd: true }), + })); +} + +// An index on the boundary of two nodes is the end of the first or the start of the second. +function locate(nodes, { index, atEnd }) { + let nodeStart = 0; + for (const node of nodes) { + const nodeEnd = nodeStart + node.data.length; + if (atEnd ? index <= nodeEnd : index < nodeEnd) return { node, offset: index - nodeStart }; + nodeStart = nodeEnd; + } + const last = nodes.at(-1); + return { node: last, offset: last.data.length }; +} + +function toRange({ start, end }) { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + return range; +} + +// The CSS Custom Highlight API paints matches without touching the note's HTML, so saving and undo never see them. +function paint(name, ranges) { + if (!globalThis.CSS?.highlights) return; + if (ranges.length === 0) CSS.highlights.delete(name); + else CSS.highlights.set(name, new Highlight(...ranges)); +} + +function scrollToRange(scroller, range) { + const match = range.getBoundingClientRect(); + if (match.height === 0) return; + const view = scroller.getBoundingClientRect(); + if (match.top >= view.top && match.bottom <= view.bottom) return; + scroller.scrollTop += match.top - view.top - (view.height - match.height) / 2; +} + +// A copy of the matched text in a yellow lozenge pops over the match, then fades. +function bounce(range) { + if (REDUCED_MOTION?.matches) return; + const box = range.getBoundingClientRect(); + if (box.height === 0) return; + const lozenge = document.createElement("span"); + lozenge.className = "find-lozenge"; + lozenge.append(range.cloneContents()); + Object.assign(lozenge.style, { + left: `${box.left - LOZENGE_PADDING_PX}px`, + top: `${box.top}px`, + height: `${box.height}px`, + lineHeight: `${box.height}px`, + font: getComputedStyle(range.startContainer.parentElement).font, + }); + document.body.append(lozenge); + lozenge.animate(BOUNCE_KEYFRAMES, { duration: BOUNCE_DURATION_MS, easing: "ease-out" }) + .addEventListener("finish", () => lozenge.remove()); +} + +function countText(total, index) { + if (total === 0) return "Not found"; + return `${index + 1} of ${total}`; +} + +/** + * The find bar over the note: Ctrl+F opens it, Enter and Shift+Enter step through matches, Esc + * or Done close it and leave the current match selected. It also shows a notes search's matches + * in the note opened from the results. + */ +export function createFind({ bar, editorElement, scroller }) { + const input = bar.querySelector(".find-input"); + const count = bar.querySelector(".find-count"); + let ranges = []; + let index = 0; + + const isOpen = () => !bar.hidden; + const current = () => ranges[index] ?? null; + + function update() { + ranges = findMatches(editorElement, input.value).map(toRange); + index = Math.min(index, Math.max(ranges.length - 1, 0)); + paint("find-match", ranges); + paint("find-current", ranges.length > 0 ? [current()] : []); + count.textContent = input.value === "" ? "" : countText(ranges.length, index); + } + + function reveal() { + const range = current(); + if (!range) return; + scrollToRange(scroller, range); + bounce(range); + } + + function step(direction) { + if (ranges.length === 0) return; + index = (index + direction + ranges.length) % ranges.length; + update(); + reveal(); + } + + function open() { + clearSearchMatches(); + bar.hidden = false; + input.focus(); + input.select(); + update(); + reveal(); + } + + function close() { + if (!isOpen()) return; + bar.hidden = true; + ranges = []; + paint("find-match", []); + paint("find-current", []); + } + + // Like macOS, the editor gets back the focus with the current match selected. + function finish() { + const range = current(); + close(); + editorElement.focus(); + if (!range) return; + document.getSelection().removeAllRanges(); + document.getSelection().addRange(range); + } + + // A note just opened from the search results scrolls to its first match. + function showSearchMatches(query) { + const found = findMatches(editorElement, query).map(toRange); + paint("search-match", found); + if (found.length > 0) scrollToRange(scroller, found[0]); + } + + function clearSearchMatches() { + paint("search-match", []); + } + + function onInputKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + finish(); + } else if (event.key === "Enter") { + event.preventDefault(); + step(event.shiftKey ? -1 : 1); + } + } + + input.addEventListener("input", () => { + index = 0; + update(); + reveal(); + }); + input.addEventListener("keydown", onInputKeydown); + bar.querySelector(".find-previous").addEventListener("click", () => step(-1)); + bar.querySelector(".find-next").addEventListener("click", () => step(1)); + bar.querySelector(".find-done").addEventListener("click", finish); + editorElement.addEventListener("input", () => { + clearSearchMatches(); + if (isOpen()) update(); + }); + + // The note under the bar changed: its matches are found again where they now are. + const refresh = () => { + if (isOpen()) update(); + }; + + return { open, close, refresh, showSearchMatches, clearSearchMatches }; +} diff --git a/extension/hotkeys.js b/extension/hotkeys.js index 3465fe0..545839f 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -22,6 +22,7 @@ const DEFAULT_HOTKEYS = [ // Word's key; nothing selected changes the word at the caret. { group: "Editing", action: "changeCase", label: "Change case: lower, UPPER, Title", shift: true, code: "F3" }, { group: "Editing", action: "pastePlain", label: "Paste as plain text", mod: true, shift: true, code: "KeyV", native: true }, + { group: "Editing", action: "findInNote", label: "Find in note", mod: true, code: "KeyF" }, // VS Code's line keys. { group: "Lines", action: "copyLine", label: "Copy line (nothing selected)", mod: true, code: "KeyC", native: true }, { group: "Lines", action: "cutLine", label: "Cut line (nothing selected)", mod: true, code: "KeyX", native: true }, @@ -43,7 +44,7 @@ const DEFAULT_HOTKEYS = [ { group: "Notes", action: "deleteNote", label: "Delete note", mod: true, shift: true, code: "Backspace" }, { group: "Notes", action: "recover", label: "Recover deleted note", alt: true, code: "KeyU" }, { group: "Navigate", action: "focusList", label: "Go to notes list", alt: true, code: "Digit1" }, - { group: "Navigate", action: "search", label: "Search notes", mod: true, code: "KeyF" }, + { group: "Navigate", action: "search", label: "Search notes", mod: true, shift: true, code: "KeyF" }, { group: "Navigate", action: "search", label: "Search notes", alt: true, code: "Digit2" }, { group: "Navigate", action: "focusEditor", label: "Go to note", alt: true, code: "Digit3" }, { group: "Navigate", action: "toggleSidebar", label: "Show / hide notes list", alt: true, code: "Digit0" }, diff --git a/extension/notes.css b/extension/notes.css index 4df6507..82e161d 100644 --- a/extension/notes.css +++ b/extension/notes.css @@ -101,8 +101,8 @@ button:disabled { cursor: default; } .note-row:hover { background: var(--hover); } .note-row[aria-current="true"]::before, .note-row[aria-current="true"] + .note-row::before { display: none; } .note-row[aria-current="true"] { background: var(--selected); } -/* The card clips an outline, so keyboard focus shows as a fill (or a ring on the open note). */ -.note-row:focus-visible { outline: none; background: var(--fill); } +/* The card clips an outline, so keyboard focus shows as an inset ring; gray is left to the mouse. */ +.note-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); } .note-row[aria-current="true"]:focus-visible { background: var(--selected); box-shadow: inset 0 0 0 2px var(--accent); } .note-row.dragging { opacity: 0.45; } .note-row.drop-before { box-shadow: inset 0 3px 0 var(--accent) !important; } @@ -135,6 +135,15 @@ body.viewing-deleted #format-button, body.viewing-deleted #checklist-button, body.viewing-deleted #pin-button, body:not(.viewing-deleted) #recover-button { display: none; } +.find-bar { display: flex; align-items: center; gap: 0.25rem; padding: 0 0.75rem 0.5rem; } +.find-input { flex: 1; min-width: 0; padding: 0.35rem 0.75rem; border: 0; border-radius: 0.6rem; background: var(--fill); color: var(--text); font: inherit; } +.find-input:focus { outline: 2px solid var(--accent); outline-offset: -2px; } +.find-count { padding: 0 0.25rem; color: var(--secondary); font-size: 0.85rem; font-variant-numeric: tabular-nums; white-space: nowrap; } +/* Same yellows in both themes, as in macOS; the current match stays readable with black text. */ +::highlight(find-match) { background-color: rgb(255 214 10 / 0.4); } +::highlight(find-current) { background-color: #ffd60a; color: #000000; } +::highlight(search-match) { background-color: rgb(255 214 10 / 0.3); } +.find-lozenge { position: fixed; z-index: 10; padding: 0 3px; border-radius: 4px; background: #ffd60a; color: #000000; box-shadow: 0 1px 4px rgb(0 0 0 / 0.4); white-space: pre; pointer-events: none; } /* A 46rem column keeps lines readable on wide screens. */ .editor-scroll { flex: 1; overflow-y: auto; padding: 0 max(clamp(1.5rem, 5vw, 3rem), (100% - 46rem) / 2) 4rem; } .note-date { margin: 0.25rem 0 1rem; text-align: center; font-size: 0.8rem; color: var(--secondary); } @@ -353,7 +362,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no .note-row[aria-current="true"] { background: none; } .note-row[aria-current="true"]::before, .note-row[aria-current="true"] + .note-row::before { display: block; } .note-row[aria-current="true"]:hover { background: var(--hover); } - .note-row[aria-current="true"]:focus-visible { background: var(--fill); box-shadow: none; } + .note-row[aria-current="true"]:focus-visible { background: none; } } /* Motion after iPhone Notes: a step deeper slides the new screen in from the right while the old @@ -368,6 +377,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no /* Opaque, so a sliding screen covers the one under it. */ .editor-pane, .toolbar { background: var(--bg); } ::view-transition-old(root), ::view-transition-new(root) { animation: none; } + .find-bar:not([hidden]) { animation: 150ms var(--ease-ios) find-bar-in; } ::view-transition-group(*) { animation-duration: var(--step-duration); animation-timing-function: var(--ease-ios); } html[data-nav="forward"]::view-transition-new(pane) { animation: var(--step-duration) var(--ease-ios) both slide-in-from-right; box-shadow: -0.5rem 0 1.5rem rgb(0 0 0 / 0.2); } html[data-nav="forward"]::view-transition-old(pane) { animation: var(--step-duration) var(--ease-ios) both drift-out-left; } @@ -403,6 +413,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no .format-menu:popover-open { opacity: 0; transform: scale(0.9); } } +@keyframes find-bar-in { from { transform: translateY(-0.75rem); opacity: 0; } } @keyframes slide-in-from-right { from { transform: translateX(100%); } } @keyframes slide-out-to-right { to { transform: translateX(100%); } } @keyframes drift-out-left { to { transform: translateX(-30%); filter: brightness(0.8); } } diff --git a/extension/notes.html b/extension/notes.html index b140fff..08bd9e0 100644 --- a/extension/notes.html +++ b/extension/notes.html @@ -55,6 +55,17 @@
plan the PLAN
release
"); + assert.deepEqual(spans(findMatches(body, "release")), [["re", 0, "se", 2]]); +}); + +test("a match ending at the end of a node ends there, not at the next node's start", () => { + const body = note("tag v1
"); + assert.deepEqual(spans(findMatches(body, "tag")), [["tag", 0, "tag", 3]]); +}); + +test("a match never runs from one line into the next", () => { + const body = note("foo
bar
a.b axb (x) [y]
"); + assert.equal(findMatches(body, "a.b").length, 1); + assert.equal(findMatches(body, "(x)").length, 1); + assert.equal(findMatches(body, "[y]").length, 1); +}); + +test("an empty query finds nothing", () => { + assert.deepEqual(findMatches(note("text
"), ""), []); +}); diff --git a/tests/hotkeys.test.js b/tests/hotkeys.test.js index 4de3fa6..8bbe77f 100644 --- a/tests/hotkeys.test.js +++ b/tests/hotkeys.test.js @@ -26,11 +26,12 @@ test("Chrome-reserved shortcuts use Alt fallbacks that avoid Windows' Alt+Shift assert.equal(matchHotkey(key("KeyN", { altKey: true }), false), "newNote"); }); -test("undo, both redo forms and search are matched", () => { +test("undo, both redo forms, find in note and search are matched", () => { assert.equal(matchHotkey(key("KeyZ", { ctrlKey: true }), false), "undo"); assert.equal(matchHotkey(key("KeyZ", { ctrlKey: true, shiftKey: true }), false), "redo"); assert.equal(matchHotkey(key("KeyY", { ctrlKey: true }), false), "redo"); - assert.equal(matchHotkey(key("KeyF", { ctrlKey: true }), false), "search"); + assert.equal(matchHotkey(key("KeyF", { ctrlKey: true }), false), "findInNote"); + assert.equal(matchHotkey(key("KeyF", { ctrlKey: true, shiftKey: true }), false), "search"); }); test("shortcuts the browser already handles are not matched", () => { @@ -86,7 +87,7 @@ test("every button's shortcut is matched", () => { test("tooltips name each button's shortcut for the platform", () => { assert.equal(fillShortcuts("Delete note ({deleteNote})", false), "Delete note (Ctrl+Shift+Backspace)"); assert.equal(fillShortcuts("Delete note ({deleteNote})", true), "Delete note (⌘⇧⌫)"); - assert.equal(fillShortcuts("Search ({search}) · settings ({trackerSettings})", false), "Search (Ctrl+F) · settings (Ctrl+,)"); + assert.equal(fillShortcuts("Search ({search}) · settings ({trackerSettings})", false), "Search (Ctrl+Shift+F) · settings (Ctrl+,)"); }); test("every shortcut has a group and each group is listed in one run, so help shows it once", () => {