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/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/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/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/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": {
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/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
'));
+ assert.doesNotMatch(html, /font-size|color/);
+ assert.match(html, /]*style="list-style: none; padding-left: 0"/);
+});
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({});
- }
-});
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
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('').body;
+ splitParagraphsIntoItems(body.querySelector("li"));
+ assert.equal(body.innerHTML, '');
+});
+
+test("an item without pasted lines is left alone", () => {
+ const body = parseHtml("").body;
+ assert.equal(splitParagraphsIntoItems(body.querySelector("li")).size, 0);
+ assert.equal(body.innerHTML, "");
+});
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(""), "");
+ assert.equal(sanitizeHtml(""), "");
+ assert.equal(sanitizeHtml(""), "");
+});
+
+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
";
+ assert.equal(sanitizePastedHtml(pasted), "line one wraps here
");
+});
+
+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
");
+ assert.notEqual(sanitizePastedHtml("x\u00a0
"), "x
");
+});
+
+test("Windows clipboard fragment markers do not leave blank text", () => {
+ assert.equal(sanitizePastedHtml("\r\na
\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");
+});