feat: add row favorites picker

This commit is contained in:
wxs
2026-07-17 12:23:35 +08:00
parent 8ba1787177
commit 82e81959c0
4 changed files with 624 additions and 0 deletions
+3
View File
@@ -80,6 +80,7 @@ type MarketDataRow = {
};
export interface MarketRowDom {
actionCell?: HTMLElement;
authorId: string;
authorName: string;
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
@@ -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<BackendMetricField, HTMLElement>,
+359
View File
@@ -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<string, ReadonlySet<string>>;
onCreateFolder: () => Promise<FavoriteFolder | null>;
onSetCreatorFolderIds: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
rows: FavoriteRowPickerRow[];
}): void {
const state = getPickerDocumentState(options.document);
const currentButtons = new Set<HTMLButtonElement>();
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<FavoriteFolder | null>;
onSetCreatorFolderIds: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
pending: boolean;
selectedFolderIds: Set<string>;
};
type OpenFavoritePicker = {
button: HTMLButtonElement;
root: HTMLElement;
};
type FavoritePickerDocumentState = {
contextByButton: Map<HTMLButtonElement, FavoritePickerContext>;
onDocumentKeydown: (event: KeyboardEvent) => void;
onDocumentPointerDown: (event: PointerEvent) => void;
openPicker: OpenFavoritePicker | null;
};
const pickerDocumentStates = new WeakMap<Document, FavoritePickerDocumentState>();
function getPickerDocumentState(document: Document): FavoritePickerDocumentState {
const existingState = pickerDocumentStates.get(document);
if (existingState) {
return existingState;
}
const state: FavoritePickerDocumentState = {
contextByButton: new Map<HTMLButtonElement, FavoritePickerContext>(),
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<HTMLInputElement>("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<void> {
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<void> {
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);
}
}
+256
View File
@@ -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<FavoriteFolder | null>;
onSetCreatorFolderIds?: (
creator: FavoriteCreatorInput,
folderIds: string[]
) => Promise<void>;
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<void> {
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();
});
});
+6
View File
@@ -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",