From 82e81959c02dca4233ff7c04a710e493e3fac6d8 Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 17 Jul 2026 12:23:35 +0800 Subject: [PATCH] feat: add row favorites picker --- src/content/market/dom-sync.ts | 3 + src/content/market/favorite-row-picker.ts | 359 ++++++++++++++++++++++ tests/favorite-row-picker.test.ts | 256 +++++++++++++++ tests/market-dom-sync.test.ts | 6 + 4 files changed, 624 insertions(+) create mode 100644 src/content/market/favorite-row-picker.ts create mode 100644 tests/favorite-row-picker.test.ts diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 51b1c6e..d559277 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -80,6 +80,7 @@ type MarketDataRow = { }; export interface MarketRowDom { + actionCell?: HTMLElement; authorId: string; authorName: string; backendMetricsCells: Record; @@ -587,6 +588,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { : [] ); const authorCells = getDirectContentCells(authorColumn); + const actionCells = getDirectContentCells(actionColumn); const selectionCells = getDirectContentCells(selectionColumn); const singleCells = getDirectContentCells(singleColumn); const personalCells = getDirectContentCells(personalColumn); @@ -654,6 +656,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { return [ { + actionCell: actionCells[index], authorId, authorName, backendMetricsCells: backendMetricsCells as Record, diff --git a/src/content/market/favorite-row-picker.ts b/src/content/market/favorite-row-picker.ts new file mode 100644 index 0000000..3ad8e79 --- /dev/null +++ b/src/content/market/favorite-row-picker.ts @@ -0,0 +1,359 @@ +import type { FavoriteCreatorInput, FavoriteFolder } from "./favorites-store"; + +const ACTION_SELECTOR = 'button[data-sces-favorite-row-action="button"]'; +const PICKER_ROOT_SELECTOR = '[data-sces-favorite-row-picker="root"]'; + +export interface FavoriteRowPickerRow extends FavoriteCreatorInput { + actionCell?: HTMLElement; +} + +export function syncFavoriteRowPickers(options: { + document: Document; + folders: FavoriteFolder[]; + folderIdsByAuthorId: ReadonlyMap>; + onCreateFolder: () => Promise; + onSetCreatorFolderIds: ( + creator: FavoriteCreatorInput, + folderIds: string[] + ) => Promise; + rows: FavoriteRowPickerRow[]; +}): void { + const state = getPickerDocumentState(options.document); + const currentButtons = new Set(); + + options.rows.forEach((row) => { + if (!row.actionCell) { + return; + } + + const button = ensureFavoriteActionButton(row.actionCell); + const authorId = row.authorId.trim(); + const context: FavoritePickerContext = { + button, + creator: { + authorId: row.authorId, + authorName: row.authorName, + coreUserId: row.coreUserId + }, + folders: [...options.folders], + onCreateFolder: options.onCreateFolder, + onSetCreatorFolderIds: options.onSetCreatorFolderIds, + pending: false, + selectedFolderIds: new Set( + authorId ? options.folderIdsByAuthorId.get(authorId) ?? [] : [] + ) + }; + + currentButtons.add(button); + state.contextByButton.set(button, context); + syncFavoriteActionButton(context); + button.onclick = () => { + openFavoritePicker(options.document, state, button); + }; + }); + + for (const [button] of state.contextByButton) { + if (currentButtons.has(button)) { + continue; + } + state.contextByButton.delete(button); + button.onclick = null; + } + + if ( + state.openPicker && + (!currentButtons.has(state.openPicker.button) || + !state.openPicker.button.isConnected) + ) { + closeFavoritePicker(options.document, state); + return; + } + + if (state.openPicker) { + renderFavoritePicker(options.document, state, state.openPicker.button); + } +} + +type FavoritePickerContext = { + button: HTMLButtonElement; + creator: FavoriteCreatorInput; + folders: FavoriteFolder[]; + onCreateFolder: () => Promise; + onSetCreatorFolderIds: ( + creator: FavoriteCreatorInput, + folderIds: string[] + ) => Promise; + pending: boolean; + selectedFolderIds: Set; +}; + +type OpenFavoritePicker = { + button: HTMLButtonElement; + root: HTMLElement; +}; + +type FavoritePickerDocumentState = { + contextByButton: Map; + onDocumentKeydown: (event: KeyboardEvent) => void; + onDocumentPointerDown: (event: PointerEvent) => void; + openPicker: OpenFavoritePicker | null; +}; + +const pickerDocumentStates = new WeakMap(); + +function getPickerDocumentState(document: Document): FavoritePickerDocumentState { + const existingState = pickerDocumentStates.get(document); + if (existingState) { + return existingState; + } + + const state: FavoritePickerDocumentState = { + contextByButton: new Map(), + onDocumentKeydown: () => {}, + onDocumentPointerDown: () => {}, + openPicker: null + }; + state.onDocumentKeydown = (event) => { + if (event.key === "Escape") { + closeFavoritePicker(document, state); + } + }; + state.onDocumentPointerDown = (event) => { + const openPicker = state.openPicker; + if (!openPicker) { + return; + } + + const target = event.target; + if ( + target instanceof Node && + (openPicker.root.contains(target) || openPicker.button.contains(target)) + ) { + return; + } + + closeFavoritePicker(document, state); + }; + pickerDocumentStates.set(document, state); + return state; +} + +function ensureFavoriteActionButton(actionCell: HTMLElement): HTMLButtonElement { + const controls = Array.from(actionCell.children).filter( + (child): child is HTMLButtonElement => + child instanceof HTMLButtonElement && child.matches(ACTION_SELECTOR) + ); + const button = controls.shift() ?? actionCell.ownerDocument.createElement("button"); + + controls.forEach((control) => control.remove()); + button.dataset.scesFavoriteRowAction = "button"; + button.type = "button"; + if (button.parentElement !== actionCell) { + actionCell.insertBefore(button, actionCell.firstChild); + } else if (actionCell.firstElementChild !== button) { + actionCell.insertBefore(button, actionCell.firstChild); + } + return button; +} + +function syncFavoriteActionButton(context: FavoritePickerContext): void { + const authorId = context.creator.authorId.trim(); + const count = context.selectedFolderIds.size; + const isSaved = count > 0; + + context.button.dataset.scesFavoriteCount = String(count); + context.button.dataset.scesFavoriteIcon = isSaved + ? "bookmark-filled" + : "bookmark-outline"; + context.button.dataset.scesFavoriteState = isSaved ? "saved" : "empty"; + + if (!authorId) { + context.button.disabled = true; + context.button.title = "无法收藏:缺少达人 ID"; + context.button.setAttribute("aria-label", context.button.title); + context.button.textContent = "收藏"; + return; + } + + context.button.disabled = false; + context.button.title = isSaved ? `已收藏至 ${count} 个收藏夹` : "加入收藏夹"; + context.button.setAttribute("aria-label", context.button.title); + context.button.textContent = isSaved ? "已收藏" : "收藏"; +} + +function openFavoritePicker( + document: Document, + state: FavoritePickerDocumentState, + button: HTMLButtonElement +): void { + const context = state.contextByButton.get(button); + if (!context || !context.creator.authorId.trim() || button.disabled) { + return; + } + + if (state.openPicker?.button !== button) { + closeFavoritePicker(document, state); + removeFavoritePickerRoots(document); + + const root = document.createElement("div"); + root.dataset.scesFavoriteRowPicker = "root"; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-label", "选择收藏夹"); + root.style.position = "fixed"; + root.style.zIndex = "2147483647"; + const rect = button.getBoundingClientRect(); + root.style.left = `${Math.round(rect.left)}px`; + root.style.top = `${Math.round(rect.bottom + 4)}px`; + (document.body ?? document.documentElement).appendChild(root); + state.openPicker = { button, root }; + document.addEventListener("pointerdown", state.onDocumentPointerDown); + document.addEventListener("keydown", state.onDocumentKeydown); + } + + renderFavoritePicker(document, state, button); +} + +function closeFavoritePicker( + document: Document, + state: FavoritePickerDocumentState +): void { + const openPicker = state.openPicker; + if (!openPicker) { + return; + } + + state.openPicker = null; + openPicker.root.remove(); + document.removeEventListener("pointerdown", state.onDocumentPointerDown); + document.removeEventListener("keydown", state.onDocumentKeydown); +} + +function removeFavoritePickerRoots(document: Document): void { + document.querySelectorAll(PICKER_ROOT_SELECTOR).forEach((root) => root.remove()); +} + +function renderFavoritePicker( + document: Document, + state: FavoritePickerDocumentState, + button: HTMLButtonElement +): void { + const openPicker = state.openPicker; + const context = state.contextByButton.get(button); + if (!openPicker || openPicker.button !== button || !context) { + return; + } + + const root = openPicker.root; + const folderList = document.createElement("div"); + context.folders.forEach((folder) => { + const label = document.createElement("label"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.scesFavoriteFolderId = folder.id; + checkbox.checked = context.selectedFolderIds.has(folder.id); + checkbox.disabled = context.pending; + checkbox.addEventListener("change", () => { + const nextFolderIds = readCheckedFolderIds(root, context); + renderFavoritePicker(document, state, button); + void saveFavoriteFolderIds(document, state, context, nextFolderIds); + }); + + label.append(checkbox, document.createTextNode(folder.name)); + folderList.appendChild(label); + }); + + const createButton = document.createElement("button"); + createButton.type = "button"; + createButton.dataset.scesFavoriteRowPicker = "create-folder"; + createButton.textContent = "新建收藏夹"; + createButton.disabled = context.pending; + createButton.addEventListener("click", () => { + void createAndSaveFavoriteFolder(document, state, context); + }); + + root.replaceChildren(folderList, createButton); +} + +function readCheckedFolderIds( + root: HTMLElement, + context: FavoritePickerContext +): string[] { + const knownFolderIds = new Set(context.folders.map((folder) => folder.id)); + const selectedFolderIds = new Set( + [...context.selectedFolderIds].filter((folderId) => !knownFolderIds.has(folderId)) + ); + root + .querySelectorAll("input[data-sces-favorite-folder-id]") + .forEach((checkbox) => { + const folderId = checkbox.dataset.scesFavoriteFolderId; + if (folderId && checkbox.checked) { + selectedFolderIds.add(folderId); + } + }); + return [...selectedFolderIds]; +} + +async function saveFavoriteFolderIds( + document: Document, + state: FavoritePickerDocumentState, + context: FavoritePickerContext, + nextFolderIds: string[], + createdFolder?: FavoriteFolder +): Promise { + context.pending = true; + renderFavoritePicker(document, state, context.button); + + try { + await context.onSetCreatorFolderIds(context.creator, nextFolderIds); + if (state.contextByButton.get(context.button) !== context) { + return; + } + + context.selectedFolderIds = new Set(nextFolderIds); + if (createdFolder && !context.folders.some((folder) => folder.id === createdFolder.id)) { + context.folders = [...context.folders, createdFolder]; + } + syncFavoriteActionButton(context); + } catch { + // The picker keeps the last confirmed selection when persistence fails. + } finally { + context.pending = false; + renderFavoritePicker(document, state, context.button); + } +} + +async function createAndSaveFavoriteFolder( + document: Document, + state: FavoritePickerDocumentState, + context: FavoritePickerContext +): Promise { + if (context.pending) { + return; + } + + context.pending = true; + renderFavoritePicker(document, state, context.button); + try { + const folder = await context.onCreateFolder(); + if (!folder) { + return; + } + + const nextFolderIds = [...context.selectedFolderIds, folder.id]; + await context.onSetCreatorFolderIds(context.creator, nextFolderIds); + if (state.contextByButton.get(context.button) !== context) { + return; + } + + context.selectedFolderIds = new Set(nextFolderIds); + if (!context.folders.some((existingFolder) => existingFolder.id === folder.id)) { + context.folders = [...context.folders, folder]; + } + syncFavoriteActionButton(context); + } catch { + // The picker keeps the last confirmed selection when creating or saving fails. + } finally { + context.pending = false; + renderFavoritePicker(document, state, context.button); + } +} diff --git a/tests/favorite-row-picker.test.ts b/tests/favorite-row-picker.test.ts new file mode 100644 index 0000000..0058565 --- /dev/null +++ b/tests/favorite-row-picker.test.ts @@ -0,0 +1,256 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { syncFavoriteRowPickers } from "../src/content/market/favorite-row-picker"; +import type { FavoriteCreatorInput, FavoriteFolder } from "../src/content/market/favorites-store"; + +const firstFolder = createFolder("folder-1", "合作"); +const secondFolder = createFolder("folder-2", "待联系"); + +function createFolder(id: string, name: string): FavoriteFolder { + return { + createdAt: "2026-07-17T00:00:00.000Z", + id, + name, + updatedAt: "2026-07-17T00:00:00.000Z" + }; +} + +function createCreator(authorId = "author-1"): FavoriteCreatorInput { + return { + authorId, + authorName: "Alice", + coreUserId: "core-1" + }; +} + +function createActionCell(label = "下单"): HTMLElement { + const cell = document.createElement("div"); + cell.dataset.testid = "native-action-cell"; + const nativeButton = document.createElement("button"); + nativeButton.dataset.nativeAction = "order"; + nativeButton.textContent = label; + cell.appendChild(nativeButton); + document.body.appendChild(cell); + return cell; +} + +function readFavoriteButton(actionCell: HTMLElement): HTMLButtonElement { + const button = actionCell.querySelector( + ':scope > button[data-sces-favorite-row-action="button"]' + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected favorite action button"); + } + return button; +} + +function readPopover(): HTMLElement | null { + return document.querySelector('[data-sces-favorite-row-picker="root"]'); +} + +function sync(options: { + actionCell?: HTMLElement; + authorId?: string; + folders?: FavoriteFolder[]; + folderIds?: string[]; + onCreateFolder?: () => Promise; + onSetCreatorFolderIds?: ( + creator: FavoriteCreatorInput, + folderIds: string[] + ) => Promise; + rows?: Array<{ + actionCell?: HTMLElement; + authorId: string; + authorName: string; + coreUserId?: string; + }>; +} = {}): void { + const creator = createCreator(options.authorId); + syncFavoriteRowPickers({ + document, + folderIdsByAuthorId: new Map([ + [creator.authorId, new Set(options.folderIds ?? [])] + ]), + folders: options.folders ?? [firstFolder, secondFolder], + onCreateFolder: options.onCreateFolder ?? (async () => null), + onSetCreatorFolderIds: options.onSetCreatorFolderIds ?? (async () => {}), + rows: + options.rows ?? + (options.actionCell + ? [{ ...creator, actionCell: options.actionCell }] + : []) + }); +} + +async function flushAsyncEvents(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("favorite-row-picker", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + test("prepends one picker control without replacing the native action", () => { + const actionCell = createActionCell(); + + sync({ actionCell }); + + const button = readFavoriteButton(actionCell); + expect(actionCell.firstElementChild).toBe(button); + expect(actionCell.querySelector('[data-native-action="order"]')?.textContent).toBe( + "下单" + ); + expect(button.type).toBe("button"); + expect(button.title).toBe("加入收藏夹"); + expect(button.dataset.scesFavoriteCount).toBe("0"); + expect(button.dataset.scesFavoriteIcon).toBe("bookmark-outline"); + }); + + test("renders saved count and title from existing memberships", () => { + const actionCell = createActionCell(); + + sync({ actionCell, folderIds: [firstFolder.id, secondFolder.id] }); + + const button = readFavoriteButton(actionCell); + expect(button.title).toBe("已收藏至 2 个收藏夹"); + expect(button.dataset.scesFavoriteCount).toBe("2"); + expect(button.dataset.scesFavoriteIcon).toBe("bookmark-filled"); + }); + + test("disables a row with no author id and does not open a picker", () => { + const actionCell = createActionCell(); + + sync({ actionCell, authorId: " " }); + + const button = readFavoriteButton(actionCell); + expect(button.disabled).toBe(true); + expect(button.title).toContain("达人 ID"); + button.click(); + expect(readPopover()).toBeNull(); + }); + + test("keeps one document popover when different row buttons are clicked", () => { + const firstCell = createActionCell("下单 A"); + const secondCell = createActionCell("下单 B"); + const firstCreator = createCreator("author-1"); + const secondCreator = createCreator("author-2"); + + sync({ + rows: [ + { ...firstCreator, actionCell: firstCell }, + { ...secondCreator, actionCell: secondCell } + ] + }); + + readFavoriteButton(firstCell).click(); + expect(readPopover()).not.toBeNull(); + readFavoriteButton(secondCell).click(); + expect(document.querySelectorAll('[data-sces-favorite-row-picker="root"]')).toHaveLength(1); + expect(readPopover()?.textContent).toContain("合作"); + }); + + test("saves the complete checked folder selection for the matching creator", async () => { + const actionCell = createActionCell(); + const onSetCreatorFolderIds = vi.fn(async () => {}); + + sync({ actionCell, onSetCreatorFolderIds }); + readFavoriteButton(actionCell).click(); + const folderCheckbox = document.querySelector( + `input[data-sces-favorite-folder-id="${secondFolder.id}"]` + ) as HTMLInputElement; + folderCheckbox.checked = true; + folderCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await flushAsyncEvents(); + + expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [secondFolder.id]); + expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); + }); + + test("adds a newly created folder to the creator selection", async () => { + const actionCell = createActionCell(); + const createdFolder = createFolder("folder-3", "重点"); + const onCreateFolder = vi.fn(async () => createdFolder); + const onSetCreatorFolderIds = vi.fn(async () => {}); + + sync({ actionCell, onCreateFolder, onSetCreatorFolderIds }); + readFavoriteButton(actionCell).click(); + const createButton = document.querySelector( + '[data-sces-favorite-row-picker="create-folder"]' + ) as HTMLButtonElement; + createButton.click(); + await flushAsyncEvents(); + + expect(onCreateFolder).toHaveBeenCalledTimes(1); + expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), [createdFolder.id]); + expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); + }); + + test("keeps the visible selection when saving a checkbox change fails", async () => { + const actionCell = createActionCell(); + const onSetCreatorFolderIds = vi.fn(async () => { + throw new Error("save failed"); + }); + + sync({ actionCell, folderIds: [firstFolder.id], onSetCreatorFolderIds }); + readFavoriteButton(actionCell).click(); + const firstCheckbox = document.querySelector( + `input[data-sces-favorite-folder-id="${firstFolder.id}"]` + ) as HTMLInputElement; + firstCheckbox.checked = false; + firstCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await flushAsyncEvents(); + + expect(onSetCreatorFolderIds).toHaveBeenCalledWith(createCreator(), []); + expect( + ( + document.querySelector( + `input[data-sces-favorite-folder-id="${firstFolder.id}"]` + ) as HTMLInputElement + ).checked + ).toBe(true); + expect(readFavoriteButton(actionCell).dataset.scesFavoriteCount).toBe("1"); + }); + + test("closes the popover on outside pointer-down and Escape", () => { + const actionCell = createActionCell(); + sync({ actionCell }); + + readFavoriteButton(actionCell).click(); + document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(readPopover()).toBeNull(); + + readFavoriteButton(actionCell).click(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + expect(readPopover()).toBeNull(); + }); + + test("resyncs one control with fresh callbacks and closes an obsolete popover", async () => { + const actionCell = createActionCell(); + const staleCallback = vi.fn(async () => {}); + const currentCallback = vi.fn(async () => {}); + + sync({ actionCell, onSetCreatorFolderIds: staleCallback }); + readFavoriteButton(actionCell).click(); + expect(readPopover()).not.toBeNull(); + + sync({ actionCell, onSetCreatorFolderIds: currentCallback }); + expect( + actionCell.querySelectorAll('[data-sces-favorite-row-action="button"]') + ).toHaveLength(1); + const folderCheckbox = document.querySelector( + `input[data-sces-favorite-folder-id="${firstFolder.id}"]` + ) as HTMLInputElement; + folderCheckbox.checked = true; + folderCheckbox.dispatchEvent(new Event("change", { bubbles: true })); + await flushAsyncEvents(); + expect(staleCallback).not.toHaveBeenCalled(); + expect(currentCallback).toHaveBeenCalledWith(createCreator(), [firstFolder.id]); + + sync({ rows: [] }); + expect(readPopover()).toBeNull(); + }); +}); diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 0db3c55..f769522 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -69,6 +69,7 @@ describe("market-dom-sync", () => { expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(18); expect(table?.headerSelectionCheckbox).not.toBeNull(); expect(table?.rows[0]?.selectionCheckbox).not.toBeNull(); + expect(table?.rows[0]?.actionCell).toBeUndefined(); }); test("renders loading, success, missing, and failed states", () => { @@ -229,6 +230,11 @@ describe("market-dom-sync", () => { ) ).toBe(350); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); + expect(table.rows.map((row) => row.actionCell?.dataset.testid)).toEqual([ + "action-cell-111", + "action-cell-222" + ]); + expect(table.rows[0]?.actionCell?.textContent?.trim()).toBe("下单"); renderMarketRowState(table.rows[0], { authorId: "111",