diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index c880846..0000d96 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -11,6 +11,7 @@ import type { } from "./types"; const BACKEND_COLUMN_KEY = "backendMetrics"; +const SELECTION_COLUMN_KEY = "selection"; const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate"; const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate"; const ACTION_HEADER_TEXT = "操作"; @@ -86,12 +87,14 @@ export interface MarketRowDom { price21To60s?: string; rates?: AfterSearchRates; row: HTMLElement; + selectionCheckbox: HTMLInputElement; singleCell: HTMLElement; visibilityTargets: HTMLElement[]; orderTargets: RowOrderTarget[]; } export interface MarketTableDom { + headerSelectionCheckbox: HTMLInputElement | null; rows: MarketRowDom[]; } @@ -263,6 +266,8 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { return null; } + const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, ""); + const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader); ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率"); ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => { @@ -273,6 +278,8 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { const rows = Array.from(body.querySelectorAll("[data-market-row]")).map( (rowElement) => { const row = rowElement as HTMLElement; + const selectionCell = ensureSyntheticRowCell(row, SELECTION_COLUMN_KEY); + const selectionCheckbox = ensureSelectionRowControl(selectionCell); const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY); const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY); const backendMetricsCells = Object.fromEntries( @@ -303,6 +310,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { ?.textContent?.trim() ?? "", rates: undefined, row, + selectionCheckbox, singleCell, visibilityTargets: [row] } satisfies MarketRowDom; @@ -310,6 +318,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { ); return { + headerSelectionCheckbox, rows }; } @@ -435,12 +444,16 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { bodySection, getDirectChildIndex(headerSection, authorHeader) ); + const authorHeaderSection = getIndexedChild( + headerSection, + getDirectChildIndex(headerSection, authorHeader) + ); const rightSection = getIndexedChild( bodySection, getDirectChildIndex(headerSection, actionHeader) ); - if (!authorSection || !rightSection) { + if (!authorSection || !authorHeaderSection || !rightSection) { return null; } const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection; @@ -462,6 +475,19 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { } const rowCount = getDirectContentCells(authorColumn).length; + const selectionHeaderCell = ensureDivHeaderCell( + authorHeaderSection, + authorHeader, + SELECTION_COLUMN_KEY, + "" + ); + const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeaderCell); + const selectionColumn = ensureDivBodyColumn( + authorSection, + authorColumn, + SELECTION_COLUMN_KEY, + rowCount + ); const headerTemplateCell = getDirectHeaderCells(middleHeaderSection).at(-1) ?? findPreviousHeaderCell(actionHeader) ?? @@ -510,6 +536,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { ) as Record; syncContainerWidth(pluginHeaderSection); syncContainerWidth(pluginBodySection); + syncContainerWidth(authorHeaderSection); + syncContainerWidth(authorSection); ensureVisibleHorizontalScroll(headerSection); ensureVisibleHorizontalScroll(bodySection); ensureScrollHint(root, headerSection); @@ -525,6 +553,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { : [] ); const authorCells = getDirectContentCells(authorColumn); + const selectionCells = getDirectContentCells(selectionColumn); const singleCells = getDirectContentCells(singleColumn); const personalCells = getDirectContentCells(personalColumn); const backendMetricCellsByField = Object.fromEntries( @@ -539,6 +568,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)]; const rows = authorCells.flatMap((authorCell, index) => { + const selectionCell = selectionCells[index] ?? null; const singleCell = singleCells[index] ?? null; const personalCell = personalCells[index] ?? null; const backendMetricsCells = Object.fromEntries( @@ -548,12 +578,14 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { ]) ) as Record; if ( + !selectionCell || !singleCell || !personalCell || Object.values(backendMetricsCells).some((cell) => cell === null) ) { return []; } + const selectionCheckbox = ensureSelectionRowControl(selectionCell); const alignedRowCells = allBodyColumns.map( (column) => getDirectContentCells(column)[index] ?? null @@ -612,6 +644,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { price21To60s, rates: fallbackMarketRow?.rates, row: authorCell, + selectionCheckbox, singleCell, visibilityTargets: rowCells } satisfies MarketRowDom @@ -619,6 +652,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { }); return { + headerSelectionCheckbox, rows }; } @@ -640,7 +674,11 @@ function ensureSyntheticHeaderCell( const nextCell = header.ownerDocument.createElement("div"); nextCell.dataset.marketHeaderCell = field; nextCell.textContent = label; - header.appendChild(nextCell); + if (field === SELECTION_COLUMN_KEY) { + header.insertBefore(nextCell, header.firstChild); + } else { + header.appendChild(nextCell); + } return nextCell; } @@ -655,7 +693,11 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement { const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span"); nextCell.dataset.marketRowCell = field; - row.appendChild(nextCell); + if (field === SELECTION_COLUMN_KEY) { + row.insertBefore(nextCell, row.firstChild); + } else { + row.appendChild(nextCell); + } return nextCell; } @@ -727,9 +769,12 @@ function syncDivColumnCells( const templateCell = templateCells[index] ?? templateCells[templateCells.length - 1] ?? null; - const nextCell = templateCell - ? cloneElementShallow(templateCell) - : createBareContentCell(column.ownerDocument); + const nextCell = + field === SELECTION_COLUMN_KEY + ? createBareContentCell(column.ownerDocument) + : templateCell + ? cloneElementShallow(templateCell) + : createBareContentCell(column.ownerDocument); nextCell.dataset.marketRowCell = field; applyColumnWidth(nextCell, field); applyPluginContentCellStyles(nextCell); @@ -756,6 +801,54 @@ function applyPluginContentCellStyles(cell: HTMLElement): void { cell.style.whiteSpace = "nowrap"; } +function ensureSelectionHeaderControl(cell: HTMLElement): HTMLInputElement { + cell.textContent = ""; + cell.style.gap = "6px"; + cell.style.justifyContent = "center"; + + const checkbox = ensureSelectionCheckbox(cell, "header"); + const label = cell.querySelector( + '[data-market-selection-label="header"]' + ) as HTMLElement | null; + if (label) { + label.textContent = "全选"; + return checkbox; + } + + const nextLabel = cell.ownerDocument.createElement("span"); + nextLabel.dataset.marketSelectionLabel = "header"; + nextLabel.textContent = "全选"; + nextLabel.style.fontSize = "12px"; + cell.appendChild(nextLabel); + return checkbox; +} + +function ensureSelectionRowControl(cell: HTMLElement): HTMLInputElement { + cell.textContent = ""; + cell.style.justifyContent = "center"; + return ensureSelectionCheckbox(cell, "row"); +} + +function ensureSelectionCheckbox( + container: HTMLElement, + kind: "header" | "row" +): HTMLInputElement { + const existingCheckbox = container.querySelector( + `[data-market-selection-checkbox="${kind}"]` + ) as HTMLInputElement | null; + if (existingCheckbox) { + existingCheckbox.type = "checkbox"; + return existingCheckbox; + } + + const checkbox = container.ownerDocument.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.marketSelectionCheckbox = kind; + checkbox.style.cursor = "pointer"; + container.appendChild(checkbox); + return checkbox; +} + function getOwnerDocument(root: ParentNode): Document | null { if ("ownerDocument" in root && root.ownerDocument) { return root.ownerDocument; @@ -1434,6 +1527,11 @@ function readRateCellText(value: string | undefined): string { } function applyColumnWidth(element: HTMLElement, field: string): void { + if (field === SELECTION_COLUMN_KEY) { + element.style.minWidth = "56px"; + element.style.width = "56px"; + } + if (field === BACKEND_COLUMN_KEY) { element.style.minWidth = "240px"; element.style.width = "240px"; diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 7970497..20a8042 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -39,6 +39,9 @@ describe("market-dom-sync", () => { const table = syncMarketTable(document); expect(table).not.toBeNull(); + expect( + document.querySelector('[data-market-header-cell="selection"]') + ).not.toBeNull(); expect( document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]') ).not.toBeNull(); @@ -63,7 +66,9 @@ describe("market-dom-sync", () => { expect( document.querySelector('[data-market-header-cell="cpSearch"]') ).not.toBeNull(); - expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16); + expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18); + expect(table?.headerSelectionCheckbox).not.toBeNull(); + expect(table?.rows[0]?.selectionCheckbox).not.toBeNull(); }); test("renders loading, success, missing, and failed states", () => { @@ -170,7 +175,11 @@ describe("market-dom-sync", () => { throw new Error("Expected market table"); } + expect(table.headerSelectionCheckbox).not.toBeNull(); + expect(table.rows[0]?.selectionCheckbox).not.toBeNull(); expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]); + expect(readSelectionHeaderText()).toBe("全选"); + expect(readSelectionRowCheckboxCount()).toBe(2); expect(readPluginHeaderTexts()).toEqual([ "单视频看后搜率", "个人视频看后搜率", @@ -926,6 +935,18 @@ function readScrollHintText() { ); } +function readSelectionHeaderText() { + return ( + document + .querySelector('[data-market-header-cell="selection"]') + ?.textContent?.trim() ?? "" + ); +} + +function readSelectionRowCheckboxCount() { + return document.querySelectorAll('[data-market-selection-checkbox="row"]').length; +} + function readAuthorNames() { const authorColumn = document.querySelector( '[data-testid="author-section"] .content-column'