diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 0000d96..d0540bf 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -258,6 +258,34 @@ export function syncPluginSortHeaders( }); } +export function syncMarketSelectionState( + table: MarketTableDom, + selectedAuthorIds: Set +): void { + table.rows.forEach((rowDom) => { + rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId; + rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId); + }); + + if (!table.headerSelectionCheckbox) { + return; + } + + const visibleRows = table.rows.filter((rowDom) => + rowDom.visibilityTargets.some((target) => !target.hidden) + ); + const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows; + const selectedCount = scopedRows.filter((rowDom) => + selectedAuthorIds.has(rowDom.authorId) + ).length; + + table.headerSelectionCheckbox.indeterminate = + selectedCount > 0 && selectedCount < scopedRows.length; + table.headerSelectionCheckbox.checked = + scopedRows.length > 0 && selectedCount === scopedRows.length; + table.headerSelectionCheckbox.disabled = scopedRows.length === 0; +} + function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { const header = root.querySelector("[data-market-header]") as HTMLElement | null; const body = root.querySelector("[data-market-body]") as HTMLElement | null; @@ -285,9 +313,11 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { const backendMetricsCells = Object.fromEntries( BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)]) ) as Record; + const authorId = row.dataset.authorId ?? ""; + selectionCheckbox.dataset.marketSelectionAuthorId = authorId; return { - authorId: row.dataset.authorId ?? "", + authorId, authorName: row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? "", @@ -616,6 +646,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { readDivGridPriceDisplay(priceCells[index]?.textContent), fallbackMarketRow?.price21To60s ); + selectionCheckbox.dataset.marketSelectionAuthorId = authorId; return [ { diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 25976af..674ccd6 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -6,6 +6,7 @@ import { readMarketPageSignature, renderMarketRowState, syncPluginSortHeaders, + syncMarketSelectionState, syncMarketTable, type MarketRowDom } from "./dom-sync"; @@ -99,6 +100,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { let lastKnownPageSignature = ""; let needsResync = false; let scheduledSyncTimeoutId: number | null = null; + const selectedAuthorIds = new Set(); let toolbar: ReturnType | undefined; const observer = mutationObserverFactory(() => { if (isDisposed) { @@ -114,7 +116,14 @@ export function createMarketController(options: CreateMarketControllerOptions) { const toolbarNeedsRemount = !toolbar || !isPluginToolbarMounted(toolbar.root, options.document); - if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) { + const selectionControlsMissing = + !options.document.querySelector('[data-market-selection-checkbox="row"]') || + !options.document.querySelector('[data-market-selection-checkbox="header"]'); + if ( + nextPageSignature === lastKnownPageSignature && + !toolbarNeedsRemount && + !selectionControlsMissing + ) { return; } @@ -385,10 +394,78 @@ export function createMarketController(options: CreateMarketControllerOptions) { const records = getVisibleOrderedRecords(table); applyRowVisibility(table, new Set(records.map((record) => record.authorId))); applyRowOrder(table, records.map((record) => record.authorId)); + bindSelectionControls(table); + syncMarketSelectionState(table, selectedAuthorIds); lastKnownPageSignature = readMarketPageSignature(options.document); }); } + function bindSelectionControls(table: ReturnType): void { + if (!table) { + return; + } + + table.rows.forEach((rowDom) => { + rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId; + if (rowDom.selectionCheckbox.dataset.marketSelectionBound === "true") { + return; + } + + rowDom.selectionCheckbox.dataset.marketSelectionBound = "true"; + rowDom.selectionCheckbox.addEventListener("change", () => { + if (rowDom.selectionCheckbox.checked) { + selectedAuthorIds.add(rowDom.authorId); + } else { + selectedAuthorIds.delete(rowDom.authorId); + } + + refreshSelectionControls(); + }); + }); + + if (!table.headerSelectionCheckbox) { + return; + } + + if (table.headerSelectionCheckbox.dataset.marketSelectionBound === "true") { + return; + } + + table.headerSelectionCheckbox.dataset.marketSelectionBound = "true"; + table.headerSelectionCheckbox.addEventListener("change", () => { + const currentTable = syncMarketTable(options.document); + if (!currentTable) { + return; + } + + const visibleRows = currentTable.rows.filter((rowDom) => + rowDom.visibilityTargets.some((target) => !target.hidden) + ); + const scopedRows = visibleRows.length > 0 ? visibleRows : currentTable.rows; + if (table.headerSelectionCheckbox?.checked) { + scopedRows.forEach((rowDom) => { + selectedAuthorIds.add(rowDom.authorId); + }); + } else { + scopedRows.forEach((rowDom) => { + selectedAuthorIds.delete(rowDom.authorId); + }); + } + + refreshSelectionControls(); + }); + } + + function refreshSelectionControls(): void { + const table = syncMarketTable(options.document); + if (!table) { + return; + } + + bindSelectionControls(table); + syncMarketSelectionState(table, selectedAuthorIds); + } + function toggleSortFromHeader(field: MarketSortState["field"]): void { activeSort = getNextSortState(activeSort, field); applyCurrentView(); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index efd60e2..743f7ea 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -290,6 +290,140 @@ describe("market-content-entry", () => { expect((toolbar as HTMLElement | null)?.hidden).toBe(false); }); + test("selection keeps a clicked creator checked after the table re-renders", async () => { + document.body.innerHTML = buildMarketFixture(); + const mutationObserver = createMutationObserverFactory(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + mutationObserverFactory: mutationObserver.factory, + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("a"); + expect(readSelectionCheckboxForAuthor("a").checked).toBe(true); + + const table = document.querySelector("[data-market-table]"); + if (!(table instanceof HTMLElement)) { + throw new Error("Missing market table"); + } + + table.outerHTML = buildMarketTableOnlyFixture(); + mutationObserver.trigger(); + await flushWithTimers(); + + expect(readSelectionCheckboxForAuthor("a").checked).toBe(true); + }); + + test("selection survives a page change and re-render", async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ], + [ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" } + ] + ]; + document.body.innerHTML = buildRealMarketFixture(pages[0]); + installAsyncPaginationHarness(pages); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + click('[data-testid="next-page"]'); + await flushWithTimers(); + + expect(readSelectionCheckboxForAuthor("111").checked).toBe(true); + expect(readSelectionCheckboxForAuthor("333").checked).toBe(false); + }); + + test("selection header selects all visible creators on the current page", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + clickHeaderSelectionCheckbox(); + + expect(readSelectionCheckboxForAuthor("111").checked).toBe(true); + expect(readSelectionCheckboxForAuthor("222").checked).toBe(true); + }); + + test("selection header clears all visible creators on the current page", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + clickHeaderSelectionCheckbox(); + clickHeaderSelectionCheckbox(); + + expect(readSelectionCheckboxForAuthor("111").checked).toBe(false); + expect(readSelectionCheckboxForAuthor("222").checked).toBe(false); + }); + + test("selection header becomes indeterminate when only part of the current page is selected", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }, + { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } + ]); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + clickSelectionCheckboxForAuthor("111"); + + expect(readHeaderSelectionCheckbox().checked).toBe(false); + expect(readHeaderSelectionCheckbox().indeterminate).toBe(true); + }); + test("hydrates current page rows on start", async () => { document.body.innerHTML = buildMarketFixture(); @@ -3138,6 +3272,50 @@ function click(selector: string) { element.click(); } +function clickSelectionCheckboxForAuthor(authorId: string) { + readSelectionCheckboxForAuthor(authorId).click(); +} + +function clickHeaderSelectionCheckbox() { + readHeaderSelectionCheckbox().click(); +} + +function readSelectionCheckboxForAuthor(authorId: string) { + const bySelectionAuthorId = document.querySelector( + `[data-market-selection-author-id="${authorId}"]` + ) as HTMLInputElement | null; + if (bySelectionAuthorId) { + return bySelectionAuthorId; + } + + const bySyntheticRow = document.querySelector( + `[data-market-row][data-author-id="${authorId}"] [data-market-selection-checkbox="row"]` + ) as HTMLInputElement | null; + if (bySyntheticRow) { + return bySyntheticRow; + } + + const byAuthorCell = document.querySelector( + `[data-testid="author-cell-${authorId}"] [data-market-selection-checkbox="row"]` + ) as HTMLInputElement | null; + if (byAuthorCell) { + return byAuthorCell; + } + + throw new Error(`Missing selection checkbox for author: ${authorId}`); +} + +function readHeaderSelectionCheckbox() { + const checkbox = document.querySelector( + '[data-market-selection-checkbox="header"]' + ) as HTMLInputElement | null; + if (!checkbox) { + throw new Error("Missing header selection checkbox"); + } + + return checkbox; +} + function setInputValue(selector: string, value: string) { const element = document.querySelector(selector) as HTMLInputElement | null; if (!element) {