mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-01 18:32:27 +00:00
Merge pull request #594 from PR0M3TH3AN/codex/refactor-videocard.js-more-menu-functionality
Refactor video menus to overlay popovers
This commit is contained in:
9 files changed
+1214
-608
No files matched your search
@@ -46,6 +46,8 @@ import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import { isWatchHistoryDebugEnabled } from "./watchHistoryDebug.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import createPopover from "./ui/overlay/popoverEngine.js";
|
||||
import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js";
|
||||
import {
|
||||
initViewCounter,
|
||||
subscribeToVideoViewCount,
|
||||
@@ -703,6 +705,7 @@ class Application {
|
||||
},
|
||||
});
|
||||
this.moreMenuController.setVideoModal(this.videoModal);
|
||||
this.videoSettingsPopovers = new Map();
|
||||
|
||||
// Hide/Show Subscriptions Link
|
||||
this.subscriptionsLink = null;
|
||||
@@ -4504,6 +4507,8 @@ class Application {
|
||||
if (this.moreMenuController) {
|
||||
this.moreMenuController.closeAllMoreMenus(options);
|
||||
}
|
||||
|
||||
this.closeVideoSettingsMenu({ restoreFocus: options?.restoreFocus !== false });
|
||||
}
|
||||
|
||||
attachMoreMenuHandlers(container) {
|
||||
@@ -4512,6 +4517,191 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
requestMoreMenu(detail = {}) {
|
||||
if (!this.moreMenuController) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = detail.trigger || null;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capabilities = detail.capabilities || {};
|
||||
const canManage =
|
||||
typeof capabilities.canManageBlacklist === "boolean"
|
||||
? capabilities.canManageBlacklist
|
||||
: this.canCurrentUserManageBlacklist();
|
||||
|
||||
this.moreMenuController.toggleMoreMenu({
|
||||
trigger,
|
||||
video: detail.video || null,
|
||||
pointerInfo: detail.pointerInfo || null,
|
||||
playbackUrl: detail.playbackUrl || "",
|
||||
playbackMagnet: detail.playbackMagnet || "",
|
||||
context: detail.context || "card",
|
||||
canManageBlacklist: canManage,
|
||||
designSystem: detail.designSystem || this.designSystemContext,
|
||||
onAction: detail.onAction || null,
|
||||
onClose: detail.onClose || null,
|
||||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||||
});
|
||||
}
|
||||
|
||||
closeMoreMenu(detail = {}) {
|
||||
if (!this.moreMenuController) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trigger = detail.trigger || null;
|
||||
if (trigger) {
|
||||
return this.moreMenuController.closePopoverForTrigger(trigger, {
|
||||
restoreFocus: detail.restoreFocus !== false,
|
||||
});
|
||||
}
|
||||
|
||||
this.moreMenuController.closeAllMoreMenus({
|
||||
restoreFocus: detail.restoreFocus !== false,
|
||||
skipView: detail.skipView === true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureSettingsPopover(detail = {}) {
|
||||
const trigger = detail.trigger || null;
|
||||
if (!trigger) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let entry = this.videoSettingsPopovers.get(trigger);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
trigger,
|
||||
context: {
|
||||
card: detail.card || null,
|
||||
video: detail.video || null,
|
||||
index: Number.isFinite(detail.index) ? Math.floor(detail.index) : 0,
|
||||
capabilities: detail.capabilities || {},
|
||||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||||
},
|
||||
popover: null,
|
||||
};
|
||||
|
||||
const render = ({ document: documentRef, close }) => {
|
||||
const panel = createVideoSettingsMenuPanel({
|
||||
document: documentRef,
|
||||
video: entry.context.video,
|
||||
index: entry.context.index,
|
||||
capabilities: entry.context.capabilities,
|
||||
designSystem: this.designSystemContext,
|
||||
});
|
||||
|
||||
if (!panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttons = panel.querySelectorAll("button[data-action]");
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const action = button.dataset.action || "";
|
||||
const handled = entry.context.card?.handleSettingsMenuAction?.(
|
||||
action,
|
||||
{ event },
|
||||
);
|
||||
|
||||
if (!handled && this.isDevMode) {
|
||||
userLogger.warn(`[SettingsMenu] Unhandled action: ${action}`);
|
||||
}
|
||||
|
||||
close();
|
||||
});
|
||||
});
|
||||
|
||||
return panel;
|
||||
};
|
||||
|
||||
const documentRef =
|
||||
trigger.ownerDocument ||
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
|
||||
const popover = createPopover(trigger, render, {
|
||||
document: documentRef,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const originalDestroy = popover.destroy?.bind(popover);
|
||||
if (typeof originalDestroy === "function") {
|
||||
popover.destroy = (...args) => {
|
||||
originalDestroy(...args);
|
||||
if (this.videoSettingsPopovers.get(trigger) === entry) {
|
||||
this.videoSettingsPopovers.delete(trigger);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
entry.popover = popover;
|
||||
this.videoSettingsPopovers.set(trigger, entry);
|
||||
}
|
||||
|
||||
entry.context = {
|
||||
...entry.context,
|
||||
card: detail.card || entry.context.card,
|
||||
video: detail.video || entry.context.video,
|
||||
index: Number.isFinite(detail.index)
|
||||
? Math.floor(detail.index)
|
||||
: entry.context.index,
|
||||
capabilities: detail.capabilities || entry.context.capabilities,
|
||||
restoreFocusOnClose: detail.restoreFocus !== false,
|
||||
};
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
requestVideoSettingsMenu(detail = {}) {
|
||||
const entry = this.ensureSettingsPopover(detail);
|
||||
if (!entry?.popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof entry.popover.isOpen === "function" && entry.popover.isOpen()) {
|
||||
entry.popover.close({
|
||||
restoreFocus: entry.context.restoreFocusOnClose !== false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
entry.popover
|
||||
.open()
|
||||
.catch((error) =>
|
||||
userLogger.error("[SettingsMenu] Failed to open popover:", error),
|
||||
);
|
||||
}
|
||||
|
||||
closeVideoSettingsMenu(detail = {}) {
|
||||
const trigger = detail.trigger || null;
|
||||
const restoreFocus = detail.restoreFocus !== false;
|
||||
|
||||
if (trigger) {
|
||||
const entry = this.videoSettingsPopovers.get(trigger);
|
||||
if (entry?.popover && typeof entry.popover.close === "function") {
|
||||
return entry.popover.close({ restoreFocus });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let closed = false;
|
||||
this.videoSettingsPopovers.forEach((entry) => {
|
||||
if (entry?.popover && typeof entry.popover.close === "function") {
|
||||
const result = entry.popover.close({ restoreFocus });
|
||||
closed = closed || result;
|
||||
}
|
||||
});
|
||||
return closed;
|
||||
}
|
||||
|
||||
syncModalMoreMenuData() {
|
||||
if (this.moreMenuController) {
|
||||
this.moreMenuController.syncModalMoreMenuData();
|
||||
|
||||
@@ -2074,6 +2074,36 @@ function renderChannelVideosFromList({
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onRequestMoreMenu = (detail = {}) => {
|
||||
if (typeof app?.requestMoreMenu === "function") {
|
||||
app.requestMoreMenu({
|
||||
...detail,
|
||||
video: detail.video || video,
|
||||
pointerInfo: detail.pointerInfo || pointerInfo,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onCloseMoreMenu = (detail = {}) => {
|
||||
if (typeof app?.closeMoreMenu === "function") {
|
||||
return app.closeMoreMenu(detail);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
videoCard.onRequestSettingsMenu = (detail = {}) => {
|
||||
if (typeof app?.requestVideoSettingsMenu === "function") {
|
||||
app.requestVideoSettingsMenu(detail);
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onCloseSettingsMenu = (detail = {}) => {
|
||||
if (typeof app?.closeVideoSettingsMenu === "function") {
|
||||
return app.closeVideoSettingsMenu(detail);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cardEl = videoCard.getRoot();
|
||||
if (cardEl) {
|
||||
fragment.appendChild(cardEl);
|
||||
|
||||
+170
-409
@@ -1,12 +1,6 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
import { updateVideoCardSourceVisibility } from "../../utils/cardSourceVisibility.js";
|
||||
import positionFloatingPanel from "../utils/positionFloatingPanel.js";
|
||||
import { createFloatingPanelStyles } from "../utils/floatingPanelStyles.js";
|
||||
import { userLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getPopupOffsetPx,
|
||||
getPopupViewportPaddingPx,
|
||||
} from "../../designSystem/metrics.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -107,7 +101,11 @@ export class VideoCard {
|
||||
onRevert: null,
|
||||
onDelete: null,
|
||||
onMoreAction: null,
|
||||
onAuthorNavigate: null
|
||||
onAuthorNavigate: null,
|
||||
onRequestMoreMenu: null,
|
||||
onCloseMoreMenu: null,
|
||||
onRequestSettingsMenu: null,
|
||||
onCloseSettingsMenu: null
|
||||
};
|
||||
|
||||
this.root = null;
|
||||
@@ -115,14 +113,7 @@ export class VideoCard {
|
||||
this.titleEl = null;
|
||||
this.thumbnailEl = null;
|
||||
this.settingsButton = null;
|
||||
this.settingsDropdown = null;
|
||||
this.editButton = null;
|
||||
this.revertButton = null;
|
||||
this.deleteButton = null;
|
||||
this.moreMenuButton = null;
|
||||
this.moreMenu = null;
|
||||
this.settingsDropdownPositioner = null;
|
||||
this.moreMenuPositioner = null;
|
||||
this.urlHealthBadgeEl = null;
|
||||
this.torrentHealthBadgeEl = null;
|
||||
this.viewCountEl = null;
|
||||
@@ -195,6 +186,24 @@ export class VideoCard {
|
||||
this.callbacks.onAuthorNavigate = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onRequestMoreMenu(fn) {
|
||||
this.callbacks.onRequestMoreMenu = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onCloseMoreMenu(fn) {
|
||||
this.callbacks.onCloseMoreMenu = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onRequestSettingsMenu(fn) {
|
||||
this.callbacks.onRequestSettingsMenu =
|
||||
typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
set onCloseSettingsMenu(fn) {
|
||||
this.callbacks.onCloseSettingsMenu =
|
||||
typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return this.root;
|
||||
}
|
||||
@@ -215,25 +224,67 @@ export class VideoCard {
|
||||
return this.discussionCountEl;
|
||||
}
|
||||
|
||||
closeMoreMenu() {
|
||||
if (this.moreMenu) {
|
||||
this.moreMenu.dataset.state = "closed";
|
||||
this.moreMenu.setAttribute("aria-hidden", "true");
|
||||
this.moreMenu.hidden = true;
|
||||
closeMoreMenu(options = {}) {
|
||||
const restoreFocus = options?.restoreFocus !== false;
|
||||
const detail = {
|
||||
trigger: this.moreMenuButton,
|
||||
video: this.video,
|
||||
card: this,
|
||||
restoreFocus,
|
||||
};
|
||||
|
||||
let handled = false;
|
||||
if (this.callbacks.onCloseMoreMenu) {
|
||||
try {
|
||||
handled = this.callbacks.onCloseMoreMenu(detail) === true;
|
||||
} catch (error) {
|
||||
if (this.window?.userLogger?.warn) {
|
||||
this.window.userLogger.warn(
|
||||
"[VideoCard] onCloseMoreMenu callback failed",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.moreMenuButton) {
|
||||
this.moreMenuButton.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (!handled && restoreFocus && this.moreMenuButton?.focus) {
|
||||
try {
|
||||
this.moreMenuButton.focus();
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeSettingsMenu() {
|
||||
if (this.settingsDropdown) {
|
||||
this.settingsDropdown.dataset.state = "closed";
|
||||
this.settingsDropdown.setAttribute("aria-hidden", "true");
|
||||
this.settingsDropdown.hidden = true;
|
||||
closeSettingsMenu(options = {}) {
|
||||
const restoreFocus = options?.restoreFocus !== false;
|
||||
const detail = {
|
||||
trigger: this.settingsButton,
|
||||
video: this.video,
|
||||
card: this,
|
||||
restoreFocus,
|
||||
};
|
||||
|
||||
let handled = false;
|
||||
if (this.callbacks.onCloseSettingsMenu) {
|
||||
try {
|
||||
handled = this.callbacks.onCloseSettingsMenu(detail) === true;
|
||||
} catch (error) {
|
||||
if (this.window?.userLogger?.warn) {
|
||||
this.window.userLogger.warn(
|
||||
"[VideoCard] onCloseSettingsMenu callback failed",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.settingsButton) {
|
||||
this.settingsButton.setAttribute("aria-expanded", "false");
|
||||
|
||||
if (!handled && restoreFocus && this.settingsButton?.focus) {
|
||||
try {
|
||||
this.settingsButton.focus();
|
||||
} catch (error) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,9 +757,6 @@ export class VideoCard {
|
||||
}
|
||||
|
||||
if (this.capabilities.canEdit) {
|
||||
const wrapper = this.createElement("div", {
|
||||
classNames: ["popover", "ml-2"]
|
||||
});
|
||||
const button = this.createElement("button", {
|
||||
classNames: [
|
||||
"btn-ghost",
|
||||
@@ -716,133 +764,28 @@ export class VideoCard {
|
||||
"w-10",
|
||||
"rounded-full",
|
||||
"p-0",
|
||||
"text-muted"
|
||||
"text-muted",
|
||||
"ml-2",
|
||||
],
|
||||
attrs: {
|
||||
type: "button",
|
||||
"aria-haspopup": "true",
|
||||
"aria-expanded": "false",
|
||||
"aria-label": "Video settings"
|
||||
}
|
||||
"aria-label": "Video settings",
|
||||
},
|
||||
});
|
||||
button.dataset.settingsDropdown = String(this.index);
|
||||
|
||||
const icon = this.createSettingsIcon(["h-5", "w-5"]);
|
||||
button.appendChild(icon);
|
||||
|
||||
const dropdown = this.createElement("div", {
|
||||
classNames: ["popover__panel", "w-44", "p-0"],
|
||||
attrs: {
|
||||
hidden: "",
|
||||
"data-state": "closed",
|
||||
"aria-hidden": "true"
|
||||
}
|
||||
});
|
||||
dropdown.id = `settingsDropdown-${this.index}`;
|
||||
dropdown.setAttribute("role", "menu");
|
||||
|
||||
const list = this.createElement("div", {
|
||||
classNames: ["menu"],
|
||||
attrs: { role: "none" }
|
||||
});
|
||||
|
||||
const addMenuItem = ({ text, variant = null, dataset = {} }) => {
|
||||
const item = this.createElement("button", {
|
||||
classNames: ["menu__item", "justify-start"],
|
||||
textContent: text,
|
||||
attrs: {
|
||||
type: "button",
|
||||
role: "menuitem"
|
||||
}
|
||||
});
|
||||
if (variant) {
|
||||
item.dataset.variant = variant;
|
||||
}
|
||||
Object.entries(dataset).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
item.dataset[key] = String(value);
|
||||
});
|
||||
list.appendChild(item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const editButton = addMenuItem({
|
||||
text: "Edit",
|
||||
dataset: {
|
||||
editIndex: String(this.index),
|
||||
editEventId: this.video.id
|
||||
}
|
||||
});
|
||||
this.editButton = editButton;
|
||||
|
||||
if (this.capabilities.canRevert) {
|
||||
const revertButton = addMenuItem({
|
||||
text: "Revert",
|
||||
variant: "critical",
|
||||
dataset: {
|
||||
revertIndex: String(this.index),
|
||||
revertEventId: this.video.id
|
||||
}
|
||||
});
|
||||
this.revertButton = revertButton;
|
||||
}
|
||||
|
||||
if (this.capabilities.canDelete) {
|
||||
const deleteButton = addMenuItem({
|
||||
text: "Delete All",
|
||||
variant: "critical",
|
||||
dataset: {
|
||||
deleteAllIndex: String(this.index),
|
||||
deleteAllEventId: this.video.id
|
||||
}
|
||||
});
|
||||
this.deleteButton = deleteButton;
|
||||
}
|
||||
|
||||
dropdown.appendChild(list);
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(dropdown);
|
||||
|
||||
container.appendChild(wrapper);
|
||||
|
||||
this.settingsButton = button;
|
||||
this.settingsDropdown = dropdown;
|
||||
this.settingsDropdownStyles = createFloatingPanelStyles(dropdown);
|
||||
const metricsDocument =
|
||||
dropdown?.ownerDocument ||
|
||||
button?.ownerDocument ||
|
||||
this.document ||
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
const settingsOffset = getPopupOffsetPx({
|
||||
documentRef: metricsDocument,
|
||||
});
|
||||
const settingsViewportPadding = getPopupViewportPaddingPx({
|
||||
documentRef: metricsDocument,
|
||||
});
|
||||
this.settingsDropdownPositioner = positionFloatingPanel(
|
||||
button,
|
||||
dropdown,
|
||||
{
|
||||
placement: "bottom",
|
||||
alignment: "end",
|
||||
offset: settingsOffset,
|
||||
viewportPadding: settingsViewportPadding,
|
||||
styles: this.settingsDropdownStyles,
|
||||
},
|
||||
);
|
||||
container.appendChild(button);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
buildMoreMenu() {
|
||||
const wrapper = this.createElement("div", {
|
||||
classNames: ["popover", "ml-1"]
|
||||
});
|
||||
wrapper.dataset.moreMenuWrapper = "true";
|
||||
|
||||
const button = this.createElement("button", {
|
||||
classNames: [
|
||||
"btn-ghost",
|
||||
@@ -850,208 +793,23 @@ export class VideoCard {
|
||||
"w-10",
|
||||
"rounded-full",
|
||||
"p-0",
|
||||
"text-muted"
|
||||
"text-muted",
|
||||
"ml-1",
|
||||
],
|
||||
attrs: {
|
||||
type: "button",
|
||||
"aria-haspopup": "true",
|
||||
"aria-expanded": "false",
|
||||
"aria-label": "More options"
|
||||
}
|
||||
"aria-label": "More options",
|
||||
},
|
||||
});
|
||||
button.dataset.moreDropdown = String(this.index);
|
||||
button.dataset.moreMenuToggleBound = "true";
|
||||
|
||||
const icon = this.createEllipsisIcon(["w-5", "h-5", "object-contain"]);
|
||||
button.appendChild(icon);
|
||||
|
||||
const dropdown = this.createElement("div", {
|
||||
classNames: ["popover__panel", "w-40", "p-0"],
|
||||
attrs: {
|
||||
hidden: "",
|
||||
"data-state": "closed",
|
||||
"aria-hidden": "true"
|
||||
}
|
||||
});
|
||||
dropdown.id = `moreDropdown-${this.index}`;
|
||||
dropdown.dataset.moreMenu = "true";
|
||||
dropdown.setAttribute("role", "menu");
|
||||
|
||||
const list = this.createElement("div", {
|
||||
classNames: ["menu"],
|
||||
attrs: { role: "none" }
|
||||
});
|
||||
|
||||
const addActionButton = (text, action, extraDataset = {}, options = {}) => {
|
||||
const { variant = null } = options || {};
|
||||
const btn = this.createElement("button", {
|
||||
classNames: ["menu__item", "justify-start"],
|
||||
textContent: text,
|
||||
attrs: {
|
||||
type: "button",
|
||||
role: "menuitem"
|
||||
}
|
||||
});
|
||||
if (variant) {
|
||||
btn.dataset.variant = variant;
|
||||
}
|
||||
btn.dataset.action = action;
|
||||
Object.entries(extraDataset || {}).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
btn.dataset[key] = String(value);
|
||||
});
|
||||
list.appendChild(btn);
|
||||
return btn;
|
||||
};
|
||||
|
||||
addActionButton("Open channel", "open-channel", {
|
||||
author: this.video.pubkey || ""
|
||||
});
|
||||
|
||||
addActionButton("Copy link", "copy-link", {
|
||||
eventId: this.video.id || ""
|
||||
});
|
||||
|
||||
const pointer = Array.isArray(this.pointerInfo?.pointer)
|
||||
? this.pointerInfo.pointer
|
||||
: null;
|
||||
const pointerType = pointer && pointer.length >= 2 ? pointer[0] : "";
|
||||
const pointerValue = pointer && pointer.length >= 2 ? pointer[1] : "";
|
||||
const pointerRelay = pointer && pointer.length >= 3 ? pointer[2] || "" : "";
|
||||
const numericKind =
|
||||
Number.isFinite(this.video.kind) && this.video.kind > 0
|
||||
? Math.floor(this.video.kind)
|
||||
: null;
|
||||
|
||||
const baseBoostDataset = {
|
||||
eventId: this.video.id || "",
|
||||
author: this.video.pubkey || ""
|
||||
};
|
||||
|
||||
if (pointerType && pointerValue) {
|
||||
baseBoostDataset.pointerType = pointerType;
|
||||
baseBoostDataset.pointerValue = pointerValue;
|
||||
}
|
||||
if (pointerRelay) {
|
||||
baseBoostDataset.pointerRelay = pointerRelay;
|
||||
}
|
||||
if (Number.isFinite(numericKind)) {
|
||||
baseBoostDataset.kind = String(numericKind);
|
||||
}
|
||||
|
||||
const boostLabel = this.createElement("div", {
|
||||
classNames: ["menu__heading"],
|
||||
textContent: "Boost on Nostr…"
|
||||
});
|
||||
list.appendChild(boostLabel);
|
||||
|
||||
addActionButton("Repost (kind 6)", "repost-event", baseBoostDataset);
|
||||
|
||||
if (this.playbackUrl && this.video.isPrivate !== true) {
|
||||
const mirrorDataset = {
|
||||
...baseBoostDataset,
|
||||
url: this.playbackUrl,
|
||||
magnet: this.playbackMagnet || "",
|
||||
thumbnail:
|
||||
typeof this.video.thumbnail === "string" ? this.video.thumbnail : "",
|
||||
description:
|
||||
typeof this.video.description === "string"
|
||||
? this.video.description
|
||||
: "",
|
||||
title: typeof this.video.title === "string" ? this.video.title : "",
|
||||
isPrivate: this.video.isPrivate === true ? "true" : "false"
|
||||
};
|
||||
|
||||
addActionButton("Mirror (kind 1063)", "mirror-video", mirrorDataset);
|
||||
}
|
||||
|
||||
const ensureDataset = {
|
||||
...baseBoostDataset,
|
||||
pubkey: this.video.pubkey || ""
|
||||
};
|
||||
|
||||
addActionButton("Rebroadcast", "ensure-presence", ensureDataset);
|
||||
|
||||
list.appendChild(
|
||||
this.createElement("div", {
|
||||
classNames: ["menu__separator"],
|
||||
attrs: { role: "separator" }
|
||||
})
|
||||
);
|
||||
|
||||
if (this.pointerInfo && this.pointerInfo.key && this.pointerInfo.pointer) {
|
||||
const [historyPointerType, historyPointerValue, historyPointerRelay] =
|
||||
this.pointerInfo.pointer;
|
||||
if (historyPointerType && historyPointerValue) {
|
||||
const removeButton = addActionButton(
|
||||
"Remove from history",
|
||||
"remove-history",
|
||||
{
|
||||
pointerKey: this.pointerInfo.key,
|
||||
pointerType: historyPointerType,
|
||||
pointerValue: historyPointerValue,
|
||||
pointerRelay: historyPointerRelay || "",
|
||||
reason: "remove-item"
|
||||
}
|
||||
);
|
||||
removeButton.setAttribute(
|
||||
"title",
|
||||
"Remove this entry from your encrypted history. Relay sync may take a moment."
|
||||
);
|
||||
removeButton.setAttribute(
|
||||
"aria-label",
|
||||
"Remove from history (updates encrypted history and may take a moment to sync to relays)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.capabilities.canManageBlacklist) {
|
||||
addActionButton(
|
||||
"Blacklist creator",
|
||||
"blacklist-author",
|
||||
{ author: this.video.pubkey || "" },
|
||||
{ variant: "critical" }
|
||||
);
|
||||
}
|
||||
|
||||
addActionButton(
|
||||
"Block creator",
|
||||
"block-author",
|
||||
{ author: this.video.pubkey || "" },
|
||||
{ variant: "critical" }
|
||||
);
|
||||
|
||||
addActionButton("Report", "report", { eventId: this.video.id || "" });
|
||||
|
||||
dropdown.appendChild(list);
|
||||
wrapper.appendChild(button);
|
||||
wrapper.appendChild(dropdown);
|
||||
|
||||
this.moreMenuButton = button;
|
||||
this.moreMenu = dropdown;
|
||||
this.moreMenuStyles = createFloatingPanelStyles(dropdown);
|
||||
const menuMetricsDocument =
|
||||
dropdown?.ownerDocument ||
|
||||
button?.ownerDocument ||
|
||||
this.document ||
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
const menuOffset = getPopupOffsetPx({
|
||||
documentRef: menuMetricsDocument,
|
||||
});
|
||||
const menuViewportPadding = getPopupViewportPaddingPx({
|
||||
documentRef: menuMetricsDocument,
|
||||
});
|
||||
this.moreMenuPositioner = positionFloatingPanel(button, dropdown, {
|
||||
placement: "bottom",
|
||||
alignment: "end",
|
||||
offset: menuOffset,
|
||||
viewportPadding: menuViewportPadding,
|
||||
styles: this.moreMenuStyles,
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
return button;
|
||||
}
|
||||
|
||||
buildBadgesContainer() {
|
||||
@@ -1637,106 +1395,70 @@ export class VideoCard {
|
||||
});
|
||||
});
|
||||
|
||||
if (this.settingsButton && this.settingsDropdown) {
|
||||
if (this.settingsButton) {
|
||||
this.settingsButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const willOpen = this.settingsDropdown.dataset.state !== "open";
|
||||
|
||||
if (this.onRequestCloseAllMenus) {
|
||||
this.onRequestCloseAllMenus(this);
|
||||
} else {
|
||||
this.closeSettingsMenu();
|
||||
this.closeSettingsMenu({ restoreFocus: false });
|
||||
}
|
||||
if (willOpen) {
|
||||
this.settingsDropdown.hidden = false;
|
||||
this.settingsDropdown.dataset.state = "open";
|
||||
this.settingsDropdown.setAttribute("aria-hidden", "false");
|
||||
this.settingsButton.setAttribute("aria-expanded", "true");
|
||||
this.settingsDropdownPositioner?.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.editButton) {
|
||||
this.editButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.closeSettingsMenu();
|
||||
if (this.callbacks.onEdit) {
|
||||
this.callbacks.onEdit({
|
||||
if (this.callbacks.onRequestSettingsMenu) {
|
||||
const detail = {
|
||||
event,
|
||||
trigger: this.settingsButton,
|
||||
video: this.video,
|
||||
card: this,
|
||||
index: this.index,
|
||||
card: this
|
||||
});
|
||||
capabilities: { ...this.capabilities },
|
||||
designSystem: this.designSystem,
|
||||
};
|
||||
this.callbacks.onRequestSettingsMenu(detail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.revertButton) {
|
||||
this.revertButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.closeSettingsMenu();
|
||||
if (this.callbacks.onRevert) {
|
||||
this.callbacks.onRevert({
|
||||
event,
|
||||
video: this.video,
|
||||
index: this.index,
|
||||
card: this
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.deleteButton) {
|
||||
this.deleteButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
this.closeSettingsMenu();
|
||||
if (this.callbacks.onDelete) {
|
||||
this.callbacks.onDelete({
|
||||
event,
|
||||
video: this.video,
|
||||
index: this.index,
|
||||
card: this
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.moreMenuButton && this.moreMenu) {
|
||||
if (this.moreMenuButton) {
|
||||
this.moreMenuButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const willOpen = this.moreMenu.dataset.state !== "open";
|
||||
|
||||
if (this.onRequestCloseAllMenus) {
|
||||
this.onRequestCloseAllMenus(this);
|
||||
}
|
||||
if (willOpen) {
|
||||
this.moreMenu.hidden = false;
|
||||
this.moreMenu.dataset.state = "open";
|
||||
this.moreMenu.setAttribute("aria-hidden", "false");
|
||||
this.moreMenuButton.setAttribute("aria-expanded", "true");
|
||||
this.moreMenuPositioner?.update();
|
||||
}
|
||||
});
|
||||
|
||||
const actionButtons = this.moreMenu.querySelectorAll(
|
||||
"button[data-action]"
|
||||
);
|
||||
actionButtons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.callbacks.onMoreAction) {
|
||||
const dataset = { ...button.dataset };
|
||||
this.callbacks.onMoreAction({
|
||||
event,
|
||||
video: this.video,
|
||||
card: this,
|
||||
dataset
|
||||
});
|
||||
}
|
||||
this.closeMoreMenu();
|
||||
});
|
||||
if (this.callbacks.onRequestMoreMenu) {
|
||||
const actionForwarder = this.callbacks.onMoreAction
|
||||
? ({ action, dataset, event: actionEvent }) => {
|
||||
const payload = {
|
||||
...dataset,
|
||||
action: action || dataset?.action || "",
|
||||
};
|
||||
this.callbacks.onMoreAction({
|
||||
event: actionEvent,
|
||||
video: this.video,
|
||||
card: this,
|
||||
dataset: payload,
|
||||
});
|
||||
}
|
||||
: null;
|
||||
|
||||
this.callbacks.onRequestMoreMenu({
|
||||
event,
|
||||
trigger: this.moreMenuButton,
|
||||
video: this.video,
|
||||
card: this,
|
||||
pointerInfo: this.pointerInfo,
|
||||
playbackUrl: this.playbackUrl,
|
||||
playbackMagnet: this.playbackMagnet,
|
||||
capabilities: { ...this.capabilities },
|
||||
designSystem: this.designSystem,
|
||||
onAction: actionForwarder,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1783,6 +1505,45 @@ export class VideoCard {
|
||||
}
|
||||
}
|
||||
|
||||
handleSettingsMenuAction(action, { event = null } = {}) {
|
||||
const normalized = typeof action === "string" ? action.trim() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized === "edit" && this.callbacks.onEdit) {
|
||||
this.callbacks.onEdit({
|
||||
event,
|
||||
video: this.video,
|
||||
index: this.index,
|
||||
card: this,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "revert" && this.callbacks.onRevert) {
|
||||
this.callbacks.onRevert({
|
||||
event,
|
||||
video: this.video,
|
||||
index: this.index,
|
||||
card: this,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === "delete" && this.callbacks.onDelete) {
|
||||
this.callbacks.onDelete({
|
||||
event,
|
||||
video: this.video,
|
||||
index: this.index,
|
||||
card: this,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
createElement(tagName, { classNames = [], attrs = {}, textContent } = {}) {
|
||||
const el = this.document.createElement(tagName);
|
||||
classNames
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { normalizeDesignSystemContext } from "../../designSystem.js";
|
||||
|
||||
function resolveDocument(documentRef) {
|
||||
if (documentRef && documentRef.nodeType === 9) {
|
||||
return documentRef;
|
||||
}
|
||||
if (typeof document !== "undefined" && document?.nodeType === 9) {
|
||||
return document;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createElement(doc, tagName, { classNames = [], attrs = {}, textContent } = {}) {
|
||||
const el = doc.createElement(tagName);
|
||||
classNames
|
||||
.filter((name) => typeof name === "string" && name.trim())
|
||||
.forEach((name) => el.classList.add(name));
|
||||
Object.entries(attrs).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
el.setAttribute(key, value);
|
||||
});
|
||||
if (typeof textContent === "string") {
|
||||
el.textContent = textContent;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function normalizePointer(pointerInfo) {
|
||||
if (!pointerInfo || typeof pointerInfo !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointer = Array.isArray(pointerInfo.pointer)
|
||||
? pointerInfo.pointer
|
||||
: null;
|
||||
if (!pointer || pointer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [type, value, relay] = pointer;
|
||||
const normalized = {
|
||||
key: typeof pointerInfo.key === "string" ? pointerInfo.key : "",
|
||||
type: typeof type === "string" ? type : "",
|
||||
value: typeof value === "string" ? value : "",
|
||||
relay: typeof relay === "string" ? relay : "",
|
||||
};
|
||||
|
||||
if (!normalized.type || !normalized.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveVideoMetadata(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return {
|
||||
id: "",
|
||||
pubkey: "",
|
||||
title: "",
|
||||
description: "",
|
||||
thumbnail: "",
|
||||
isPrivate: false,
|
||||
kind: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
id: typeof video.id === "string" ? video.id : "",
|
||||
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
||||
title: typeof video.title === "string" ? video.title : "",
|
||||
description:
|
||||
typeof video.description === "string" ? video.description : "",
|
||||
thumbnail:
|
||||
typeof video.thumbnail === "string" ? video.thumbnail : "",
|
||||
isPrivate: video.isPrivate === true,
|
||||
};
|
||||
|
||||
if (Number.isFinite(video.kind) && video.kind > 0) {
|
||||
normalized.kind = Math.floor(video.kind);
|
||||
} else {
|
||||
normalized.kind = null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ensureMenuContainer(doc, panelClassNames = []) {
|
||||
const panel = createElement(doc, "div", {
|
||||
classNames: ["popover__panel", ...panelClassNames],
|
||||
attrs: { role: "menu" },
|
||||
});
|
||||
|
||||
const list = createElement(doc, "div", {
|
||||
classNames: ["menu"],
|
||||
attrs: { role: "none" },
|
||||
});
|
||||
|
||||
panel.appendChild(list);
|
||||
return { panel, list };
|
||||
}
|
||||
|
||||
function appendMenuSeparator(doc, list) {
|
||||
const separator = createElement(doc, "div", {
|
||||
classNames: ["menu__separator"],
|
||||
attrs: { role: "separator" },
|
||||
});
|
||||
list.appendChild(separator);
|
||||
return separator;
|
||||
}
|
||||
|
||||
function appendMenuHeading(doc, list, text) {
|
||||
const heading = createElement(doc, "div", {
|
||||
classNames: ["menu__heading"],
|
||||
textContent: text,
|
||||
});
|
||||
list.appendChild(heading);
|
||||
return heading;
|
||||
}
|
||||
|
||||
function appendMenuAction(doc, list, {
|
||||
text,
|
||||
action,
|
||||
dataset = {},
|
||||
variant = null,
|
||||
context = "",
|
||||
}) {
|
||||
const button = createElement(doc, "button", {
|
||||
classNames: ["menu__item", "justify-start"],
|
||||
textContent: text,
|
||||
attrs: { type: "button", role: "menuitem" },
|
||||
});
|
||||
|
||||
button.dataset.action = action;
|
||||
if (variant) {
|
||||
button.dataset.variant = variant;
|
||||
}
|
||||
if (context) {
|
||||
button.dataset.context = context;
|
||||
}
|
||||
|
||||
Object.entries(dataset).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
button.dataset[key] = String(value);
|
||||
});
|
||||
|
||||
list.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
export function createVideoMoreMenuPanel({
|
||||
document: documentRef = null,
|
||||
video = null,
|
||||
pointerInfo = null,
|
||||
playbackUrl = "",
|
||||
playbackMagnet = "",
|
||||
canManageBlacklist = false,
|
||||
context = "card",
|
||||
designSystem = null,
|
||||
} = {}) {
|
||||
const doc = resolveDocument(documentRef);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
normalizeDesignSystemContext(designSystem);
|
||||
|
||||
const metadata = resolveVideoMetadata(video);
|
||||
const pointer = normalizePointer(pointerInfo);
|
||||
|
||||
const { panel, list } = ensureMenuContainer(doc, ["w-48", "p-0"]);
|
||||
panel.dataset.menu = "video-more";
|
||||
panel.dataset.menuContext = context;
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Open channel",
|
||||
action: "open-channel",
|
||||
dataset: { author: metadata.pubkey, context },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Copy link",
|
||||
action: "copy-link",
|
||||
dataset: { eventId: metadata.id, context },
|
||||
});
|
||||
|
||||
const baseBoostDataset = {
|
||||
eventId: metadata.id,
|
||||
author: metadata.pubkey,
|
||||
context,
|
||||
};
|
||||
|
||||
if (pointer) {
|
||||
baseBoostDataset.pointerType = pointer.type;
|
||||
baseBoostDataset.pointerValue = pointer.value;
|
||||
if (pointer.relay) {
|
||||
baseBoostDataset.pointerRelay = pointer.relay;
|
||||
}
|
||||
if (pointer.key) {
|
||||
baseBoostDataset.pointerKey = pointer.key;
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(metadata.kind) && metadata.kind > 0) {
|
||||
baseBoostDataset.kind = String(metadata.kind);
|
||||
}
|
||||
|
||||
appendMenuHeading(doc, list, "Boost on Nostr…");
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Repost (kind 6)",
|
||||
action: "repost-event",
|
||||
dataset: baseBoostDataset,
|
||||
});
|
||||
|
||||
if (playbackUrl && metadata.isPrivate !== true) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Mirror (kind 1063)",
|
||||
action: "mirror-video",
|
||||
dataset: {
|
||||
...baseBoostDataset,
|
||||
url: playbackUrl,
|
||||
magnet: playbackMagnet || "",
|
||||
thumbnail: metadata.thumbnail,
|
||||
description: metadata.description,
|
||||
title: metadata.title,
|
||||
isPrivate: metadata.isPrivate ? "true" : "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Rebroadcast",
|
||||
action: "ensure-presence",
|
||||
dataset: {
|
||||
...baseBoostDataset,
|
||||
pubkey: metadata.pubkey,
|
||||
},
|
||||
});
|
||||
|
||||
appendMenuSeparator(doc, list);
|
||||
|
||||
if (pointer) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Remove from history",
|
||||
action: "remove-history",
|
||||
dataset: {
|
||||
pointerKey: pointer.key,
|
||||
pointerType: pointer.type,
|
||||
pointerValue: pointer.value,
|
||||
pointerRelay: pointer.relay,
|
||||
reason: "remove-item",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (canManageBlacklist) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Blacklist creator",
|
||||
action: "blacklist-author",
|
||||
variant: "critical",
|
||||
dataset: { author: metadata.pubkey },
|
||||
});
|
||||
}
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Block creator",
|
||||
action: "block-author",
|
||||
variant: "critical",
|
||||
dataset: { author: metadata.pubkey },
|
||||
});
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Report",
|
||||
action: "report",
|
||||
dataset: { eventId: metadata.id },
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
export function createVideoSettingsMenuPanel({
|
||||
document: documentRef = null,
|
||||
video = null,
|
||||
index = 0,
|
||||
capabilities = {},
|
||||
designSystem = null,
|
||||
} = {}) {
|
||||
const doc = resolveDocument(documentRef);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
normalizeDesignSystemContext(designSystem);
|
||||
|
||||
const metadata = resolveVideoMetadata(video);
|
||||
const normalizedIndex = Number.isFinite(index) ? Math.max(0, Math.floor(index)) : 0;
|
||||
const perms = {
|
||||
canEdit: capabilities?.canEdit === true,
|
||||
canRevert: capabilities?.canRevert === true,
|
||||
canDelete: capabilities?.canDelete === true,
|
||||
};
|
||||
|
||||
const { panel, list } = ensureMenuContainer(doc, ["w-44", "p-0"]);
|
||||
panel.dataset.menu = "video-settings";
|
||||
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Edit",
|
||||
action: "edit",
|
||||
dataset: {
|
||||
index: String(normalizedIndex),
|
||||
eventId: metadata.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (perms.canRevert) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Revert",
|
||||
action: "revert",
|
||||
variant: "critical",
|
||||
dataset: {
|
||||
index: String(normalizedIndex),
|
||||
eventId: metadata.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (perms.canDelete) {
|
||||
appendMenuAction(doc, list, {
|
||||
text: "Delete All",
|
||||
action: "delete",
|
||||
variant: "critical",
|
||||
dataset: {
|
||||
index: String(normalizedIndex),
|
||||
eventId: metadata.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
+193
-120
@@ -1,5 +1,8 @@
|
||||
import { normalizeDesignSystemContext } from "../designSystem.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createVideoMoreMenuPanel,
|
||||
} from "./components/videoMenuRenderers.js";
|
||||
import createPopover from "./overlay/popoverEngine.js";
|
||||
|
||||
export default class MoreMenuController {
|
||||
@@ -50,8 +53,6 @@ export default class MoreMenuController {
|
||||
};
|
||||
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
this.boundDocumentClick = null;
|
||||
this.boundDocumentKeydown = null;
|
||||
|
||||
this.videoListView = null;
|
||||
this.videoModal = null;
|
||||
@@ -169,9 +170,9 @@ export default class MoreMenuController {
|
||||
|
||||
this.closeAllMoreMenus({ skipView: true });
|
||||
|
||||
this.popoversByTrigger.forEach((popover) => {
|
||||
this.popoversByTrigger.forEach((entry) => {
|
||||
try {
|
||||
popover?.destroy?.();
|
||||
entry?.popover?.destroy?.();
|
||||
} catch (error) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn("[MoreMenu] Failed to destroy popover:", error);
|
||||
@@ -180,53 +181,11 @@ export default class MoreMenuController {
|
||||
});
|
||||
this.popoversByTrigger.clear();
|
||||
this.activePopover = null;
|
||||
|
||||
if (this.document && this.boundDocumentClick) {
|
||||
this.document.removeEventListener("click", this.boundDocumentClick);
|
||||
}
|
||||
if (this.document && this.boundDocumentKeydown) {
|
||||
this.document.removeEventListener("keydown", this.boundDocumentKeydown);
|
||||
}
|
||||
|
||||
this.boundDocumentClick = null;
|
||||
this.boundDocumentKeydown = null;
|
||||
this.moreMenuGlobalHandlerBound = false;
|
||||
}
|
||||
|
||||
ensureGlobalMoreMenuHandlers() {
|
||||
if (this.moreMenuGlobalHandlerBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.document) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.moreMenuGlobalHandlerBound = true;
|
||||
|
||||
this.boundDocumentClick = (event) => {
|
||||
const target = event?.target;
|
||||
if (!target || typeof target.closest !== "function") {
|
||||
this.closeAllMoreMenus();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target.closest("[data-more-menu-wrapper]") ||
|
||||
target.closest("[data-more-menu]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.closeAllMoreMenus();
|
||||
};
|
||||
|
||||
this.boundDocumentKeydown = (event) => {
|
||||
if (event?.key === "Escape") {
|
||||
this.closeAllMoreMenus();
|
||||
}
|
||||
};
|
||||
|
||||
this.document.addEventListener("click", this.boundDocumentClick);
|
||||
this.document.addEventListener("keydown", this.boundDocumentKeydown);
|
||||
}
|
||||
|
||||
getHTMLElementConstructor(documentRef) {
|
||||
@@ -236,46 +195,22 @@ export default class MoreMenuController {
|
||||
);
|
||||
}
|
||||
|
||||
resolveMenuTemplate(key, documentRef) {
|
||||
if (!documentRef || typeof documentRef.getElementById !== "function") {
|
||||
return null;
|
||||
}
|
||||
createPopoverRender(entry) {
|
||||
return ({ document: documentRef, close }) => {
|
||||
const panel = createVideoMoreMenuPanel({
|
||||
document: documentRef,
|
||||
video: entry.context.video,
|
||||
pointerInfo: entry.context.pointerInfo,
|
||||
playbackUrl: entry.context.playbackUrl,
|
||||
playbackMagnet: entry.context.playbackMagnet,
|
||||
canManageBlacklist: entry.context.canManageBlacklist,
|
||||
context: entry.context.context || "card",
|
||||
designSystem: entry.context.designSystem || this.designSystem,
|
||||
});
|
||||
|
||||
const identifier = typeof key === "string" && key ? key : "";
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = documentRef.getElementById(`moreDropdown-${identifier}`);
|
||||
const HTMLElementCtor = this.getHTMLElementConstructor(documentRef);
|
||||
if (HTMLElementCtor && !(template instanceof HTMLElementCtor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return template || null;
|
||||
}
|
||||
|
||||
resetMenuTemplateState(template) {
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
template.hidden = true;
|
||||
template.setAttribute("aria-hidden", "true");
|
||||
template.dataset.state = "closed";
|
||||
}
|
||||
|
||||
createMenuRenderFactory({ template }) {
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ({ close }) => {
|
||||
const panel = template.cloneNode(true);
|
||||
panel.removeAttribute("id");
|
||||
panel.hidden = false;
|
||||
panel.setAttribute("aria-hidden", "false");
|
||||
panel.dataset.state = "open";
|
||||
if (!panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttons = panel.querySelectorAll("button[data-action]");
|
||||
buttons.forEach((button) => {
|
||||
@@ -284,15 +219,35 @@ export default class MoreMenuController {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const dataset = { ...button.dataset };
|
||||
const action = dataset.action || "";
|
||||
|
||||
try {
|
||||
await this.handleMoreMenuAction(action, dataset);
|
||||
} catch (error) {
|
||||
userLogger.error("[MoreMenu] Failed to handle action:", error);
|
||||
}
|
||||
|
||||
if (typeof entry.context.onAction === "function") {
|
||||
try {
|
||||
entry.context.onAction({ action, dataset, event });
|
||||
} catch (callbackError) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn(
|
||||
"[MoreMenu] onAction callback failed",
|
||||
callbackError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof close === "function") {
|
||||
close();
|
||||
}
|
||||
this.handleMoreMenuAction(action, dataset);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,7 +255,7 @@ export default class MoreMenuController {
|
||||
};
|
||||
}
|
||||
|
||||
decoratePopoverLifecycle(popover, trigger) {
|
||||
decoratePopoverLifecycle(popover, trigger, entry) {
|
||||
if (!popover) {
|
||||
return null;
|
||||
}
|
||||
@@ -320,11 +275,28 @@ export default class MoreMenuController {
|
||||
}
|
||||
|
||||
if (typeof originalClose === "function") {
|
||||
popover.close = (options) => {
|
||||
const result = originalClose(options);
|
||||
popover.close = (options = {}) => {
|
||||
const restoreFocus =
|
||||
options?.restoreFocus !== undefined
|
||||
? options.restoreFocus
|
||||
: entry?.context?.restoreFocusOnClose !== false;
|
||||
const result = originalClose({ restoreFocus });
|
||||
if (result && this.activePopover === popover) {
|
||||
this.activePopover = null;
|
||||
}
|
||||
if (result && typeof entry?.context?.onClose === "function") {
|
||||
try {
|
||||
entry.context.onClose({
|
||||
trigger,
|
||||
reason: options?.reason || "close",
|
||||
restoreFocus,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isDevMode) {
|
||||
userLogger.warn("[MoreMenu] onClose callback failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -335,7 +307,7 @@ export default class MoreMenuController {
|
||||
this.activePopover = null;
|
||||
}
|
||||
originalDestroy(...args);
|
||||
if (trigger && this.popoversByTrigger.get(trigger) === popover) {
|
||||
if (trigger && this.popoversByTrigger.get(trigger) === entry) {
|
||||
this.popoversByTrigger.delete(trigger);
|
||||
}
|
||||
};
|
||||
@@ -344,7 +316,7 @@ export default class MoreMenuController {
|
||||
return popover;
|
||||
}
|
||||
|
||||
ensurePopoverForTrigger({ trigger, key }) {
|
||||
ensurePopoverForTrigger({ trigger }) {
|
||||
if (!trigger) {
|
||||
return null;
|
||||
}
|
||||
@@ -358,46 +330,98 @@ export default class MoreMenuController {
|
||||
this.document ||
|
||||
(typeof document !== "undefined" ? document : null);
|
||||
|
||||
const template = this.resolveMenuTemplate(key, documentRef);
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
const entry = {
|
||||
trigger,
|
||||
context: {
|
||||
video: null,
|
||||
pointerInfo: null,
|
||||
playbackUrl: "",
|
||||
playbackMagnet: "",
|
||||
canManageBlacklist: false,
|
||||
context: "card",
|
||||
designSystem: this.designSystem,
|
||||
onAction: null,
|
||||
onClose: null,
|
||||
restoreFocusOnClose: true,
|
||||
},
|
||||
popover: null,
|
||||
};
|
||||
|
||||
this.resetMenuTemplateState(template);
|
||||
|
||||
const render = this.createMenuRenderFactory({ template });
|
||||
if (typeof render !== "function") {
|
||||
return null;
|
||||
}
|
||||
const render = this.createPopoverRender(entry);
|
||||
|
||||
const popover = createPopover(trigger, render, {
|
||||
document: documentRef,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const decorated = this.decoratePopoverLifecycle(popover, trigger) || popover;
|
||||
this.popoversByTrigger.set(trigger, decorated);
|
||||
return decorated;
|
||||
const decorated = this.decoratePopoverLifecycle(popover, trigger, entry) || popover;
|
||||
entry.popover = decorated;
|
||||
this.popoversByTrigger.set(trigger, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async toggleMoreMenu({ trigger, key }) {
|
||||
const popover = this.ensurePopoverForTrigger({ trigger, key });
|
||||
if (!popover) {
|
||||
async toggleMoreMenu(detail = {}) {
|
||||
const {
|
||||
trigger = null,
|
||||
video = null,
|
||||
pointerInfo = null,
|
||||
playbackUrl = "",
|
||||
playbackMagnet = "",
|
||||
context = "card",
|
||||
canManageBlacklist = this.callbacks.canCurrentUserManageBlacklist(),
|
||||
designSystem = null,
|
||||
onAction = null,
|
||||
onClose = null,
|
||||
restoreFocusOnClose = true,
|
||||
} = detail;
|
||||
|
||||
const entry = this.ensurePopoverForTrigger({ trigger });
|
||||
if (!entry?.popover) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof popover.isOpen === "function" && popover.isOpen()) {
|
||||
popover.close();
|
||||
entry.context = {
|
||||
...entry.context,
|
||||
video,
|
||||
pointerInfo,
|
||||
playbackUrl,
|
||||
playbackMagnet,
|
||||
canManageBlacklist,
|
||||
context,
|
||||
designSystem: designSystem || this.designSystem,
|
||||
onAction,
|
||||
onClose,
|
||||
restoreFocusOnClose: restoreFocusOnClose !== false,
|
||||
};
|
||||
|
||||
if (typeof entry.popover.isOpen === "function" && entry.popover.isOpen()) {
|
||||
entry.popover.close({
|
||||
restoreFocus: entry.context.restoreFocusOnClose !== false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await popover.open();
|
||||
await entry.popover.open();
|
||||
} catch (error) {
|
||||
userLogger.error("[MoreMenu] Failed to open popover:", error);
|
||||
}
|
||||
}
|
||||
|
||||
closePopoverForTrigger(trigger, options = {}) {
|
||||
const entry = trigger ? this.popoversByTrigger.get(trigger) : null;
|
||||
if (!entry?.popover || typeof entry.popover.close !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const restoreFocus =
|
||||
options?.restoreFocus !== undefined
|
||||
? options.restoreFocus
|
||||
: entry.context?.restoreFocusOnClose !== false;
|
||||
|
||||
return entry.popover.close({ restoreFocus });
|
||||
}
|
||||
|
||||
closeAllMoreMenus(options = {}) {
|
||||
const skipView = options?.skipView === true;
|
||||
|
||||
@@ -405,12 +429,16 @@ export default class MoreMenuController {
|
||||
this.videoListView.closeAllMenus({ skipController: true });
|
||||
}
|
||||
|
||||
const currentPopover = this.activePopover;
|
||||
if (currentPopover && typeof currentPopover.close === "function") {
|
||||
currentPopover.close();
|
||||
if (this.activePopover === currentPopover) {
|
||||
this.activePopover = null;
|
||||
const restoreFocus = options?.restoreFocus !== false;
|
||||
|
||||
this.popoversByTrigger.forEach((entry) => {
|
||||
if (entry?.popover && typeof entry.popover.close === "function") {
|
||||
entry.popover.close({ restoreFocus });
|
||||
}
|
||||
});
|
||||
|
||||
if (this.activePopover) {
|
||||
this.activePopover = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,14 +468,59 @@ export default class MoreMenuController {
|
||||
return;
|
||||
}
|
||||
button.dataset.moreMenuToggleBound = "true";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const key = button.getAttribute("data-more-dropdown") || "";
|
||||
this.toggleMoreMenu({ trigger: button, key });
|
||||
const context = button.getAttribute("data-context") || "card";
|
||||
|
||||
let video = null;
|
||||
let pointerInfo = null;
|
||||
let playbackUrl = "";
|
||||
let playbackMagnet = "";
|
||||
|
||||
if (context === "modal") {
|
||||
video = this.callbacks.getCurrentVideo();
|
||||
if (Array.isArray(video?.pointer) && video.pointer.length >= 2) {
|
||||
pointerInfo = { pointer: video.pointer };
|
||||
}
|
||||
playbackUrl = typeof video?.url === "string" ? video.url : "";
|
||||
playbackMagnet = (() => {
|
||||
if (typeof video?.magnet === "string" && video.magnet) {
|
||||
return video.magnet;
|
||||
}
|
||||
if (typeof video?.infoHash === "string" && video.infoHash) {
|
||||
return video.infoHash;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
}
|
||||
|
||||
if (!pointerInfo) {
|
||||
const pointerType = button.getAttribute("data-pointer-type") || "";
|
||||
const pointerValue = button.getAttribute("data-pointer-value") || "";
|
||||
const pointerRelay = button.getAttribute("data-pointer-relay") || "";
|
||||
const pointerKey = button.getAttribute("data-pointer-key") || "";
|
||||
if (pointerType && pointerValue) {
|
||||
pointerInfo = {
|
||||
pointer: pointerRelay
|
||||
? [pointerType, pointerValue, pointerRelay]
|
||||
: [pointerType, pointerValue],
|
||||
key: pointerKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.toggleMoreMenu({
|
||||
trigger: button,
|
||||
video,
|
||||
pointerInfo,
|
||||
playbackUrl,
|
||||
playbackMagnet,
|
||||
context,
|
||||
canManageBlacklist: this.callbacks.canCurrentUserManageBlacklist(),
|
||||
designSystem: this.designSystem,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+239
-52
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
computePosition,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
autoUpdate,
|
||||
arrow as arrowMiddleware,
|
||||
} from "@floating-ui/dom";
|
||||
import logger from "../../utils/logger.js";
|
||||
import {
|
||||
getPopupOffsetPx,
|
||||
@@ -17,12 +9,6 @@ import { ensureOverlayRoot } from "./overlayRoot.js";
|
||||
const DEFAULT_PLACEMENT = "bottom-start";
|
||||
const DEFAULT_STRATEGY = "fixed";
|
||||
const DEFAULT_MAX_WIDTH_TOKEN = "--popover-inline-safe-max";
|
||||
const STATIC_SIDE_MAP = Object.freeze({
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
});
|
||||
|
||||
let activePopoverInstance = null;
|
||||
|
||||
@@ -82,32 +68,61 @@ function resolveArrow(option, panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyArrowStyles(arrowElement, placement, middlewareData) {
|
||||
if (!arrowElement) {
|
||||
function applyArrowStyles({
|
||||
arrowElement,
|
||||
side,
|
||||
panelLeft,
|
||||
panelTop,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
anchorRect,
|
||||
}) {
|
||||
if (!arrowElement || !side || !anchorRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrowData = middlewareData?.arrow || {};
|
||||
const arrowRect =
|
||||
typeof arrowElement.getBoundingClientRect === "function"
|
||||
? arrowElement.getBoundingClientRect()
|
||||
: null;
|
||||
const arrowWidth = arrowRect?.width ?? arrowElement.offsetWidth ?? 0;
|
||||
const arrowHeight = arrowRect?.height ?? arrowElement.offsetHeight ?? 0;
|
||||
|
||||
arrowElement.style.left =
|
||||
arrowData.x !== undefined && arrowData.x !== null ? `${arrowData.x}px` : "";
|
||||
arrowElement.style.top =
|
||||
arrowData.y !== undefined && arrowData.y !== null ? `${arrowData.y}px` : "";
|
||||
const anchorCenterX = anchorRect.left + anchorRect.width / 2;
|
||||
const anchorCenterY = anchorRect.top + anchorRect.height / 2;
|
||||
|
||||
arrowElement.style.left = "";
|
||||
arrowElement.style.right = "";
|
||||
arrowElement.style.top = "";
|
||||
arrowElement.style.bottom = "";
|
||||
|
||||
const [side] = placement.split("-");
|
||||
const staticSide = STATIC_SIDE_MAP[side];
|
||||
|
||||
if (staticSide) {
|
||||
const size =
|
||||
staticSide === "top" || staticSide === "bottom"
|
||||
? arrowElement.offsetHeight || 0
|
||||
: arrowElement.offsetWidth || 0;
|
||||
arrowElement.style[staticSide] = `${-Math.max(size, 0)}px`;
|
||||
if (side === "top" || side === "bottom") {
|
||||
let arrowLeft = anchorCenterX - panelLeft - arrowWidth / 2;
|
||||
arrowLeft = Math.min(
|
||||
Math.max(arrowLeft, 0),
|
||||
Math.max(panelWidth - arrowWidth, 0),
|
||||
);
|
||||
arrowElement.style.left = `${Math.round(arrowLeft)}px`;
|
||||
if (side === "bottom") {
|
||||
arrowElement.style.top = `${-Math.round(arrowHeight)}px`;
|
||||
} else {
|
||||
arrowElement.style.bottom = `${-Math.round(arrowHeight)}px`;
|
||||
}
|
||||
} else if (side === "left" || side === "right") {
|
||||
let arrowTop = anchorCenterY - panelTop - arrowHeight / 2;
|
||||
arrowTop = Math.min(
|
||||
Math.max(arrowTop, 0),
|
||||
Math.max(panelHeight - arrowHeight, 0),
|
||||
);
|
||||
arrowElement.style.top = `${Math.round(arrowTop)}px`;
|
||||
if (side === "right") {
|
||||
arrowElement.style.left = `${-Math.round(arrowWidth)}px`;
|
||||
} else {
|
||||
arrowElement.style.right = `${-Math.round(arrowWidth)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
arrowElement.dataset.popoverArrowSide = side || "";
|
||||
arrowElement.dataset.popoverArrowSide = side;
|
||||
}
|
||||
|
||||
function containsTarget(root, target) {
|
||||
@@ -127,6 +142,85 @@ function setExpandedAttribute(trigger, value) {
|
||||
trigger.setAttribute("aria-expanded", value ? "true" : "false");
|
||||
}
|
||||
|
||||
function createAutoUpdate({ documentRef, anchor, panel, update }) {
|
||||
if (!documentRef || !anchor || !panel || typeof update !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanupFns = [];
|
||||
const safeUpdate = () => {
|
||||
try {
|
||||
const result = update();
|
||||
if (result && typeof result.then === "function") {
|
||||
result.catch((error) => {
|
||||
logger.dev.warn("[popover] auto-update failed", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.dev.warn("[popover] auto-update failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const view = documentRef.defaultView || globalThis;
|
||||
if (view && typeof view.addEventListener === "function") {
|
||||
const resizeHandler = () => safeUpdate();
|
||||
view.addEventListener("resize", resizeHandler);
|
||||
cleanupFns.push(() => view.removeEventListener("resize", resizeHandler));
|
||||
|
||||
const scrollHandler = () => safeUpdate();
|
||||
view.addEventListener("scroll", scrollHandler, true);
|
||||
cleanupFns.push(() => view.removeEventListener("scroll", scrollHandler, true));
|
||||
}
|
||||
|
||||
const scrollTarget = documentRef.scrollingElement || documentRef;
|
||||
if (scrollTarget && typeof scrollTarget.addEventListener === "function") {
|
||||
const docScrollHandler = () => safeUpdate();
|
||||
scrollTarget.addEventListener("scroll", docScrollHandler, true);
|
||||
cleanupFns.push(() =>
|
||||
scrollTarget.removeEventListener("scroll", docScrollHandler, true),
|
||||
);
|
||||
}
|
||||
|
||||
const observers = [];
|
||||
if (typeof ResizeObserver === "function") {
|
||||
try {
|
||||
const anchorObserver = new ResizeObserver(safeUpdate);
|
||||
anchorObserver.observe(anchor);
|
||||
observers.push(anchorObserver);
|
||||
} catch (error) {
|
||||
logger.dev.warn("[popover] anchor resize observer failed", error);
|
||||
}
|
||||
|
||||
if (panel !== anchor) {
|
||||
try {
|
||||
const panelObserver = new ResizeObserver(safeUpdate);
|
||||
panelObserver.observe(panel);
|
||||
observers.push(panelObserver);
|
||||
} catch (error) {
|
||||
logger.dev.warn("[popover] panel resize observer failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupFns.forEach((cleanup) => {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
logger.dev.warn("[popover] auto-update cleanup failed", error);
|
||||
}
|
||||
});
|
||||
|
||||
observers.forEach((observer) => {
|
||||
try {
|
||||
observer.disconnect();
|
||||
} catch (error) {
|
||||
logger.dev.warn("[popover] observer disconnect failed", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createPopover(trigger, render, options = {}) {
|
||||
const anchor = isElement(trigger) ? trigger : null;
|
||||
const documentRef = resolveDocument(anchor, options.document);
|
||||
@@ -231,35 +325,125 @@ export function createPopover(trigger, render, options = {}) {
|
||||
}
|
||||
|
||||
async function updatePosition() {
|
||||
if (!anchor || !panel) {
|
||||
if (!anchor || !panel || !documentRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const middleware = [
|
||||
offset(gap),
|
||||
flip({ padding: viewportPadding }),
|
||||
shift({ padding: viewportPadding }),
|
||||
];
|
||||
|
||||
if (arrowElement) {
|
||||
middleware.push(arrowMiddleware({ element: arrowElement }));
|
||||
const anchorRect =
|
||||
typeof anchor.getBoundingClientRect === "function"
|
||||
? anchor.getBoundingClientRect()
|
||||
: null;
|
||||
if (!anchorRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y, placement: resolvedPlacement, middlewareData } =
|
||||
await computePosition(anchor, panel, {
|
||||
placement,
|
||||
strategy,
|
||||
middleware,
|
||||
});
|
||||
const view = documentRef.defaultView || globalThis;
|
||||
const docEl = documentRef.documentElement || null;
|
||||
const viewportWidth =
|
||||
docEl?.clientWidth ?? view?.innerWidth ?? panel.offsetWidth ?? 0;
|
||||
const viewportHeight =
|
||||
docEl?.clientHeight ?? view?.innerHeight ?? panel.offsetHeight ?? 0;
|
||||
|
||||
const panelRect =
|
||||
typeof panel.getBoundingClientRect === "function"
|
||||
? panel.getBoundingClientRect()
|
||||
: null;
|
||||
const panelWidth = panelRect?.width ?? panel.offsetWidth ?? 0;
|
||||
const panelHeight = panelRect?.height ?? panel.offsetHeight ?? 0;
|
||||
|
||||
let [side, alignment = "start"] = placement.split("-");
|
||||
if (!side || !["top", "bottom", "left", "right"].includes(side)) {
|
||||
side = "bottom";
|
||||
}
|
||||
if (!["start", "end", "center"].includes(alignment)) {
|
||||
alignment = "start";
|
||||
}
|
||||
|
||||
const effectiveGap = Number.isFinite(gap) ? gap : 0;
|
||||
const safePadding = Number.isFinite(viewportPadding) ? viewportPadding : 0;
|
||||
|
||||
function clamp(value, min, max, fallback) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback ?? min;
|
||||
}
|
||||
if (min > max) {
|
||||
return fallback ?? (min + max) / 2;
|
||||
}
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
if (side === "bottom") {
|
||||
const fitsBelow =
|
||||
anchorRect.bottom + effectiveGap + panelHeight <=
|
||||
viewportHeight - safePadding;
|
||||
const fitsAbove =
|
||||
anchorRect.top - effectiveGap - panelHeight >= safePadding;
|
||||
if (!fitsBelow && fitsAbove) {
|
||||
side = "top";
|
||||
}
|
||||
} else if (side === "top") {
|
||||
const fitsAbove =
|
||||
anchorRect.top - effectiveGap - panelHeight >= safePadding;
|
||||
const fitsBelow =
|
||||
anchorRect.bottom + effectiveGap + panelHeight <=
|
||||
viewportHeight - safePadding;
|
||||
if (!fitsAbove && fitsBelow) {
|
||||
side = "bottom";
|
||||
}
|
||||
}
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
if (side === "top") {
|
||||
top = anchorRect.top - panelHeight - effectiveGap;
|
||||
} else if (side === "bottom") {
|
||||
top = anchorRect.bottom + effectiveGap;
|
||||
} else if (side === "left") {
|
||||
top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
|
||||
} else if (side === "right") {
|
||||
top = anchorRect.top + anchorRect.height / 2 - panelHeight / 2;
|
||||
}
|
||||
|
||||
if (side === "left") {
|
||||
left = anchorRect.left - panelWidth - effectiveGap;
|
||||
} else if (side === "right") {
|
||||
left = anchorRect.right + effectiveGap;
|
||||
} else if (alignment === "end") {
|
||||
left = anchorRect.right - panelWidth;
|
||||
} else if (alignment === "center") {
|
||||
left = anchorRect.left + anchorRect.width / 2 - panelWidth / 2;
|
||||
} else {
|
||||
left = anchorRect.left;
|
||||
}
|
||||
|
||||
const minTop = safePadding;
|
||||
const maxTop = viewportHeight - safePadding - panelHeight;
|
||||
const minLeft = safePadding;
|
||||
const maxLeft = viewportWidth - safePadding - panelWidth;
|
||||
|
||||
const fallbackTop = viewportHeight / 2 - panelHeight / 2;
|
||||
const fallbackLeft = viewportWidth / 2 - panelWidth / 2;
|
||||
|
||||
top = clamp(top, minTop, maxTop, fallbackTop);
|
||||
left = clamp(left, minLeft, maxLeft, fallbackLeft);
|
||||
|
||||
panel.style.position = strategy;
|
||||
panel.style.left = `${x}px`;
|
||||
panel.style.top = `${y}px`;
|
||||
panel.dataset.popoverPlacement = resolvedPlacement;
|
||||
panel.style.left = `${Math.round(left)}px`;
|
||||
panel.style.top = `${Math.round(top)}px`;
|
||||
panel.dataset.popoverPlacement = `${side}-${alignment}`;
|
||||
|
||||
if (arrowElement) {
|
||||
applyArrowStyles(arrowElement, resolvedPlacement, middlewareData);
|
||||
applyArrowStyles({
|
||||
arrowElement,
|
||||
side,
|
||||
panelLeft: left,
|
||||
panelTop: top,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
anchorRect,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.user.error("[popover] positioning failed", error);
|
||||
@@ -336,8 +520,11 @@ export function createPopover(trigger, render, options = {}) {
|
||||
autoUpdateCleanup = null;
|
||||
}
|
||||
|
||||
autoUpdateCleanup = autoUpdate(anchor, panelElement, updatePosition, {
|
||||
animationFrame: false,
|
||||
autoUpdateCleanup = createAutoUpdate({
|
||||
documentRef,
|
||||
anchor,
|
||||
panel: panelElement,
|
||||
update: () => updatePosition(),
|
||||
});
|
||||
|
||||
await updatePosition();
|
||||
|
||||
@@ -150,6 +150,22 @@ export class VideoListView {
|
||||
: () => {},
|
||||
closeAllMenus:
|
||||
typeof utils.closeAllMenus === "function" ? utils.closeAllMenus : () => {},
|
||||
requestMoreMenu:
|
||||
typeof utils.requestMoreMenu === "function"
|
||||
? utils.requestMoreMenu
|
||||
: () => {},
|
||||
closeMoreMenu:
|
||||
typeof utils.closeMoreMenu === "function"
|
||||
? utils.closeMoreMenu
|
||||
: () => false,
|
||||
requestSettingsMenu:
|
||||
typeof utils.requestSettingsMenu === "function"
|
||||
? utils.requestSettingsMenu
|
||||
: () => {},
|
||||
closeSettingsMenu:
|
||||
typeof utils.closeSettingsMenu === "function"
|
||||
? utils.closeSettingsMenu
|
||||
: () => false,
|
||||
};
|
||||
|
||||
this.renderedVideoIds = new Set();
|
||||
@@ -504,6 +520,38 @@ export class VideoListView {
|
||||
});
|
||||
};
|
||||
|
||||
videoCard.onRequestMoreMenu = (detail = {}) => {
|
||||
if (typeof this.utils.requestMoreMenu !== "function") {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...detail,
|
||||
video: detail.video || video,
|
||||
pointerInfo: detail.pointerInfo || pointerInfo,
|
||||
};
|
||||
this.utils.requestMoreMenu(payload);
|
||||
};
|
||||
|
||||
videoCard.onCloseMoreMenu = (detail = {}) => {
|
||||
if (typeof this.utils.closeMoreMenu === "function") {
|
||||
return this.utils.closeMoreMenu(detail);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
videoCard.onRequestSettingsMenu = (detail = {}) => {
|
||||
if (typeof this.utils.requestSettingsMenu === "function") {
|
||||
this.utils.requestSettingsMenu(detail);
|
||||
}
|
||||
};
|
||||
|
||||
videoCard.onCloseSettingsMenu = (detail = {}) => {
|
||||
if (typeof this.utils.closeSettingsMenu === "function") {
|
||||
return this.utils.closeSettingsMenu(detail);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const cardEl = videoCard.getRoot();
|
||||
if (!cardEl) {
|
||||
return;
|
||||
|
||||
Generated
-26
@@ -9,7 +9,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"path": "^0.12.7",
|
||||
"webtorrent": "^1.9.7"
|
||||
},
|
||||
@@ -709,31 +708,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"path": "^0.12.7",
|
||||
"webtorrent": "^1.9.7"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user