authorgravatar for dominic@sdko.orgDominic Roy <dominic@sdko.org> 2026-09-25 01:54:08-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 11:24:08+05:30
log20f0ff1781fb02898a6fa5f97f76f36079cff0b8
tree7d170ae0848319c9cee48e5a1db7c23f29234c04
parenta107f8a0792c3add90d398602998c55c3ebaef27
signature Signed by PGP key B5690EEEBB952194

fix(web): keep nested task states out of parent bullets (#11477)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

2 files changed, 71 insertions(+), 4 deletions(-)

apps/web/src/markdown-clipboard.test.ts+68-3
......@@ -19,6 +19,7 @@ class FakeText {
1919
2020class FakeElement {
2121 readonly nodeType = ELEMENT_NODE;
22 checked = false;
2223 readonly childNodes: Array<FakeElement | FakeText> = [];
2324 readonly classList = {
2425 contains: (name: string) => this.classNames.includes(name),
......@@ -61,18 +62,31 @@ class FakeElement {
6162
6263 /** Supports only the selectors markdown-clipboard actually asks for. */
6364 querySelector(selector: string): FakeElement | null {
65 if (selector.includes(", ")) {
66 for (const part of selector.split(", ")) {
67 const match = this.querySelector(part);
68 if (match) return match;
69 }
70 return null;
71 }
6472 const childOnly = selector.startsWith(":scope > ");
65 const target = childOnly ? selector.slice(":scope > ".length) : selector;
73 const [target, ...rest] = (childOnly ? selector.slice(":scope > ".length) : selector).split(
74 " > ",
75 );
6676 const matches = (element: FakeElement): boolean => {
6777 if (target === 'input[type="checkbox"]') {
6878 return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";
6979 }
70 return element.tagName === target.toUpperCase();
80 return element.tagName === target?.toUpperCase();
7181 };
7282 const search = (parent: FakeElement): FakeElement | null => {
7383 for (const child of parent.childNodes) {
7484 if (!(child instanceof FakeElement)) continue;
75 if (matches(child)) return child;
85 if (matches(child)) {
86 if (rest.length === 0) return child;
87 const nested = child.querySelector(`:scope > ${rest.join(" > ")}`);
88 if (nested) return nested;
89 }
7690 if (!childOnly) {
7791 const nested = search(child);
7892 if (nested) return nested;
......@@ -143,6 +157,57 @@ describe("serializeRenderedMarkdownFragment", () => {
143157 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
144158 });
145159
160 describe.each([
161 { parentLayout: "tight", childLayout: "tight" },
162 { parentLayout: "tight", childLayout: "loose" },
163 { parentLayout: "loose", childLayout: "tight" },
164 { parentLayout: "loose", childLayout: "loose" },
165 ])("$parentLayout parent with $childLayout child", ({ parentLayout, childLayout }) => {
166 it.each([
167 { parentChecked: null, childChecked: true, parent: "- Parent", child: " - [x] Child" },
168 {
169 parentChecked: false,
170 childChecked: true,
171 parent: "- [ ] Parent",
172 child: " - [x] Child",
173 },
174 {
175 parentChecked: true,
176 childChecked: false,
177 parent: "- [x] Parent",
178 child: " - [ ] Child",
179 },
180 ])("copies $parent with $child", ({ parentChecked, childChecked, parent, child }) => {
181 const parentContent = parentLayout === "loose" ? new FakeElement("P") : new FakeElement("LI");
182 if (parentChecked !== null) {
183 const checkbox = new FakeElement("INPUT", [], { type: "checkbox" });
184 checkbox.checked = parentChecked;
185 parentContent.append(checkbox, new FakeText(" "));
186 }
187 parentContent.append(new FakeText("Parent"));
188 const parentItem =
189 parentLayout === "loose" ? new FakeElement("LI").append(parentContent) : parentContent;
190 const checkbox = new FakeElement("INPUT", [], { type: "checkbox" });
191 checkbox.checked = childChecked;
192 const childContent = new FakeElement(childLayout === "loose" ? "P" : "LI").append(
193 checkbox,
194 new FakeText(" Child"),
195 );
196 const childItem =
197 childLayout === "loose" ? new FakeElement("LI").append(childContent) : childContent;
198 parentItem.append(new FakeText("\n"), new FakeElement("UL").append(childItem));
199 const container = new FakeElement("DIV").append(
200 new FakeElement("P").append(new FakeText("Before")),
201 new FakeElement("UL").append(parentItem),
202 new FakeElement("P").append(new FakeText("After")),
203 );
204
205 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
206 `Before\n\n${parent}${parentLayout === "loose" ? "\n\n" : "\n"}${child}\n\nAfter`,
207 );
208 });
209 });
210
146211 it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {
147212 const citation = {
148213 version: 1 as const,
apps/web/src/markdown-clipboard.ts+3-1
......@@ -119,7 +119,9 @@ function serializeTable(table: Element): string {
119119}
120120
121121function serializeListItem(item: Element, ordered: boolean, index: number): string {
122 const checkbox = item.querySelector('input[type="checkbox"]');
122 const checkbox = item.querySelector(
123 ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]',
124 );
123125 const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";
124126 const marker = ordered ? `${index}. ${task}` : `- ${task}`;
125127 let content = serializeChildren(item)