Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion desktop/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion desktop/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
15 changes: 15 additions & 0 deletions extension/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -413,6 +422,7 @@ function openCreated(note) {
function clearSearch() {
state.query = "";
byId("search").value = "";
find.clearSearchMatches();
}

function togglePin() {
Expand Down Expand Up @@ -653,6 +663,7 @@ function wireSidebar() {
wireNoteDragging();
byId("search").addEventListener("input", (event) => {
state.query = event.target.value.trim().toLowerCase();
find.clearSearchMatches();
renderList();
});
}
Expand Down Expand Up @@ -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();
Expand Down
214 changes: 214 additions & 0 deletions extension/find.js
Original file line number Diff line number Diff line change
@@ -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 };
}
3 changes: 2 additions & 1 deletion extension/hotkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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" },
Expand Down
17 changes: 14 additions & 3 deletions extension/notes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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
Expand All @@ -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; }
Expand Down Expand Up @@ -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); } }
Expand Down
Loading
Loading