mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
feat(queue): drag rows outside queue to remove; trash ghost outside bounds (#420)
* feat(queue): drag rows outside queue to remove; trash ghost outside bounds - Document-level psy-drop when cursor leaves queue rect (main + mini) - DragDropContext: drop coordinates on psy-detail; optional wrapRef on OverlayScrollArea - Trash icon on drag ghost only outside registered queue hit-test regions * docs(changelog): add queue drag-out removal for PR #420
This commit is contained in:
@@ -98,6 +98,15 @@ The top header behavior was reworked for narrow widths: search, Live and Orbit c
|
|||||||
Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling.
|
Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling.
|
||||||
|
|
||||||
|
|
||||||
|
### Queue — Drag Outside to Remove
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#420](https://github.com/Psychotoxical/psysonic/pull/420)**
|
||||||
|
|
||||||
|
You can remove a track from the play queue by dragging its row **outside** the queue sidebar (main window) or outside the mini player’s queue list. Drop targets still support reordering when you release inside the queue area.
|
||||||
|
|
||||||
|
The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself.
|
||||||
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
|
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
|
|||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
|
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
|
||||||
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
import { useWindowVisibility } from '../hooks/useWindowVisibility';
|
||||||
import { IS_LINUX } from '../utils/platform';
|
import { IS_LINUX } from '../utils/platform';
|
||||||
import MiniContextMenu from './MiniContextMenu';
|
import MiniContextMenu from './MiniContextMenu';
|
||||||
@@ -115,6 +115,18 @@ export default function MiniPlayer() {
|
|||||||
const [volumeOpen, setVolumeOpen] = useState(false);
|
const [volumeOpen, setVolumeOpen] = useState(false);
|
||||||
const ticker = useRef<number | null>(null);
|
const ticker = useRef<number | null>(null);
|
||||||
const queueScrollRef = useRef<HTMLDivElement>(null);
|
const queueScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const miniQueueWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queueOpen) return;
|
||||||
|
const hitTest = (cx: number, cy: number) => {
|
||||||
|
const el = miniQueueWrapRef.current;
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||||
|
};
|
||||||
|
return registerQueueDragHitTest(hitTest);
|
||||||
|
}, [queueOpen]);
|
||||||
const volumeBtnRef = useRef<HTMLButtonElement>(null);
|
const volumeBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const volumePopRef = useRef<HTMLDivElement>(null);
|
const volumePopRef = useRef<HTMLDivElement>(null);
|
||||||
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
|
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
|
||||||
@@ -415,6 +427,37 @@ export default function MiniPlayer() {
|
|||||||
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
return () => el.removeEventListener('psy-drop', onPsyDrop);
|
||||||
}, [queueOpen, state.queue.length]);
|
}, [queueOpen, state.queue.length]);
|
||||||
|
|
||||||
|
// Drop outside the mini queue strip → remove (same UX as main QueuePanel).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!queueOpen) return;
|
||||||
|
const onDocPsyDrop = (e: Event) => {
|
||||||
|
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
|
||||||
|
if (!d?.data) return;
|
||||||
|
const cx = d.clientX;
|
||||||
|
const cy = d.clientY;
|
||||||
|
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||||
|
let parsed: { type?: string; index?: number } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(d.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
|
||||||
|
const wrap = miniQueueWrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
const inside =
|
||||||
|
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||||
|
if (inside) return;
|
||||||
|
psyDragFromIdxRef.current = null;
|
||||||
|
dropTargetRef.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
emit('mini:remove', { index: parsed.index }).catch(() => {});
|
||||||
|
};
|
||||||
|
document.addEventListener('psy-drop', onDocPsyDrop);
|
||||||
|
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
|
||||||
|
}, [queueOpen]);
|
||||||
|
|
||||||
// Auto-scroll the current track into view when the queue expands.
|
// Auto-scroll the current track into view when the queue expands.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queueOpen) return;
|
if (!queueOpen) return;
|
||||||
@@ -633,6 +676,7 @@ export default function MiniPlayer() {
|
|||||||
|
|
||||||
{queueOpen && (
|
{queueOpen && (
|
||||||
<OverlayScrollArea
|
<OverlayScrollArea
|
||||||
|
wrapRef={miniQueueWrapRef}
|
||||||
viewportRef={queueScrollRef}
|
viewportRef={queueScrollRef}
|
||||||
className="mini-queue-wrap"
|
className="mini-queue-wrap"
|
||||||
viewportClassName="mini-queue"
|
viewportClassName="mini-queue"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type OverlayScrollAreaProps = {
|
|||||||
viewportScrollBehaviorAuto?: boolean;
|
viewportScrollBehaviorAuto?: boolean;
|
||||||
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
|
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
|
||||||
viewportRef?: React.Ref<HTMLDivElement>;
|
viewportRef?: React.Ref<HTMLDivElement>;
|
||||||
|
/** Ref to the outer wrapper (incl. overlay scrollbar rail). */
|
||||||
|
wrapRef?: React.Ref<HTMLDivElement>;
|
||||||
/** Optional id on the viewport (e.g. main app scroll for route pages). */
|
/** Optional id on the viewport (e.g. main app scroll for route pages). */
|
||||||
viewportId?: string;
|
viewportId?: string;
|
||||||
/** Optional wheel handler on the scrollable viewport. */
|
/** Optional wheel handler on the scrollable viewport. */
|
||||||
@@ -49,6 +51,7 @@ export default function OverlayScrollArea({
|
|||||||
railInset = 'none',
|
railInset = 'none',
|
||||||
viewportScrollBehaviorAuto = false,
|
viewportScrollBehaviorAuto = false,
|
||||||
viewportRef: viewportRefProp,
|
viewportRef: viewportRefProp,
|
||||||
|
wrapRef: wrapRefProp,
|
||||||
viewportId,
|
viewportId,
|
||||||
viewportOnWheel,
|
viewportOnWheel,
|
||||||
viewportOnTouchMove,
|
viewportOnTouchMove,
|
||||||
@@ -109,6 +112,11 @@ export default function OverlayScrollArea({
|
|||||||
assignRef(viewportRefProp, el);
|
assignRef(viewportRefProp, el);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setWrapNode = (el: HTMLDivElement | null) => {
|
||||||
|
wrapRef.current = el;
|
||||||
|
assignRef(wrapRefProp, el);
|
||||||
|
};
|
||||||
|
|
||||||
const rootClass = [
|
const rootClass = [
|
||||||
'overlay-scroll',
|
'overlay-scroll',
|
||||||
RAIL_INSET_CLASS[railInset],
|
RAIL_INSET_CLASS[railInset],
|
||||||
@@ -121,7 +129,7 @@ export default function OverlayScrollArea({
|
|||||||
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
|
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
|
<div ref={setWrapNode} className={rootClass} onMouseMove={onMouseMove}>
|
||||||
<div
|
<div
|
||||||
id={viewportId}
|
id={viewportId}
|
||||||
ref={setViewportNode}
|
ref={setViewportNode}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { copyTextToClipboard } from '../utils/serverMagicString';
|
|||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
|
||||||
import LyricsPane from './LyricsPane';
|
import LyricsPane from './LyricsPane';
|
||||||
import NowPlayingInfo from './NowPlayingInfo';
|
import NowPlayingInfo from './NowPlayingInfo';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
@@ -301,6 +301,7 @@ function QueuePanelHostOrSolo() {
|
|||||||
const clearQueue = usePlayerStore(s => s.clearQueue);
|
const clearQueue = usePlayerStore(s => s.clearQueue);
|
||||||
|
|
||||||
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
const reorderQueue = usePlayerStore(s => s.reorderQueue);
|
||||||
|
const removeTrack = usePlayerStore(s => s.removeTrack);
|
||||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||||
const enqueue = usePlayerStore(s => s.enqueue);
|
const enqueue = usePlayerStore(s => s.enqueue);
|
||||||
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
const enqueueAt = usePlayerStore(s => s.enqueueAt);
|
||||||
@@ -440,6 +441,16 @@ function QueuePanelHostOrSolo() {
|
|||||||
|
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hitTest = (cx: number, cy: number) => {
|
||||||
|
const el = asideRef.current;
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||||
|
};
|
||||||
|
return registerQueueDragHitTest(hitTest);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
|
||||||
/** Only these drag types may be dropped into the queue. */
|
/** Only these drag types may be dropped into the queue. */
|
||||||
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
|
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
|
||||||
@@ -507,6 +518,37 @@ function QueuePanelHostOrSolo() {
|
|||||||
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
return () => aside.removeEventListener('psy-drop', onPsyDrop);
|
||||||
}, [enqueueAt]);
|
}, [enqueueAt]);
|
||||||
|
|
||||||
|
// Drag a queue row outside the panel → remove (drop never reaches `aside`).
|
||||||
|
useEffect(() => {
|
||||||
|
const onDocPsyDrop = (e: Event) => {
|
||||||
|
if (!isQueueVisible) return;
|
||||||
|
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
|
||||||
|
if (!d?.data) return;
|
||||||
|
const cx = d.clientX;
|
||||||
|
const cy = d.clientY;
|
||||||
|
if (typeof cx !== 'number' || typeof cy !== 'number') return;
|
||||||
|
let parsed: { type?: string; index?: number } | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(d.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
|
||||||
|
const aside = asideRef.current;
|
||||||
|
if (!aside) return;
|
||||||
|
const r = aside.getBoundingClientRect();
|
||||||
|
const inside =
|
||||||
|
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
|
||||||
|
if (inside) return;
|
||||||
|
psyDragFromIdxRef.current = null;
|
||||||
|
externalDropTargetRef.current = null;
|
||||||
|
setExternalDropTarget(null);
|
||||||
|
removeTrack(parsed.index);
|
||||||
|
};
|
||||||
|
document.addEventListener('psy-drop', onDocPsyDrop);
|
||||||
|
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
|
||||||
|
}, [isQueueVisible, removeTrack]);
|
||||||
|
|
||||||
useEffect(function queueAutoScroll() {
|
useEffect(function queueAutoScroll() {
|
||||||
if (suppressNextAutoScrollRef.current) {
|
if (suppressNextAutoScrollRef.current) {
|
||||||
suppressNextAutoScrollRef.current = false;
|
suppressNextAutoScrollRef.current = false;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────
|
||||||
export interface DragPayload {
|
export interface DragPayload {
|
||||||
@@ -33,6 +34,8 @@ export interface DragPayload {
|
|||||||
interface DragState {
|
interface DragState {
|
||||||
payload: DragPayload | null;
|
payload: DragPayload | null;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
|
/** `queue_reorder` only: true when cursor is outside every registered queue rect */
|
||||||
|
queueReorderOutside?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragDropContextValue {
|
interface DragDropContextValue {
|
||||||
@@ -52,10 +55,41 @@ const Ctx = createContext<DragDropContextValue>({
|
|||||||
|
|
||||||
export const useDragDrop = () => useContext(Ctx);
|
export const useDragDrop = () => useContext(Ctx);
|
||||||
|
|
||||||
|
function isQueueReorderDrag(data: string): boolean {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data).type === 'queue_reorder';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hit-tests for queue UI rects (main sidebar + mini-player list). Used so the drag ghost only shows “remove” outside those bounds. */
|
||||||
|
const queueDragHitTests: Array<(x: number, y: number) => boolean> = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a function that returns true when (clientX, clientY) lies inside
|
||||||
|
* this window’s queue drop area. Unregister on cleanup.
|
||||||
|
*/
|
||||||
|
export function registerQueueDragHitTest(fn: (x: number, y: number) => boolean): () => void {
|
||||||
|
queueDragHitTests.push(fn);
|
||||||
|
return () => {
|
||||||
|
const i = queueDragHitTests.indexOf(fn);
|
||||||
|
if (i >= 0) queueDragHitTests.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeQueueReorderOutside(clientX: number, clientY: number): boolean {
|
||||||
|
if (queueDragHitTests.length === 0) return false;
|
||||||
|
const inside = queueDragHitTests.some((t) => t(clientX, clientY));
|
||||||
|
return !inside;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Ghost overlay ─────────────────────────────────────────────────
|
// ── Ghost overlay ─────────────────────────────────────────────────
|
||||||
function DragGhost({ state }: { state: DragState }) {
|
function DragGhost({ state }: { state: DragState }) {
|
||||||
if (!state.payload) return null;
|
if (!state.payload) return null;
|
||||||
const { label, coverUrl } = state.payload;
|
const { label, coverUrl, data } = state.payload;
|
||||||
|
const queueReorder = isQueueReorderDrag(data);
|
||||||
|
const showTrash = queueReorder && state.queueReorderOutside === true;
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -83,6 +117,14 @@ function DragGhost({ state }: { state: DragState }) {
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showTrash && (
|
||||||
|
<Trash2
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2.25}
|
||||||
|
aria-hidden
|
||||||
|
style={{ flexShrink: 0, color: 'var(--danger, var(--ctp-red, #f38ba8))' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{coverUrl && (
|
{coverUrl && (
|
||||||
<img
|
<img
|
||||||
src={coverUrl}
|
src={coverUrl}
|
||||||
@@ -119,7 +161,13 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// Clear any text selection the browser may have started during the
|
// Clear any text selection the browser may have started during the
|
||||||
// threshold detection phase (mousedown → mousemove before startDrag).
|
// threshold detection phase (mousedown → mousemove before startDrag).
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
setState({ payload, position: { x, y } });
|
setState({
|
||||||
|
payload,
|
||||||
|
position: { x, y },
|
||||||
|
...(isQueueReorderDrag(payload.data)
|
||||||
|
? { queueReorderOutside: computeQueueReorderOutside(x, y) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -135,7 +183,18 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// a text-selection drag, which causes element highlighting and
|
// a text-selection drag, which causes element highlighting and
|
||||||
// horizontal auto-scroll in grid containers.
|
// horizontal auto-scroll in grid containers.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
|
setState((prev) => {
|
||||||
|
if (!prev.payload) return prev;
|
||||||
|
const pos = { x: e.clientX, y: e.clientY };
|
||||||
|
if (!isQueueReorderDrag(prev.payload.data)) {
|
||||||
|
return { ...prev, position: pos };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
position: pos,
|
||||||
|
queueReorderOutside: computeQueueReorderOutside(pos.x, pos.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
|
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
|
||||||
@@ -146,14 +205,15 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
|||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
|
|
||||||
if (dispatchDrop) {
|
if (dispatchDrop) {
|
||||||
|
const p = stateRef.current.position;
|
||||||
|
const pl = stateRef.current.payload;
|
||||||
const evt = new CustomEvent('psy-drop', {
|
const evt = new CustomEvent('psy-drop', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
detail: stateRef.current.payload,
|
detail: pl
|
||||||
|
? { ...pl, clientX: p.x, clientY: p.y }
|
||||||
|
: pl,
|
||||||
});
|
});
|
||||||
const el = document.elementFromPoint(
|
const el = document.elementFromPoint(p.x, p.y);
|
||||||
stateRef.current.position.x,
|
|
||||||
stateRef.current.position.y,
|
|
||||||
);
|
|
||||||
if (el) el.dispatchEvent(evt);
|
if (el) el.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user