From b04a79c1e4bd00fc0ae357bdc41526d5f1f93ba9 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 9 Nov 2026 12:40:18 +0700 Subject: [PATCH 1/3] feat: remove the global show/hide shortcut Ctrl+Alt+N no longer registers with Windows. The tray icon and a second launch still bring the window forward; a key saved for the old shortcut is dropped when settings load. --- README.md | 6 ++--- desktop/Cargo.toml | 1 - desktop/src/lib.rs | 44 ------------------------------------ extension/app.js | 19 ++-------------- extension/desktop.js | 7 ------ extension/hotkeys.js | 12 +--------- extension/shortcut-editor.js | 2 +- tests-e2e/app.e2e.js | 5 ++-- tests/desktop.test.js | 10 +------- tests/hotkeys.test.js | 30 ++++-------------------- 10 files changed, 13 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 80ecb38..a5939be 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ - **Sprints in one note.** Every sprint and a Backlog live in one Sprints note: a points donut, goals per sprint and per month, drag-and-drop tasks and a rich-text note for each task. - **Keyboard first.** Every button has a shortcut, and you can change any of them. - **Back and forward.** The mouse's side buttons and Alt+← / Alt+→ step through screens. -- **A keystroke away.** `Ctrl+Alt+N` shows or hides Notelet from any app; it waits in the system tray. - **Private.** Notes stay in one file on your PC. Export a backup file whenever you like. @@ -75,7 +74,7 @@ Notelet is an independent project and is not affiliated with or endorsed by Appl 2. Run it. Windows may warn about an unknown publisher the first time, because the installer is not code-signed: click **More info**, then **Run anyway**. It installs for your account only, so no admin rights are needed. 3. Notelet opens, adds an icon to the system tray and starts with Windows from then on. -Press `Ctrl+Alt+N` from any app to show or hide Notelet. Closing the window (X) only hides it; to quit, right-click the tray icon → **Quit**. Turn off **Start with Windows** in the same menu if you don't want it at login. The window remembers its size and place; `Alt+Enter` or the ⤢ button maximizes and restores it. +Closing the window (X) only hides it; click the tray icon to bring it back. To quit, right-click the tray icon → **Quit**. Turn off **Start with Windows** in the same menu if you don't want it at login. The window remembers its size and place; `Alt+Enter` or the ⤢ button maximizes and restores it. ## Update @@ -129,11 +128,10 @@ Press `Ctrl+/` (`⌘/` on Mac) for the full list, grouped by topic. Every button | Change case: lower, UPPER, Title | `Shift+F3` | `⇧F3` | | Back / Forward | mouse side buttons, `Alt+←` / `Alt+→` | mouse side buttons, `⌘[` / `⌘]` | | All shortcuts | `Ctrl+/` | `⌘/` | -| Show / hide Notelet from any app | `Ctrl+Alt+N` | — | Text styles follow Notes for Mac with Ctrl in place of Cmd; where Chrome kept a key for itself (Ctrl+N, Ctrl+Shift+T) Notelet uses Alt instead, the same keys as the extension had. -**To change a shortcut**, click its key in the list and press the new one. Esc, Tab or a click elsewhere cancels; Backspace removes the key. Keys Windows keeps, and those Chrome kept, are refused; the show / hide key must use Ctrl or Alt, and a key another app holds is refused with a message; and a key another shortcut uses asks before taking it over. ↺ resets one shortcut; **Reset All to Default** resets them all. +**To change a shortcut**, click its key in the list and press the new one. Esc, Tab or a click elsewhere cancels; Backspace removes the key. Keys Windows keeps, and those Chrome kept, are refused; a key another shortcut uses asks before taking it over. ↺ resets one shortcut; **Reset All to Default** resets them all. ## Development diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index fd5391d..16c9058 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -15,7 +15,6 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "~2.12", features = ["tray-icon"] } tauri-plugin-single-instance = "2" -tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" tauri-plugin-window-state = "2" tauri-plugin-updater = "2" diff --git a/desktop/src/lib.rs b/desktop/src/lib.rs index 248e9fe..2ce9f4c 100644 --- a/desktop/src/lib.rs +++ b/desktop/src/lib.rs @@ -10,7 +10,6 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent} use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow, WindowEvent}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt as _}; use tauri_plugin_dialog::DialogExt; -use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; use tauri_plugin_updater::{Update, UpdaterExt}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; @@ -38,7 +37,6 @@ impl Pending { #[derive(Default)] struct AppState { storage_lock: Mutex<()>, - global_shortcut: Mutex>, pending: Mutex>, } @@ -76,28 +74,6 @@ fn storage_save(app: AppHandle, state: State, data: Map storage::save(&data_dir(&app)?, &data).map_err(|error| error.to_string()) } -/// Registers the new key before letting go of the old one, so a key another app holds leaves -/// the old key working. Returns `Err("taken")` in that case; `None` removes the key. -#[tauri::command] -fn set_global_shortcut(app: AppHandle, state: State, accelerator: Option) -> Result<(), String> { - let wanted = accelerator - .map(|text| text.parse::().map_err(|error| error.to_string())) - .transpose()?; - let mut current = state.global_shortcut.lock().map_err(|error| error.to_string())?; - if wanted == *current { - return Ok(()); - } - let shortcuts = app.global_shortcut(); - if let Some(shortcut) = wanted { - shortcuts.register(shortcut).map_err(|_| "taken".to_string())?; - } - if let Some(old) = current.take() { - let _ = shortcuts.unregister(old); - } - *current = wanted; - Ok(()) -} - #[tauri::command] fn toggle_maximize(window: WebviewWindow) -> Result<(), String> { let maximized = window.is_maximized().map_err(|error| error.to_string())?; @@ -143,16 +119,6 @@ fn hide_main(app: &AppHandle) { } } -fn toggle_main(app: &AppHandle) { - let Some(window) = app.get_webview_window(MAIN_WINDOW) else { return }; - let in_front = window.is_visible().unwrap_or(false) && window.is_focused().unwrap_or(false); - if in_front { - hide_main(app); - } else { - show_main(app); - } -} - /// Asks the page to write pending edits, then quits or updates once it answers or the timeout /// passes, whichever comes first. fn request_after_flush(app: &AppHandle, pending: Pending) { @@ -307,22 +273,12 @@ pub fn run() { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show_main(app))) .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec![HIDDEN_ARG]))) .plugin(tauri_plugin_window_state::Builder::default().with_state_flags(window_state_flags()).build()) - .plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler(|app, _shortcut, event| { - if event.state == ShortcutState::Pressed { - toggle_main(app); - } - }) - .build(), - ) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .manage(AppState::default()) .invoke_handler(tauri::generate_handler![ storage_load, storage_save, - set_global_shortcut, toggle_maximize, after_flush, save_backup diff --git a/extension/app.js b/extension/app.js index 92de352..3147143 100644 --- a/extension/app.js +++ b/extension/app.js @@ -1,12 +1,12 @@ import { backupFileName, createBackup, parseBackup } from "./backup.js"; import { closeOnOutsideClick, pickTextFile, showToast } from "./dialogs.js"; -import { createFileArea, historyKey, recoveryMessage, toAccelerator, whenShown } from "./desktop.js"; +import { createFileArea, historyKey, recoveryMessage, whenShown } from "./desktop.js"; 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 { formatEditedDate } from "./format.js"; -import { applyShortcutTitles, globalCombo, matchHotkey, setCustomKeys } from "./hotkeys.js"; +import { applyShortcutTitles, matchHotkey, setCustomKeys } from "./hotkeys.js"; import { setupLayout } from "./layout.js"; import { EMPTY_BODY_HTML, EMPTY_NOTE_HTML, isBlankNote, isEmptyNote, isExpired, newNote, noteText, noteTitleOf, orderedGroups, placeNote, stepNote } from "./model.js"; import { mergeNote } from "./merge.js"; @@ -28,7 +28,6 @@ const VIEWPORT_MARGIN_PX = 8; const IS_MAC = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform); const NARROW = matchMedia("(max-width: 699px)"); const MERGE_CONFLICT_MESSAGE = "This note was also edited in another window. A paragraph you both changed now appears in both versions; for anything else, this window's change was kept."; -const SHORTCUT_TAKEN_MESSAGE = "That key is already used by another app, so Notelet kept the previous one."; const SAVE_FAILED_MESSAGE = "Couldn't save your last change. Your text is still here — keep this tab open and try again, or export a backup."; const THEME_ICONS = { @@ -82,7 +81,6 @@ async function main() { state.ui = loaded.ui; state.settings = loaded.settings; setCustomKeys(state.settings.hotkeys); - registerGlobalShortcut().catch(() => whenShown(document, window, () => showToast(SHORTCUT_TAKEN_MESSAGE))); showTheme(state.ui.theme); await removeStaleNotes(); editor = new NoteEditor({ element: byId("editor"), isMac: IS_MAC, onChange: onEditorChange }); @@ -98,8 +96,6 @@ async function main() { whenShown(document, window, () => showStartupNotice(recovery, loaded.notes.length === 0)); } -const registerGlobalShortcut = () => tauri.core.invoke("set_global_shortcut", { accelerator: toAccelerator(globalCombo()) }); - // shortcut: an edit typed under SAVE_DELAY_MS before Windows shuts down can be lost, since WebView2 // is killed without a page event; fixing it needs a WM_QUERYENDSESSION hook in Rust. function listenForFlush() { @@ -808,19 +804,8 @@ const showHelp = () => openShortcuts({ }); function saveShortcuts(hotkeys) { - const previous = state.settings.hotkeys; adoptSettings({ ...state.settings, hotkeys }); store.saveSettings(state.settings).catch(reportSaveError); - registerGlobalShortcut().catch(() => keepPreviousGlobalKey(previous)); -} - -// Windows refused the new global key, so the setting goes back to the key still registered. -function keepPreviousGlobalKey(previousHotkeys) { - const { showHide: _refused, ...hotkeys } = state.settings.hotkeys; - const restored = Object.hasOwn(previousHotkeys, "showHide") ? { ...hotkeys, showHide: previousHotkeys.showHide } : hotkeys; - adoptSettings({ ...state.settings, hotkeys: restored }); - store.saveSettings(state.settings).catch(reportSaveError); - showToast(SHORTCUT_TAKEN_MESSAGE); } // Changed shortcuts take effect at once, tooltips included. diff --git a/extension/desktop.js b/extension/desktop.js index 47eed1a..d0c905c 100644 --- a/extension/desktop.js +++ b/extension/desktop.js @@ -63,13 +63,6 @@ function removeKeys(map, keys) { return changes; } -// The accelerator string the global-shortcut plugin parses, e.g. "Ctrl+Alt+KeyN". -export function toAccelerator(combo) { - if (!combo?.code) return null; - const modifiers = [combo.mod && "Ctrl", combo.alt && "Alt", combo.shift && "Shift"].filter(Boolean); - return [...modifiers, combo.code].join("+"); -} - export function recoveryMessage({ recovered, damagedPath }) { if (recovered === "backup") return "Notelet's notes file was damaged, so the last good copy was opened. Your most recent change may be missing."; if (recovered === "reset") return `Notelet's notes file was damaged and was moved to ${damagedPath}. Import a backup to bring your notes back.`; diff --git a/extension/hotkeys.js b/extension/hotkeys.js index 1da8f63..3465fe0 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -57,8 +57,6 @@ const DEFAULT_HOTKEYS = [ { group: "App", action: "import", label: "Import backup", alt: true, code: "KeyI" }, { group: "App", action: "openFullPage", label: "Maximize / restore window", alt: true, code: "Enter" }, { group: "App", action: "help", label: "Show or hide this list", mod: true, code: "Slash" }, - // Registered with Windows, so it works while another app is in front. - { group: "App", action: "showHide", label: "Show / hide Notelet (from any app)", mod: true, alt: true, code: "KeyN", global: true }, ]; // Each shortcut has a stable id: its action, with 2 added for an action's second key. @@ -117,9 +115,8 @@ const WINDOWS_ALT_KEYS = new Set(["KeyF", "KeyE", "F4", "Space", "Tab"]); // F1 to F12 type nothing, so they work without Ctrl or Alt. const FUNCTION_KEY = /^F\d+$/; -export function reservedReason(combo, isMac, hotkey = null) { +export function reservedReason(combo, isMac) { const label = hotkeyLabel(combo, isMac); - if (hotkey?.global && !combo.mod && !combo.alt) return "A key that works in every app needs Ctrl or Alt."; if (!combo.mod && !combo.alt && !FUNCTION_KEY.test(combo.code)) return `Add ${isMac ? "⌘ or ⌥" : "Ctrl or Alt"}, so the key doesn't just type.`; if (!isMac && combo.alt && combo.shift && !combo.mod) return "Alt+Shift switches the keyboard language on Windows."; if (combo.mod && !combo.alt && (combo.shift ? CHROME_MOD_SHIFT_KEYS : CHROME_MOD_KEYS).has(combo.code)) return `Chrome keeps ${label} for itself.`; @@ -155,13 +152,6 @@ const MAC_KEY_NAMES = { ...KEY_NAMES, Backspace: "⌫", Enter: "↩" }; const keyName = (code, isMac) => (isMac ? MAC_KEY_NAMES : KEY_NAMES)[code] ?? code.replace(/^(Key|Digit|Numpad)/, ""); -// The key to register with Windows for showing Notelet, or null when the user removed it. -export function globalCombo() { - const hotkey = activeHotkeys().find((candidate) => candidate.global); - if (hotkey.code === null) return null; - return { mod: Boolean(hotkey.mod), alt: Boolean(hotkey.alt), shift: Boolean(hotkey.shift), code: hotkey.code }; -} - // An action's first key that is set; "no key" when the user removed them all. export function shortcutFor(action, isMac) { const hotkey = activeHotkeys().find((candidate) => candidate.action === action && candidate.code !== null); diff --git a/extension/shortcut-editor.js b/extension/shortcut-editor.js index 627928a..726b675 100644 --- a/extension/shortcut-editor.js +++ b/extension/shortcut-editor.js @@ -75,7 +75,7 @@ function recordKey(event, { id, keys, isMac }) { if (event.key === "Escape") return {}; if (event.key === "Backspace" || event.key === "Delete") return { keys: { ...keys, [id]: null } }; const combo = comboOf(event, isMac); - const reason = reservedReason(combo, isMac, defaultHotkey(id)); + const reason = reservedReason(combo, isMac); if (reason) return { message: { id, text: reason } }; const other = hotkeysWith(keys).find((hotkey) => hotkey.id !== id && sameCombo(hotkey, combo)); if (other && !isEditable(other)) return { message: { id, text: `${hotkeyLabel(combo, isMac)} is ${other.label}, which Chrome handles.` } }; diff --git a/tests-e2e/app.e2e.js b/tests-e2e/app.e2e.js index baac6cf..d4b5aa0 100644 --- a/tests-e2e/app.e2e.js +++ b/tests-e2e/app.e2e.js @@ -43,10 +43,9 @@ test("every default in-app shortcut reaches the page", async () => { }; addEventListener("keydown", window.__record, { capture: true }); `); - const combos = HOTKEYS.filter((hotkey) => !hotkey.global); - for (const combo of combos) await app.press(combo); + for (const combo of HOTKEYS) await app.press(combo); const seen = new Set(await app.execute("removeEventListener('keydown', window.__record, { capture: true }); return window.__seen")); - const missing = combos.filter((combo) => !seen.has(comboName(combo))).map((combo) => `${combo.id} (${comboName(combo)})`); + const missing = HOTKEYS.filter((combo) => !seen.has(comboName(combo))).map((combo) => `${combo.id} (${comboName(combo)})`); assert.deepEqual(missing, []); }); diff --git a/tests/desktop.test.js b/tests/desktop.test.js index d67c9fd..8fd82b4 100644 --- a/tests/desktop.test.js +++ b/tests/desktop.test.js @@ -1,7 +1,7 @@ import "./dom.js"; import { test } from "node:test"; import assert from "node:assert/strict"; -import { createFileArea, historyKey, recoveryMessage, toAccelerator, whenShown } from "../extension/desktop.js"; +import { createFileArea, historyKey, recoveryMessage, whenShown } from "../extension/desktop.js"; import { DEFAULT_UI, SCHEMA_VERSION, createStore } from "../extension/store.js"; import { defaultSettings } from "../extension/sprint.js"; @@ -86,14 +86,6 @@ test("the store works on the file area: a fresh install gets defaults and the sc assert.equal(backend.saves.at(-1).schemaVersion, SCHEMA_VERSION); }); -test("toAccelerator names modifiers in Ctrl, Alt, Shift order", () => { - assert.equal(toAccelerator({ mod: true, alt: true, shift: false, code: "KeyN" }), "Ctrl+Alt+KeyN"); - assert.equal(toAccelerator({ mod: true, alt: true, shift: true, code: "Digit1" }), "Ctrl+Alt+Shift+Digit1"); - assert.equal(toAccelerator({ mod: false, alt: true, shift: false, code: "F2" }), "Alt+F2"); - assert.equal(toAccelerator(null), null); - assert.equal(toAccelerator({ mod: true, alt: false, shift: false, code: null }), null); -}); - test("recoveryMessage explains a restored copy or a moved file", () => { assert.equal(recoveryMessage({ recovered: "backup", damagedPath: null }), "Notelet's notes file was damaged, so the last good copy was opened. Your most recent change may be missing."); diff --git a/tests/hotkeys.test.js b/tests/hotkeys.test.js index ebe381f..4de3fa6 100644 --- a/tests/hotkeys.test.js +++ b/tests/hotkeys.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { HOTKEYS, defaultHotkey, fillShortcuts, globalCombo, hotkeyLabel, matchHotkey, normalizeCustomKeys, reservedReason, setCustomKeys } from "../extension/hotkeys.js"; +import { HOTKEYS, fillShortcuts, hotkeyLabel, matchHotkey, normalizeCustomKeys, reservedReason, setCustomKeys } from "../extension/hotkeys.js"; const key = (code, modifiers = {}) => ({ code, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, isComposing: false, ...modifiers, @@ -135,33 +135,11 @@ test("stored shortcut changes keep only well-formed keys for shortcuts that can const plainCombo = (code, modifiers = {}) => ({ mod: false, alt: false, shift: false, code, ...modifiers }); -test("the global shortcut defaults to Ctrl+Alt+N and is marked global", () => { - const hotkey = HOTKEYS.find((candidate) => candidate.id === "showHide"); - assert.equal(hotkey.mod, true); - assert.equal(hotkey.alt, true); - assert.equal(hotkey.code, "KeyN"); - assert.equal(hotkey.global, true); - assert.equal(matchHotkey(key("KeyN", { ctrlKey: true, altKey: true }), false), "showHide"); -}); - -test("a global key needs Ctrl or Alt, even a function key", () => { - assert.equal(reservedReason(plainCombo("F1"), false, defaultHotkey("showHide")), "A key that works in every app needs Ctrl or Alt."); - assert.equal(reservedReason(plainCombo("F1"), false, defaultHotkey("help")), null); - assert.equal(reservedReason(plainCombo("KeyK", { mod: true, alt: true }), false, defaultHotkey("showHide")), null); +test("no shortcut works from other apps, and a key saved for the old one is dropped", () => { + assert.equal(matchHotkey(key("KeyN", { ctrlKey: true, altKey: true }), false), null); + assert.deepEqual(normalizeCustomKeys({ showHide: plainCombo("KeyK", { alt: true }) }), {}); }); test("in-app key rules are unchanged", () => { assert.equal(reservedReason(plainCombo("KeyN", { mod: true }), false), "Chrome keeps Ctrl+N for itself."); }); - -test("globalCombo follows custom keys", () => { - try { - assert.deepEqual(globalCombo(), { mod: true, alt: true, shift: false, code: "KeyN" }); - setCustomKeys({ showHide: null }); - assert.equal(globalCombo(), null); - setCustomKeys({ showHide: plainCombo("KeyK", { alt: true }) }); - assert.deepEqual(globalCombo(), plainCombo("KeyK", { alt: true })); - } finally { - setCustomKeys({}); - } -}); From 7ec9bf59e0c5f2db96d3e309a805e86f8c4a89d6 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 9 Nov 2026 12:40:18 +0700 Subject: [PATCH 2/3] fix: make text styles, copy and paste work on what is selected Style keys acted on the selection's focus, so a triple-click or Shift+Down styled the next line and a multi-line selection styled one line. They now cover every selected line and keep the selection; Tab, indent and move line do too. Paste is cleaned before it enters the note: line breaks of a page's HTML source no longer show, each plain text line is its own line, and Google Docs copies are no longer all bold. Copies leave out the stray empty line and inline styles. Typing over a triple-clicked line keeps the next line, and Chrome's inline style spans are removed after edits. A link now opens with Ctrl+click, so a plain or double click can place the caret and select its text. Punctuation after a typed URL stays out of the link. --- README.md | 2 + extension/clipboard.js | 2 + extension/editor.js | 167 +++++++++++++++++++++++++++++++--------- extension/lines.js | 50 +++++++++++- extension/lists.js | 35 +++++++++ extension/notes.css | 2 +- extension/sanitize.js | 78 ++++++++++++++++++- extension/selection.js | 17 ++++ tests/clipboard.test.js | 6 ++ tests/lines.test.js | 17 +++- tests/lists.test.js | 14 +++- tests/sanitize.test.js | 46 ++++++++++- 12 files changed, 387 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index a5939be..2337f26 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ Press `Ctrl+/` (`⌘/` on Mac) for the full list, grouped by topic. Every button | Back / Forward | mouse side buttons, `Alt+←` / `Alt+→` | mouse side buttons, `⌘[` / `⌘]` | | All shortcuts | `Ctrl+/` | `⌘/` | +`Ctrl+click` (`⌘`-click on Mac) opens a link in a note; a plain click only places the caret. + Text styles follow Notes for Mac with Ctrl in place of Cmd; where Chrome kept a key for itself (Ctrl+N, Ctrl+Shift+T) Notelet uses Alt instead, the same keys as the extension had. **To change a shortcut**, click its key in the list and press the new one. Esc, Tab or a click elsewhere cancels; Backspace removes the key. Keys Windows keeps, and those Chrome kept, are refused; a key another shortcut uses asks before taking it over. ↺ resets one shortcut; **Reset All to Default** resets them all. diff --git a/extension/clipboard.js b/extension/clipboard.js index 5a499cb..65bab76 100644 --- a/extension/clipboard.js +++ b/extension/clipboard.js @@ -79,6 +79,8 @@ function textMarker(list, item, index) { // characters. data-copy-marker lets a paste back into Notelet drop them again. export function clipboardHtml(root) { const copy = root.cloneNode(true); + // Chrome leaves font-size and colour spans in a note when lines merge. + copy.querySelectorAll("[style]").forEach((element) => element.removeAttribute("style")); for (const list of copy.querySelectorAll("ul.checklist, ul.dashed")) { list.setAttribute("style", "list-style: none; padding-left: 0"); for (const item of list.children) item.prepend(htmlMarker(list, item)); diff --git a/extension/editor.js b/extension/editor.js index 53a7378..ceae8c4 100644 --- a/extension/editor.js +++ b/extension/editor.js @@ -3,15 +3,15 @@ import { clipboardHtml, clipboardText, selectedContent } from "./clipboard.js"; import { History } from "./history.js"; import { matchHotkey } from "./hotkeys.js"; import { - ABOVE, BELOW, duplicateLine, indentText, insertLine, lineOf, moveLine, nextLine, outdentText, previousLine, removeLine, + ABOVE, BELOW, duplicateLine, endingInsideText, indentText, insertLine, lineOf, moveLines, nextLine, outdentText, previousLine, removeLine, selectedLines, withoutNextLineStart, } from "./lines.js"; import { - checkboxForShortcut, itemCreatedBySplit, liftListOutOfParagraph, listKindForShortcut, removeLeadingText, setBlockType, + checkboxForShortcut, clearItemState, itemCreatedBySplit, liftListOutOfParagraph, listKindForShortcut, removeLeadingText, setBlockType, splitParagraphsIntoItems, } from "./lists.js"; import { EMPTY_NOTE_HTML } from "./model.js"; -import { sanitizeHtml } from "./sanitize.js"; +import { linkableUrl, normalizeLineBreaks, plainTextToHtml, sanitizeHtml, sanitizePastedHtml } from "./sanitize.js"; import { - caretBlock, caretElement, caretOffset, isCaretAtEndOf, placeCaret, placeCaretAfter, placeCaretAtEnd, placeCaretAtStart, setCaretOffset, + caretBlock, caretElement, caretOffset, isCaretAtEndOf, placeCaret, placeCaretAfter, placeCaretAtEnd, placeCaretAtStart, restoreSelection, saveSelection, setCaretOffset, } from "./selection.js"; const TYPING_PAUSE_MS = 1000; @@ -32,6 +32,7 @@ const EDITOR_ACTIONS = new Set([ const PASTE_INPUT_TYPES = new Set(["insertFromPaste", "insertFromPasteAsQuotation", "insertFromDrop"]); const BURST_KINDS = new Set(["type", "delete"]); const CHECKLIST_ITEM = "ul.checklist > li"; +const LIST_READY_TAGS = new Set(["P", "DIV", "LI"]); export class NoteEditor { #element; @@ -130,16 +131,27 @@ export class NoteEditor { } #editLine(action, direction) { + if (action.startsWith("moveLine")) return this.#moveSelectedLines(direction); const line = caretBlock(this.#element); if (!line) return; const offset = caretOffset(line); - if (action.startsWith("insertLine")) { - placeCaretAtStart(insertLine(line, direction)); - } else if (action.startsWith("duplicateLine")) { - setCaretOffset(duplicateLine(line, direction), offset); - } else if (moveLine(line, direction)) { - setCaretOffset(line, offset); - } + if (action.startsWith("insertLine")) placeCaretAtStart(insertLine(line, direction)); + else setCaretOffset(duplicateLine(line, direction), offset); + } + + #moveSelectedLines(direction) { + const saved = saveSelection(); + if (moveLines(this.#selectedLines(), direction)) restoreSelection(saved); + } + + // The lines under the selection, or the caret's line. + #selectedLines() { + const selection = document.getSelection(); + if (!selection.rangeCount || !this.#element.contains(selection.anchorNode)) return []; + const lines = selection.isCollapsed ? [] : selectedLines(this.#element, selection.getRangeAt(0)); + if (lines.length > 0) return lines; + const line = caretBlock(this.#element); + return line ? [line] : []; } // In a list this nests the item like Tab; elsewhere it adds or removes a leading tab. @@ -149,7 +161,12 @@ export class NoteEditor { this.#matchNestedListKind(); return; } - const line = caretBlock(this.#element); + const lines = this.#selectedLines(); + if (lines.length > 1) { + lines.forEach(command === "indent" ? indentText : outdentText); + return; + } + const [line] = lines; if (!line) return; const offset = caretOffset(line); if (command === "indent") { @@ -222,6 +239,7 @@ export class NoteEditor { element.addEventListener("click", (event) => this.#onClick(event)); element.addEventListener("copy", (event) => this.#onCopy(event)); element.addEventListener("cut", (event) => this.#onCut(event)); + element.addEventListener("paste", (event) => this.#onPaste(event)); document.addEventListener("selectionchange", () => this.#rememberSelection()); } @@ -247,6 +265,7 @@ export class NoteEditor { else this.#redo(); return; } + this.#trimSelectionToLine(); if (event.inputType === "insertParagraph") this.#itemBeforeEnter = caretElement(this.#element)?.closest(CHECKLIST_ITEM) ?? null; if (!event.isComposing) this.#checkpointTyping(event.inputType); } @@ -265,9 +284,10 @@ export class NoteEditor { if (!block || !HEADING_TAGS.has(block.tagName) || !isCaretAtEndOf(block)) return; event.preventDefault(); this.#checkpoint(); + if (!document.getSelection().isCollapsed) document.execCommand("delete"); const paragraph = document.createElement("p"); paragraph.append(document.createElement("br")); - block.after(paragraph); + (caretBlock(this.#element) ?? block).after(paragraph); placeCaretAtStart(paragraph); this.#changed(); } @@ -275,7 +295,7 @@ export class NoteEditor { #onTab(event) { event.preventDefault(); this.#checkpoint(); - if (caretElement(this.#element)?.closest("li")) { + if (caretElement(this.#element)?.closest("li") || this.#selectedLines().length > 1) { this.#indentLine(event.shiftKey ? "outdent" : "indent"); } else if (!event.shiftKey) { document.execCommand("insertText", false, "\t"); @@ -289,7 +309,7 @@ export class NoteEditor { if (!link) return; event.preventDefault(); const space = document.createTextNode(" "); - link.after(space); + document.getSelection().getRangeAt(0).insertNode(space); placeCaret(space, 1); this.#changed(); } @@ -348,7 +368,7 @@ export class NoteEditor { #rangeToCopy() { const selection = document.getSelection(); if (!selection.rangeCount || !this.#element.contains(selection.anchorNode)) return null; - if (!selection.isCollapsed) return selection.getRangeAt(0); + if (!selection.isCollapsed) return withoutNextLineStart(this.#element, selection.getRangeAt(0)); const line = caretBlock(this.#element); if (!line) return null; const range = document.createRange(); @@ -360,8 +380,69 @@ export class NoteEditor { #onCut(event) { if (!this.#onCopy(event) || !this.#element.isContentEditable) return; this.#checkpoint(); - if (document.getSelection().isCollapsed) this.#removeCaretLine(); - else document.execCommand("delete"); + if (document.getSelection().isCollapsed) { + this.#removeCaretLine(); + return; + } + const reachedNextLine = this.#trimSelectionToLine(); + document.execCommand("delete"); + if (reachedNextLine && caretBlock(this.#element)?.textContent === "") this.#removeCaretLine(); + } + + // Replacing a selection that reaches into the next line would pull that line up into this one. + // Returns whether the selection did reach it. + #trimSelectionToLine() { + const selection = document.getSelection(); + if (selection.isCollapsed || !this.#element.contains(selection.anchorNode)) return false; + const range = selection.getRangeAt(0); + const trimmed = withoutNextLineStart(this.#element, range); + if (trimmed === range) return false; + selection.removeAllRanges(); + selection.addRange(endingInsideText(trimmed)); + return true; + } + + // Cleaned here, not after the browser pastes: once in the note, a line break of the page's + // source can't be told from a real one. With Ctrl+Shift+V Chrome offers only the text. + #onPaste(event) { + if (!this.#element.isContentEditable) return; + const html = event.clipboardData.getData("text/html"); + const text = event.clipboardData.getData("text/plain"); + if (!html && !text) return; + event.preventDefault(); + this.#checkpoint(); + this.#trimSelectionToLine(); + const cleanHtml = html ? sanitizePastedHtml(html) : ""; + if (cleanHtml) document.execCommand("insertHTML", false, cleanHtml); + else this.#insertPlainText(text); + this.#keepPastedLinesAsItems(); + this.#ensureContent(); + this.#changed(); + } + + #keepPastedLinesAsItems() { + const item = caretElement(this.#element)?.closest("li"); + if (!item) return; + const saved = saveSelection(); + restoreSelection(saved, splitParagraphsIntoItems(item)); + } + + #insertPlainText(text) { + const lines = normalizeLineBreaks(text).replace(/\n$/, ""); + const caretAt = caretElement(this.#element); + if (!lines.includes("\n") || caretAt?.closest("pre")) document.execCommand("insertText", false, lines); + else if (caretAt?.closest("li")) this.#insertLinesAsItems(lines.split("\n").filter(Boolean), caretAt.closest("li").parentElement); + else document.execCommand("insertHTML", false, plainTextToHtml(text)); + } + + // Blank lines are dropped: Enter on an empty item would leave the list. + #insertLinesAsItems(lines, list) { + const existing = new Set(list.children); + lines.forEach((line, index) => { + if (index > 0) document.execCommand("insertParagraph"); + document.execCommand("insertText", false, line); + }); + [...list.children].filter((item) => !existing.has(item)).forEach(clearItemState); } #removeCaretLine() { @@ -379,14 +460,18 @@ export class NoteEditor { const link = event.target.closest?.("a[href]"); if (!link) return; event.preventDefault(); - window.open(link.href, "_blank", "noopener"); + // A plain click must stay free for placing the caret and selecting the link's text. + const opensLink = (this.#isMac ? event.metaKey : event.ctrlKey) || !this.#element.isContentEditable; + if (opensLink) window.open(link.href, "_blank", "noopener"); } #setBlock(tag) { - const block = caretBlock(this.#element); - if (!block) return; - const offset = caretOffset(block); - setCaretOffset(setBlockType(block, tag), offset); + this.#retype(this.#selectedLines(), tag); + } + + #retype(lines, tag) { + const saved = saveSelection(); + restoreSelection(saved, new Map(lines.map((line) => [line, setBlockType(line, tag)]))); } #toggleList(kind) { @@ -395,18 +480,16 @@ export class NoteEditor { document.execCommand(listCommand(current)); return; } - const block = caretBlock(this.#element); - if (!current && block && block.tagName !== "P" && block.tagName !== "DIV") setCaretOffset(setBlockType(block, "p"), 0); + if (!current) this.#retype(this.#selectedLines().filter((line) => !LIST_READY_TAGS.has(line.tagName)), "p"); if (!current || (current.tagName === "OL") !== (kind === "numbered")) { document.execCommand(kind === "numbered" ? "insertOrderedList" : "insertUnorderedList"); } const list = caretElement(this.#element)?.closest("ul,ol"); if (!list) return; - unwrapStyledSpans(list); applyListKind(list, kind); - const { focusNode, focusOffset } = document.getSelection(); + const saved = saveSelection(); liftListOutOfParagraph(list); - placeCaret(focusNode, focusOffset); + restoreSelection(saved); } // Chrome's list and block commands rebuild the line and drop the caret at its start. @@ -447,13 +530,16 @@ export class NoteEditor { const match = node.textContent.slice(0, end).match(URL_BEFORE_CARET); if (!match) return null; this.#checkpoint(); + const url = linkableUrl(match[1]); + const punctuation = match[1].length - url.length; const range = document.createRange(); range.setStart(node, end - match[1].length); - range.setEnd(node, end); + range.setEnd(node, end - punctuation); const link = document.createElement("a"); - link.href = match[1]; + link.href = url; range.surroundContents(link); - placeCaretAfter(link); + if (punctuation > 0) placeCaret(link.nextSibling, punctuation); + else placeCaretAfter(link); return link; } @@ -518,8 +604,21 @@ export class NoteEditor { } #changed() { + this.#stripBrowserStyling(); this.#onChange(this.#element.innerHTML); } + + // Chrome pins the old look on text with inline styles when lines merge, a list changes type or + // bold is toggled in a heading. Notes have no inline styles, so these only get in the way. + #stripBrowserStyling() { + const styled = this.#element.querySelectorAll("[style]"); + const wrappers = this.#element.querySelectorAll("span, font"); + if (styled.length === 0 && wrappers.length === 0) return; + const saved = saveSelection(); + styled.forEach((element) => element.removeAttribute("style")); + wrappers.forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes)); + restoreSelection(saved); + } } function listKind(list) { @@ -540,12 +639,6 @@ function applyListKind(list, kind) { } } -// Switching between list types, Chrome wraps the text in copied -// from the old list; the note has no inline styles, so the spans only get in the way. -function unwrapStyledSpans(list) { - list.querySelectorAll("span[style]").forEach((span) => span.replaceWith(...span.childNodes)); -} - function toggleChecked(item) { item.setAttribute("data-checked", item.getAttribute("data-checked") === "true" ? "false" : "true"); } diff --git a/extension/lines.js b/extension/lines.js index 269041f..8c2009d 100644 --- a/extension/lines.js +++ b/extension/lines.js @@ -5,12 +5,15 @@ export const LINE_SELECTOR = "h1,h2,h3,p,pre,li,div"; export const ABOVE = -1; export const BELOW = 1; const TEXT_NODE = 3; +const ELEMENT_NODE = 1; -// A line moves past its neighbour in the same list or at the top level; at an edge it stays. -export function moveLine(line, direction) { - const neighbour = direction === ABOVE ? line.previousElementSibling : line.nextElementSibling; +// Lines move past their neighbour in the same list or at the top level; at an edge they stay. +export function moveLines(lines, direction) { + if (lines.length === 0 || lines.some((line) => line.parentElement !== lines[0].parentElement)) return false; + const neighbour = direction === ABOVE ? lines[0].previousElementSibling : lines.at(-1).nextElementSibling; if (!neighbour) return false; - insertBeside(neighbour, line, direction); + if (direction === ABOVE) lines.at(-1).after(neighbour); + else lines[0].before(neighbour); return true; } @@ -64,6 +67,45 @@ export function previousLine(root, line) { return lines[lines.indexOf(line) - 1] ?? null; } +// Triple-click and Shift+Down end a selection at the very start of the next line, which looks +// unselected, so copies and styles stop at the end of the line before it. +export function withoutNextLineStart(root, range) { + const line = lineOf(root, range.endContainer); + if (range.collapsed || !line || line.contains(range.startContainer)) return range; + const head = range.cloneRange(); + head.setStart(line, 0); + if (head.toString() !== "") return range; + let boundary = line; + while (boundary.parentElement !== root && !boundary.previousSibling) boundary = boundary.parentElement; + const trimmed = range.cloneRange(); + trimmed.setEndBefore(boundary); + return trimmed; +} + +// For a range that becomes the live selection: between two blocks Chrome snaps its end back to +// the next line, so it has to sit inside the text. A copy needs the block end, to keep its line. +export function endingInsideText(range) { + let last = range.endContainer.childNodes[range.endOffset - 1]; + if (!last) return range; + while (last.lastChild) last = last.lastChild; + const moved = range.cloneRange(); + if (last.nodeType === TEXT_NODE) moved.setEnd(last, last.length); + else moved.setEndBefore(last); + return moved; +} + +const LINE_OR_LIST = `${LINE_SELECTOR},ul,ol`; +const isLineOrList = (node) => node.nodeType === ELEMENT_NODE && node.matches(LINE_OR_LIST); +const onlyHoldsLines = (line) => line.hasChildNodes() && [...line.childNodes].every(isLineOrList); +const selectionIsInside = (node, range) => isLineOrList(node) && node.contains(range.startContainer) && node.contains(range.endContainer); + +// A list item is left out when only items nested inside it are selected. +export function selectedLines(root, range) { + const trimmed = withoutNextLineStart(root, range); + return [...root.querySelectorAll(LINE_SELECTOR)].filter((line) => trimmed.intersectsNode(line) + && !onlyHoldsLines(line) && ![...line.children].some((child) => selectionIsInside(child, trimmed))); +} + function insertBeside(line, added, direction) { if (direction === ABOVE) line.before(added); else line.after(added); diff --git a/extension/lists.js b/extension/lists.js index d9fb86e..8caf0a9 100644 --- a/extension/lists.js +++ b/extension/lists.js @@ -68,6 +68,41 @@ export function itemCreatedBySplit(itemBefore, itemAfter) { return itemAfter === itemBefore ? itemBefore.previousElementSibling : itemAfter; } +export function clearItemState(item) { + if (item.hasAttribute("data-checked")) item.setAttribute("data-checked", "false"); + item.removeAttribute("data-points"); + item.removeAttribute("data-status"); +} + +function emptyItemLike(item) { + const next = item.cloneNode(false); + clearItemState(next); + return next; +} + +// Chrome leaves lines pasted into a list item as

s inside it; each should be its own item. +// Whatever follows a pasted line, such as the rest of the old line, stays with it. Returns which +// item replaced which

. +export function splitParagraphsIntoItems(item) { + const replaced = new Map(); + const firstParagraph = [...item.children].find((child) => child.tagName === "P"); + if (!firstParagraph) return replaced; + let last = item; + for (const node of [firstParagraph, ...siblingsAfter(firstParagraph)]) { + if (node.tagName !== "P") { + last.append(node); + continue; + } + const next = emptyItemLike(item); + next.append(...node.childNodes); + last.after(next); + node.remove(); + replaced.set(node, next); + last = next; + } + return replaced; +} + const LIST_SHORTCUTS = { "-": "dashed", "*": "bulleted", "•": "bulleted", "1.": "numbered", "1)": "numbered" }; const CHECKBOX_SHORTCUTS = { "[ ]": false, "[]": false, "[x]": true, "[X]": true }; const TEXT_NODE = 3; diff --git a/extension/notes.css b/extension/notes.css index a169f08..4df6507 100644 --- a/extension/notes.css +++ b/extension/notes.css @@ -163,7 +163,7 @@ body:not(.viewing-deleted) #recover-button { display: none; } border-color: var(--accent); background: var(--accent) var(--check-mark) center / 75% no-repeat; } -.editor a { color: var(--accent-text); cursor: pointer; } +.editor a { color: var(--accent-text); cursor: text; } /* Sprints: one screen at a time — a sprint or the backlog, all sprints by month, or one task. */ .board { margin-bottom: 0.5rem; container-type: inline-size; } diff --git a/extension/sanitize.js b/extension/sanitize.js index 926b673..82ac731 100644 --- a/extension/sanitize.js +++ b/extension/sanitize.js @@ -2,6 +2,8 @@ import { parseHtml } from "./model.js"; const ELEMENT_NODE = 1; const TEXT_NODE = 3; +const COMMENT_NODE = 8; +const SHOW_TEXT = 4; const KEPT_TAGS = { h1: "h1", h2: "h2", h3: "h3", h4: "h3", h5: "h3", h6: "h3", p: "p", pre: "pre", ul: "ul", ol: "ol", li: "li", b: "b", strong: "b", i: "i", em: "i", u: "u", s: "s", strike: "s", del: "s", a: "a", br: "br", @@ -22,15 +24,74 @@ const STATUS_ID = /^[a-z0-9-]{1,24}$/; const SECTION_IDS = new Set(["last", "current"]); const SAFE_URL = /^(https?:|mailto:)/i; const ROOT_BLOCKS = new Set(["h1", "h2", "h3", "p", "pre", "ul", "ol"]); +const BOLD_TAGS = new Set(["b", "strong"]); +const NOT_BOLD_STYLE = /font-weight:\s*(normal|[1-4]00)\b/i; +const LIST_TAGS = new Set(["UL", "OL"]); export function sanitizeHtml(html) { - const source = parseHtml(html).body; + return cleanBody(parseHtml(html).body); +} + +// For HTML from another app. A whole note cannot go through this: the "\n" in its text is a real +// line, while in a page's source it is only how the markup was laid out. +export function sanitizePastedHtml(html) { + const { body } = parseHtml(html); + collapseSourceWhitespace(body); + return cleanBody(body); +} + +function cleanBody(source) { const target = source.ownerDocument.createElement("div"); appendClean(source, target); wrapLooseInline(target); return target.innerHTML; } +const KEEPS_WHITESPACE = 'pre, textarea, [style*="white-space:pre"], [style*="white-space: pre"]'; +const LINE_EDGE_TAGS = new Set([...BLOCK_TAGS, ...CELL_TAGS, "body", "br"]); +const isLineEdge = (node) => node.nodeType === ELEMENT_NODE && LINE_EDGE_TAGS.has(node.tagName.toLowerCase()); + +function adjacentContent(node, direction) { + let sibling = node[direction]; + while (sibling?.nodeType === COMMENT_NODE) sibling = sibling[direction]; + return sibling; +} + +const touchesLineEdge = (node, direction) => { + const sibling = adjacentContent(node, direction); + return sibling ? isLineEdge(sibling) : isLineEdge(node.parentElement); +}; + +// Follows how a browser renders source text; only ASCII whitespace, so   stays. +function collapseSourceWhitespace(root) { + const walker = root.ownerDocument.createTreeWalker(root, SHOW_TEXT); + const textNodes = []; + while (walker.nextNode()) if (!walker.currentNode.parentElement.closest(KEEPS_WHITESPACE)) textNodes.push(walker.currentNode); + for (const node of textNodes) { + let text = node.textContent.replace(/[ \t\r\n\f]+/g, " "); + if (touchesLineEdge(node, "previousSibling")) text = text.replace(/^ /, ""); + if (touchesLineEdge(node, "nextSibling")) text = text.replace(/ $/, ""); + node.textContent = text; + } +} + +export const normalizeLineBreaks = (text) => text.replace(/\r\n?/g, "\n"); + +const escapeHtml = (text) => text.replace(/&/g, "&").replace(//g, ">"); + +export function plainTextToHtml(text) { + return normalizeLineBreaks(text).replace(/\n$/, "").split("\n").map((line) => `

${escapeHtml(line) || "
"}

`).join(""); +} + +const URL_TRAILING_PUNCTUATION = /[.,;:!?'\]]+$/; +const countOf = (text, char) => text.split(char).length - 1; + +// Punctuation typed after a URL belongs to the sentence; a ")" only stays if the URL opened it. +export function linkableUrl(text) { + const url = text.replace(URL_TRAILING_PUNCTUATION, ""); + return url.endsWith(")") && countOf(url, ")") > countOf(url, "(") ? linkableUrl(url.slice(0, -1)) : url; +} + // Text left at the top level has no block to style or count as a line, so it gets one. function wrapLooseInline(container) { let paragraph = null; @@ -62,6 +123,10 @@ function appendElement(element, target) { const tag = element.tagName.toLowerCase(); // Ticks and dashes Notelet adds to its own copies (see clipboard.js). if (DROPPED.has(tag) || element.hasAttribute("data-copy-marker")) return; + if (isOnlyParagraphOfItem(element)) { + appendClean(element, target); + return; + } const cleanTag = cleanTagFor(element, tag); if (cleanTag === null) { appendClean(element, target); @@ -74,8 +139,19 @@ function appendElement(element, target) { target.append(clean); } +// Docs and Word put each list item's text in a

; left there, the

would be the line that +// styles and line moves act on, instead of the item. +function isOnlyParagraphOfItem(element) { + const item = element.parentElement; + if (element.tagName !== "P" || item?.tagName !== "LI") return false; + const hasOwnText = [...item.childNodes].some((node) => node.nodeType === TEXT_NODE && node.textContent.trim() !== ""); + return !hasOwnText && [...item.children].filter((child) => !LIST_TAGS.has(child.tagName)).length === 1; +} + function cleanTagFor(element, tag) { if (tag === "a") return SAFE_URL.test(element.getAttribute("href")?.trim() ?? "") ? "a" : null; + // Google Docs wraps a whole copy in . + if (BOLD_TAGS.has(tag) && NOT_BOLD_STYLE.test(element.getAttribute("style") ?? "")) return null; if (Object.hasOwn(KEPT_TAGS, tag)) return KEPT_TAGS[tag]; if (PARAGRAPH_LIKE.has(tag)) return hasBlockChild(element) ? null : "p"; return null; diff --git a/extension/selection.js b/extension/selection.js index 609b31f..325a8fa 100644 --- a/extension/selection.js +++ b/extension/selection.js @@ -67,6 +67,23 @@ export function placeCaretAfter(node) { selectRange(range); } +// Selection fields are live, so this copies them. Text nodes survive a line changing type or +// moving, which lets restoreSelection put back exactly what was selected; replaced maps lines +// that were swapped for a new element, for selections that sit on the line itself. +export function saveSelection() { + const { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection(); + return { anchorNode, anchorOffset, focusNode, focusOffset }; +} + +export function restoreSelection(saved, replaced = new Map()) { + const anchor = replaced.get(saved.anchorNode) ?? saved.anchorNode; + const focus = replaced.get(saved.focusNode) ?? saved.focusNode; + if (!anchor?.isConnected || !focus?.isConnected) return; + document.getSelection().setBaseAndExtent(anchor, Math.min(saved.anchorOffset, nodeLength(anchor)), focus, Math.min(saved.focusOffset, nodeLength(focus))); +} + +const nodeLength = (node) => (node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length); + function selectRange(range) { const selection = document.getSelection(); selection.removeAllRanges(); diff --git a/tests/clipboard.test.js b/tests/clipboard.test.js index a8f550f..e0a61e5 100644 --- a/tests/clipboard.test.js +++ b/tests/clipboard.test.js @@ -82,3 +82,9 @@ test("text selected inside a heading or Monostyled line copies with its style", assert.equal(selectedContent(selectionInside(body.querySelector("pre").firstChild)).innerHTML, "

mono
"); assert.equal(selectedContent(selectionInside(body.querySelector("p").firstChild)).innerHTML, "Body"); }); + +test("inline styles left in a note by the browser stay out of the copied HTML", () => { + const html = clipboardHtml(root('

a b

  • c
')); + assert.doesNotMatch(html, /font-size|color/); + assert.match(html, /]*style="list-style: none; padding-left: 0"/); +}); diff --git a/tests/lines.test.js b/tests/lines.test.js index 8183068..ddd3612 100644 --- a/tests/lines.test.js +++ b/tests/lines.test.js @@ -3,7 +3,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseHtml } from "../extension/model.js"; import { - ABOVE, BELOW, duplicateLine, indentText, insertLine, lineOf, moveLine, nextLine, outdentText, previousLine, removeLine, + ABOVE, BELOW, duplicateLine, indentText, insertLine, lineOf, moveLines, nextLine, outdentText, previousLine, removeLine, } from "../extension/lines.js"; const NOTE = "

Title

one

  • a
    • nested
  • b

two

"; @@ -12,10 +12,10 @@ const line = (body, text) => [...body.querySelectorAll("h1,p,li")].find((element test("a line moves past its neighbour and stays put at an edge", () => { const body = note(); - assert.equal(moveLine(line(body, "one"), ABOVE), true); + assert.equal(moveLines([line(body, "one")], ABOVE), true); assert.match(body.innerHTML, /^

one<\/p>

Title<\/h1>/); - assert.equal(moveLine(line(body, "b"), BELOW), false); - assert.equal(moveLine(line(body, "b"), ABOVE), true); + assert.equal(moveLines([line(body, "b")], BELOW), false); + assert.equal(moveLines([line(body, "b")], ABOVE), true); assert.match(body.innerHTML, /
  • b<\/li>
  • a/); }); @@ -63,3 +63,12 @@ test("the line of a node is its block, never the note itself", () => { assert.equal(lineOf(body, line(body, "one").firstChild), line(body, "one")); assert.equal(lineOf(body, body), null); }); + +test("selected lines move together past the next neighbour", () => { + const body = parseHtml("

    a

    b

    c

    ").body; + const [a, b] = body.children; + assert.equal(moveLines([a, b], BELOW), true); + assert.equal(body.innerHTML, "

    c

    a

    b

    "); + assert.equal(moveLines([a, b], BELOW), false); + assert.equal(moveLines([], BELOW), false); +}); diff --git a/tests/lists.test.js b/tests/lists.test.js index 80eac18..7e9c6ba 100644 --- a/tests/lists.test.js +++ b/tests/lists.test.js @@ -3,7 +3,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseHtml } from "../extension/model.js"; import { - checkboxForShortcut, itemCreatedBySplit, liftListOutOfParagraph, listKindForShortcut, removeLeadingText, setBlockType, + checkboxForShortcut, itemCreatedBySplit, liftListOutOfParagraph, listKindForShortcut, removeLeadingText, setBlockType, splitParagraphsIntoItems, } from "../extension/lists.js"; function paragraphWith(...children) { @@ -139,3 +139,15 @@ test("an empty leftover after the list does not become a blank paragraph", () => liftListOutOfParagraph(list); assert.equal(body.innerHTML, "
    "); }); + +test("lines pasted into a list item become items, and the old line's tail stays last", () => { + const body = parseHtml('
    • a

      b

      c

      tail
      • kid
    ').body; + splitParagraphsIntoItems(body.querySelector("li")); + assert.equal(body.innerHTML, '
    • a
    • b
    • ctail
      • kid
    '); +}); + +test("an item without pasted lines is left alone", () => { + const body = parseHtml("
    • a
    ").body; + assert.equal(splitParagraphsIntoItems(body.querySelector("li")).size, 0); + assert.equal(body.innerHTML, "
    • a
    "); +}); diff --git a/tests/sanitize.test.js b/tests/sanitize.test.js index 8f3265e..d18e409 100644 --- a/tests/sanitize.test.js +++ b/tests/sanitize.test.js @@ -2,7 +2,7 @@ import "./dom.js"; import { test } from "node:test"; import assert from "node:assert/strict"; import { parseHtml } from "../extension/model.js"; -import { sanitizeHtml } from "../extension/sanitize.js"; +import { linkableUrl, normalizeLineBreaks, plainTextToHtml, sanitizeHtml, sanitizePastedHtml } from "../extension/sanitize.js"; test("keeps the editor's own structure unchanged", () => { const html = '

    T

    H

    S

    a b c d e

    ' @@ -74,3 +74,47 @@ test("section headings keep their section marker; other values are dropped", () assert.equal(kept.getAttribute("data-section"), "current"); assert.equal(sanitizeHtml('

    x

    y

    '), "

    x

    y

    "); }); + +test("Google Docs' not-bold wrapper does not turn the pasted text bold", () => { + const pasted = '

    Plain

    Text

    '; + assert.equal(sanitizeHtml(pasted), "

    Plain

    Text

    "); +}); + +test("real bold still pastes as bold", () => { + assert.equal(sanitizeHtml('

    a b

    '), "

    a b

    "); +}); + +test("a list item's single paragraph from Docs or Word becomes the item's own text", () => { + assert.equal(sanitizeHtml("
    • one

    "), "
    • one
    "); + assert.equal(sanitizeHtml("
    • one

      • two

    "), "
    • one
      • two
    "); + assert.equal(sanitizeHtml("
    • one

      two

    "), "
    • one

      two

    "); +}); + +test("line breaks and indents of a page's HTML source do not show up as text", () => { + const pasted = "
    \n

    line one\n wraps here

    \n
      \n
    • \n item\n
    • \n
    \n
    "; + assert.equal(sanitizePastedHtml(pasted), "

    line one wraps here

    • item
    "); +}); + +test("pasted HTML keeps spaces between inline words and real preformatted text", () => { + assert.equal(sanitizePastedHtml("

    a b c d

    "), "

    a b c d

    "); + assert.equal(sanitizePastedHtml("
    a\n  b
    "), "
    a\n  b
    "); + assert.equal(sanitizePastedHtml('
    code
    '), "

    code

    "); + assert.notEqual(sanitizePastedHtml("

    x\u00a0

    "), "

    x

    "); +}); + +test("Windows clipboard fragment markers do not leave blank text", () => { + assert.equal(sanitizePastedHtml("\r\n

    a

    \r\n"), "

    a

    "); +}); + +test("plain text becomes one line per line, on Windows and elsewhere", () => { + assert.equal(plainTextToHtml("a\r\nb\r\n"), "

    a

    b

    "); + assert.equal(plainTextToHtml("a\n\n"), "

    a


    <b>

    "); + assert.equal(normalizeLineBreaks("a\r\nb\rc"), "a\nb\nc"); +}); + +test("punctuation typed after a URL stays out of the link", () => { + assert.equal(linkableUrl("https://a.com/x."), "https://a.com/x"); + assert.equal(linkableUrl("https://a.com/x),"), "https://a.com/x"); + assert.equal(linkableUrl("https://a.com/wiki/A_(b)"), "https://a.com/wiki/A_(b)"); + assert.equal(linkableUrl("https://a.com/?q=1"), "https://a.com/?q=1"); +}); From 4bc87a5b05171e54e9f6ac9c59e64718b3a6487a Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Mon, 9 Nov 2026 12:40:24 +0700 Subject: [PATCH 3/3] chore: release 0.8.0 --- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 16c9058..21bdc9d 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notelet" -version = "0.7.1" +version = "0.8.0" description = "Lightweight notes with sprint tracking" edition = "2021" publish = false diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 6bbc624..5787f67 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.7.1", + "version": "0.8.0", "identifier": "com.ttncode.notelet", "build": { "frontendDist": "../extension" diff --git a/package-lock.json b/package-lock.json index 826b219..8b96867 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notelet", - "version": "0.7.1", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notelet", - "version": "0.7.1", + "version": "0.8.0", "devDependencies": { "@tauri-apps/cli": "^2.12.4", "linkedom": "^0.18.13" diff --git a/package.json b/package.json index afb6114..db17f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notelet", - "version": "0.7.1", + "version": "0.8.0", "private": true, "type": "module", "scripts": {