feat: add row favorites picker
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user