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 {...@@ -19,6 +19,7 @@ class FakeText {
1919
20class FakeElement {20class FakeElement {
21 readonly nodeType = ELEMENT_NODE;21 readonly nodeType = ELEMENT_NODE;
22 checked = false;
22 readonly childNodes: Array<FakeElement | FakeText> = [];23 readonly childNodes: Array<FakeElement | FakeText> = [];
23 readonly classList = {24 readonly classList = {
24 contains: (name: string) => this.classNames.includes(name),25 contains: (name: string) => this.classNames.includes(name),
...@@ -61,18 +62,31 @@ class FakeElement {...@@ -61,18 +62,31 @@ class FakeElement {
6162
62 /** Supports only the selectors markdown-clipboard actually asks for. */63 /** Supports only the selectors markdown-clipboard actually asks for. */
63 querySelector(selector: string): FakeElement | null {64 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 }
64 const childOnly = selector.startsWith(":scope > ");72 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 );
66 const matches = (element: FakeElement): boolean => {76 const matches = (element: FakeElement): boolean => {
67 if (target === 'input[type="checkbox"]') {77 if (target === 'input[type="checkbox"]') {
68 return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";78 return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";
69 }79 }
70 return element.tagName === target.toUpperCase();80 return element.tagName === target?.toUpperCase();
71 };81 };
72 const search = (parent: FakeElement): FakeElement | null => {82 const search = (parent: FakeElement): FakeElement | null => {
73 for (const child of parent.childNodes) {83 for (const child of parent.childNodes) {
74 if (!(child instanceof FakeElement)) continue;84 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 }
76 if (!childOnly) {90 if (!childOnly) {
77 const nested = search(child);91 const nested = search(child);
78 if (nested) return nested;92 if (nested) return nested;
...@@ -143,6 +157,57 @@ describe("serializeRenderedMarkdownFragment", () => {...@@ -143,6 +157,57 @@ describe("serializeRenderedMarkdownFragment", () => {
143 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");157 expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
144 });158 });
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
146 it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {211 it("copies the complete quote, source, and comment instead of the comment-only chip label", () => {
147 const citation = {212 const citation = {
148 version: 1 as const,213 version: 1 as const,
apps/web/src/markdown-clipboard.ts+3-1
...@@ -119,7 +119,9 @@ function serializeTable(table: Element): string {...@@ -119,7 +119,9 @@ function serializeTable(table: Element): string {
119}119}
120120
121function serializeListItem(item: Element, ordered: boolean, index: number): string {121function 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 );
123 const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";125 const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : "";
124 const marker = ordered ? `${index}. ${task}` : `- ${task}`;126 const marker = ordered ? `${index}. ${task}` : `- ${task}`;
125 let content = serializeChildren(item)127 let content = serializeChildren(item)