From 748373c68b799c5f8759c04d11fbde252898b3c9 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Sat, 12 Sep 2026 19:06:12 -0400 Subject: [PATCH] fix(web): keep nested task states out of parent bullets --- apps/web/src/markdown-clipboard.test.ts | 71 +++++++++++++++++++++++-- apps/web/src/markdown-clipboard.ts | 4 +- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 9296967e4b82..a08dc299161b 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -19,6 +19,7 @@ class FakeText { class FakeElement { readonly nodeType = ELEMENT_NODE; + checked = false; readonly childNodes: Array = []; readonly classList = { contains: (name: string) => this.classNames.includes(name), @@ -61,18 +62,31 @@ class FakeElement { /** Supports only the selectors markdown-clipboard actually asks for. */ querySelector(selector: string): FakeElement | null { + if (selector.includes(", ")) { + for (const part of selector.split(", ")) { + const match = this.querySelector(part); + if (match) return match; + } + return null; + } const childOnly = selector.startsWith(":scope > "); - const target = childOnly ? selector.slice(":scope > ".length) : selector; + const [target, ...rest] = (childOnly ? selector.slice(":scope > ".length) : selector).split( + " > ", + ); const matches = (element: FakeElement): boolean => { if (target === 'input[type="checkbox"]') { return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; } - return element.tagName === target.toUpperCase(); + return element.tagName === target?.toUpperCase(); }; const search = (parent: FakeElement): FakeElement | null => { for (const child of parent.childNodes) { if (!(child instanceof FakeElement)) continue; - if (matches(child)) return child; + if (matches(child)) { + if (rest.length === 0) return child; + const nested = child.querySelector(`:scope > ${rest.join(" > ")}`); + if (nested) return nested; + } if (!childOnly) { const nested = search(child); if (nested) return nested; @@ -143,6 +157,57 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); }); + describe.each([ + { parentLayout: "tight", childLayout: "tight" }, + { parentLayout: "tight", childLayout: "loose" }, + { parentLayout: "loose", childLayout: "tight" }, + { parentLayout: "loose", childLayout: "loose" }, + ])("$parentLayout parent with $childLayout child", ({ parentLayout, childLayout }) => { + it.each([ + { parentChecked: null, childChecked: true, parent: "- Parent", child: " - [x] Child" }, + { + parentChecked: false, + childChecked: true, + parent: "- [ ] Parent", + child: " - [x] Child", + }, + { + parentChecked: true, + childChecked: false, + parent: "- [x] Parent", + child: " - [ ] Child", + }, + ])("copies $parent with $child", ({ parentChecked, childChecked, parent, child }) => { + const parentContent = parentLayout === "loose" ? new FakeElement("P") : new FakeElement("LI"); + if (parentChecked !== null) { + const checkbox = new FakeElement("INPUT", [], { type: "checkbox" }); + checkbox.checked = parentChecked; + parentContent.append(checkbox, new FakeText(" ")); + } + parentContent.append(new FakeText("Parent")); + const parentItem = + parentLayout === "loose" ? new FakeElement("LI").append(parentContent) : parentContent; + const checkbox = new FakeElement("INPUT", [], { type: "checkbox" }); + checkbox.checked = childChecked; + const childContent = new FakeElement(childLayout === "loose" ? "P" : "LI").append( + checkbox, + new FakeText(" Child"), + ); + const childItem = + childLayout === "loose" ? new FakeElement("LI").append(childContent) : childContent; + parentItem.append(new FakeText("\n"), new FakeElement("UL").append(childItem)); + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Before")), + new FakeElement("UL").append(parentItem), + new FakeElement("P").append(new FakeText("After")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + `Before\n\n${parent}${parentLayout === "loose" ? "\n\n" : "\n"}${child}\n\nAfter`, + ); + }); + }); + it("copies the complete quote, source, and comment instead of the comment-only chip label", () => { const citation = { version: 1 as const, diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 4a96c8b31d13..39b941801638 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -119,7 +119,9 @@ function serializeTable(table: Element): string { } function serializeListItem(item: Element, ordered: boolean, index: number): string { - const checkbox = item.querySelector('input[type="checkbox"]'); + const checkbox = item.querySelector( + ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]', + ); const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; const marker = ordered ? `${index}. ${task}` : `- ${task}`; let content = serializeChildren(item)