Rewrite BetterAudioPlayer(#935)
* BetterAudioPlayer with patches * small fixes * some extra fixes, formatting, etc --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
8f8901b7e7
commit
43ebdc2064
2 changed files with 207 additions and 230 deletions
|
|
@ -4,316 +4,292 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
import "./styles.css";
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { EquicordDevs } from "@utils/constants";
|
||||
import { classNameFactory } from "@utils/css";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { showToast, Toasts } from "@webpack/common";
|
||||
import { ColorUtils, React, showToast, Toasts } from "@webpack/common";
|
||||
|
||||
const fileSizeLimit = 12e6;
|
||||
const cl = classNameFactory("vc-better-audio-player-");
|
||||
const CORS_PROXY = "https://cors.keiran0.workers.dev?url=";
|
||||
const MAX_FILE_SIZE = 12e6;
|
||||
|
||||
function parseFileSize(size: string) {
|
||||
const [value, unit] = size.split(" ");
|
||||
const multiplier = {
|
||||
B: 1,
|
||||
KB: 1024,
|
||||
MB: 1024 ** 2,
|
||||
GB: 1024 ** 3,
|
||||
TB: 1024 ** 4,
|
||||
}[unit];
|
||||
if (!multiplier) return;
|
||||
return parseFloat(value) * multiplier;
|
||||
interface PlayerInstance {
|
||||
mediaRef: React.RefObject<HTMLAudioElement>;
|
||||
props: { src: string; type: string; };
|
||||
}
|
||||
|
||||
function getMetadata(audioElement: HTMLElement) {
|
||||
const metadataElement = audioElement.querySelector("[class*='metadataContent']");
|
||||
const nameElement = metadataElement?.querySelector("a");
|
||||
const sizeElement = audioElement.querySelector("[class*='metadataContent'] [class*='metadataSize']");
|
||||
const url = nameElement?.getAttribute("href");
|
||||
const audioElementLink = audioElement.querySelector("audio");
|
||||
function validateColor(value: string, key: string, fallback: string) {
|
||||
const rgbPattern = /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/;
|
||||
if (rgbPattern.test(value)) return;
|
||||
|
||||
if (!sizeElement?.textContent || !nameElement?.textContent || !url || !audioElementLink) return false;
|
||||
|
||||
const name = nameElement.textContent;
|
||||
const size = parseFileSize(sizeElement.textContent);
|
||||
|
||||
if (size && size > fileSizeLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const elements = [metadataElement?.parentElement, audioElement.querySelector("[class*='audioControls']")];
|
||||
|
||||
const computedStyle = getComputedStyle(audioElement);
|
||||
const parentBorderRadius = computedStyle.borderRadius;
|
||||
|
||||
if (settings.store.forceMoveBelow) {
|
||||
elements.forEach(element => {
|
||||
if (element) (element as HTMLElement).style.zIndex = "2";
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
size,
|
||||
url,
|
||||
audio: audioElementLink,
|
||||
parentBorderRadius: parentBorderRadius,
|
||||
};
|
||||
}
|
||||
|
||||
async function addListeners(audioElement: HTMLAudioElement, url: string, parentBorderRadius: string) {
|
||||
const madeURL = new URL(url);
|
||||
madeURL.searchParams.set("t", Date.now().toString());
|
||||
|
||||
const corsProxyUrl = "https://cors.keiran0.workers.dev?url=" + encodeURIComponent(madeURL.href);
|
||||
const response = await fetch(corsProxyUrl);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
|
||||
const source = audioContext.createMediaElementSource(audioElement);
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioContext.destination);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvasContext = canvas.getContext("2d");
|
||||
if (!canvasContext) return;
|
||||
|
||||
canvas.classList.add("better-audio-visualizer");
|
||||
audioElement.parentElement?.appendChild(canvas);
|
||||
|
||||
if (parentBorderRadius) canvas.style.borderRadius = parentBorderRadius;
|
||||
|
||||
function drawVisualizer() {
|
||||
if (!audioElement.paused) {
|
||||
requestAnimationFrame(drawVisualizer);
|
||||
if (value.startsWith("#")) {
|
||||
const rgb = ColorUtils.hexToRgb(value);
|
||||
if (rgb) {
|
||||
settings.store[key] = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
||||
return;
|
||||
}
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
analyser.getByteFrequencyData(frequencyData);
|
||||
|
||||
if (!canvasContext) return;
|
||||
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (settings.store.oscilloscope) drawOscilloscope(canvasContext, canvas, dataArray, bufferLength);
|
||||
if (settings.store.spectrograph) drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength);
|
||||
}
|
||||
|
||||
audioElement.src = blobUrl;
|
||||
audioElement.addEventListener("play", () => {
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume();
|
||||
}
|
||||
drawVisualizer();
|
||||
});
|
||||
|
||||
audioElement.addEventListener("pause", () => {
|
||||
audioContext.suspend();
|
||||
});
|
||||
showToast(`Invalid color format for ${key}, use "R, G, B" or "#RRGGBB"`, Toasts.Type.FAILURE);
|
||||
settings.store[key] = fallback;
|
||||
}
|
||||
|
||||
function drawOscilloscope(canvasContext, canvas, dataArray, bufferLength) {
|
||||
const sliceWidth = canvas.width / bufferLength;
|
||||
function maxTypedArray(arr: Uint8Array<ArrayBufferLike>): number {
|
||||
let max = 0;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] > max) max = arr[i];
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
function drawOscilloscope(ctx: CanvasRenderingContext2D, w: number, h: number, dataArray: Uint8Array<ArrayBufferLike>, bufferLength: number) {
|
||||
const sliceWidth = w / bufferLength;
|
||||
const [r, g, b] = settings.store.oscilloscopeColor.split(",").map(Number);
|
||||
const amp = 3;
|
||||
let x = 0;
|
||||
|
||||
const { oscilloscopeSolidColor, oscilloscopeColor } = settings.store;
|
||||
|
||||
const [r, g, b] = oscilloscopeColor.split(",").map(Number);
|
||||
|
||||
canvasContext.lineWidth = 2;
|
||||
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvasContext.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const v = dataArray[i] / 128.0;
|
||||
const y = (v * canvas.height) / 2;
|
||||
const v = (dataArray[i] - 128) / 128;
|
||||
const y = (h / 2) - (v * amp * h / 2);
|
||||
|
||||
if (oscilloscopeSolidColor) {
|
||||
canvasContext.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
if (settings.store.oscilloscopeSolidColor) {
|
||||
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
} else {
|
||||
const red = Math.min(r + (v * 100) + (i / bufferLength) * 155, 255);
|
||||
const green = Math.min(g + (v * 50) + (i / bufferLength) * 155, 255);
|
||||
const blue = Math.min(b + (v * 150) + (i / bufferLength) * 155, 255);
|
||||
|
||||
canvasContext.strokeStyle = `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
canvasContext.moveTo(x, y);
|
||||
} else {
|
||||
canvasContext.lineTo(x, y);
|
||||
const absV = Math.abs(v);
|
||||
ctx.strokeStyle = `rgb(${Math.min(r + absV * 100 + (i / bufferLength) * 155, 255)}, ${Math.min(g + absV * 50 + (i / bufferLength) * 155, 255)}, ${Math.min(b + absV * 150 + (i / bufferLength) * 155, 255)})`;
|
||||
}
|
||||
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
x += sliceWidth;
|
||||
}
|
||||
|
||||
canvasContext.stroke();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength) {
|
||||
const { spectrographSolidColor, spectrographColor } = settings.store;
|
||||
const maxHeight = canvas.height;
|
||||
const barWidth = canvas.width / bufferLength;
|
||||
function drawSpectrograph(ctx: CanvasRenderingContext2D, w: number, h: number, frequencyData: Uint8Array<ArrayBufferLike>, bufferLength: number) {
|
||||
const barWidth = w / bufferLength;
|
||||
const maxVal = maxTypedArray(frequencyData);
|
||||
if (maxVal === 0) return;
|
||||
|
||||
const [r, g, b] = settings.store.spectrographColor.split(",").map(Number);
|
||||
let x = 0;
|
||||
|
||||
const maxFrequencyValue = Math.max(...frequencyData);
|
||||
|
||||
if (maxFrequencyValue === 0 || !isFinite(maxFrequencyValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const normalizedHeight = (frequencyData[i] / maxFrequencyValue) * maxHeight;
|
||||
const barH = (frequencyData[i] / maxVal) * h;
|
||||
|
||||
if (spectrographSolidColor) {
|
||||
canvasContext.fillStyle = `rgb(${spectrographColor})`;
|
||||
if (settings.store.spectrographSolidColor) {
|
||||
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
} else {
|
||||
const [r, g, b] = spectrographColor.split(",").map(Number);
|
||||
|
||||
const red = Math.min(r + (i / bufferLength) * 155, 255);
|
||||
const green = Math.min(g + (i / bufferLength) * 155, 255);
|
||||
const blue = Math.min(b + (i / bufferLength) * 155, 255);
|
||||
|
||||
const gradient = canvasContext.createLinearGradient(x, canvas.height - normalizedHeight, x, canvas.height);
|
||||
const gradient = ctx.createLinearGradient(x, h - barH, x, h);
|
||||
gradient.addColorStop(0, `rgb(${red}, ${green}, ${blue})`);
|
||||
|
||||
const darkerColor = `rgb(${Math.max(red - 50, 0)},${Math.max(green - 50, 0)},${Math.max(blue - 50, 0)})`;
|
||||
|
||||
gradient.addColorStop(1, darkerColor);
|
||||
canvasContext.fillStyle = gradient;
|
||||
gradient.addColorStop(1, `rgb(${Math.max(red - 50, 0)}, ${Math.max(green - 50, 0)}, ${Math.max(blue - 50, 0)})`);
|
||||
ctx.fillStyle = gradient;
|
||||
}
|
||||
|
||||
canvasContext.fillRect(x, canvas.height - normalizedHeight, barWidth, normalizedHeight);
|
||||
ctx.fillRect(x, h - barH, barWidth, barH);
|
||||
x += barWidth + 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
function scanForAudioElements(element: HTMLElement) {
|
||||
element.querySelectorAll("[class*='wrapperAudio']:not([data-better-audio-processed])").forEach(audioElement => {
|
||||
(audioElement as HTMLElement).dataset.betterAudioProcessed = "true";
|
||||
const metadata = getMetadata(audioElement as HTMLElement);
|
||||
async function fetchAudioBlob(src: string): Promise<string | null> {
|
||||
const url = new URL(src);
|
||||
url.searchParams.set("t", Date.now().toString());
|
||||
|
||||
if (!metadata) return;
|
||||
const response = await fetch(CORS_PROXY + encodeURIComponent(url.href));
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength && Number(contentLength) > MAX_FILE_SIZE) return null;
|
||||
|
||||
addListeners(metadata.audio, metadata.url, metadata.parentBorderRadius);
|
||||
});
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
function createObserver(targetNode: HTMLElement) {
|
||||
const observer = new MutationObserver(mutations => {
|
||||
mutations.forEach(mutation => {
|
||||
if (mutation.type === "childList") {
|
||||
mutation.addedNodes.forEach(addedNode => {
|
||||
if (addedNode instanceof HTMLElement) {
|
||||
scanForAudioElements(addedNode);
|
||||
}
|
||||
});
|
||||
function Visualizer({ playerRef, src }: { playerRef: React.RefObject<HTMLAudioElement>; src: string; }) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const audioCtxRef = React.useRef<AudioContext | null>(null);
|
||||
const analyserRef = React.useRef<AnalyserNode | null>(null);
|
||||
const animFrameRef = React.useRef(0);
|
||||
const setupDoneRef = React.useRef(false);
|
||||
const blobUrlRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const audio = playerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!audio || !canvas) return () => { };
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const init = async () => {
|
||||
const blobUrl = await fetchAudioBlob(src).catch(() => null);
|
||||
if (cancelled || !blobUrl) return;
|
||||
|
||||
blobUrlRef.current = blobUrl;
|
||||
|
||||
const wasPlaying = !audio.paused;
|
||||
const { currentTime } = audio;
|
||||
audio.src = blobUrl;
|
||||
audio.currentTime = currentTime;
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
const source = audioCtx.createMediaElementSource(audio);
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioCtx.destination);
|
||||
audioCtxRef.current = audioCtx;
|
||||
analyserRef.current = analyser;
|
||||
setupDoneRef.current = true;
|
||||
|
||||
if (wasPlaying) {
|
||||
audio.play().catch(() => { });
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(targetNode, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
return observer;
|
||||
}
|
||||
};
|
||||
|
||||
function tryHexToRgb(hex) {
|
||||
if (hex.startsWith("#")) {
|
||||
const hexMatch = hex.match(/\w\w/g);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch.map(x => parseInt(x, 16));
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
const canvasCtx = canvas.getContext("2d");
|
||||
let dataArray: Uint8Array<ArrayBuffer> | null = null;
|
||||
let frequencyData: Uint8Array<ArrayBuffer> | null = null;
|
||||
|
||||
function handleColorChange(value, settingKey, defaultValue) {
|
||||
const rgbPattern = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/;
|
||||
const draw = () => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!canvasCtx || !analyser) return;
|
||||
|
||||
if (!value.match(rgbPattern)) {
|
||||
const rgb = tryHexToRgb(value);
|
||||
if (!dataArray || !frequencyData) {
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
dataArray = new Uint8Array(bufferLength);
|
||||
frequencyData = new Uint8Array(bufferLength);
|
||||
}
|
||||
|
||||
if (rgb.match(rgbPattern)) {
|
||||
settings.store[settingKey] = rgb;
|
||||
} else {
|
||||
showToast(`Invalid color format for ${settingKey}, make sure it's in the format 'R, G, B' or '#RRGGBB'`, Toasts.Type.FAILURE);
|
||||
settings.store[settingKey] = defaultValue;
|
||||
}
|
||||
} else {
|
||||
settings.store[settingKey] = value;
|
||||
}
|
||||
if (!audio.paused) animFrameRef.current = requestAnimationFrame(draw);
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
analyser.getByteFrequencyData(frequencyData);
|
||||
|
||||
const { width, height } = canvas.getBoundingClientRect();
|
||||
canvasCtx.clearRect(0, 0, width, height);
|
||||
if (settings.store.oscilloscope) drawOscilloscope(canvasCtx, width, height, dataArray, dataArray.length);
|
||||
if (settings.store.spectrograph) drawSpectrograph(canvasCtx, width, height, frequencyData, frequencyData.length);
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
if (!setupDoneRef.current) return;
|
||||
if (audioCtxRef.current?.state === "suspended") {
|
||||
audioCtxRef.current.resume();
|
||||
}
|
||||
draw();
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
audioCtxRef.current?.suspend();
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
audioCtxRef.current?.close();
|
||||
audioCtxRef.current = null;
|
||||
analyserRef.current = null;
|
||||
setupDoneRef.current = false;
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return () => { };
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * window.devicePixelRatio;
|
||||
canvas.height = rect.height * window.devicePixelRatio;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
};
|
||||
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
className={cl("canvas")}
|
||||
ref={canvasRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const settings = definePluginSettings({
|
||||
oscilloscope: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Enable oscilloscope visualizer",
|
||||
description: "Enable oscilloscope visualizer.",
|
||||
default: true,
|
||||
},
|
||||
spectrograph: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Enable spectrograph visualizer",
|
||||
description: "Enable spectrograph visualizer.",
|
||||
default: true,
|
||||
},
|
||||
oscilloscopeSolidColor: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Use solid color for oscilloscope",
|
||||
description: "Use a solid color for the oscilloscope.",
|
||||
default: false,
|
||||
},
|
||||
oscilloscopeColor: {
|
||||
type: OptionType.STRING,
|
||||
description: "Color for oscilloscope",
|
||||
description: "Color for the oscilloscope (R, G, B or #hex).",
|
||||
default: "255, 255, 255",
|
||||
onChange: value => handleColorChange(value, "oscilloscopeColor", "255, 255, 255"),
|
||||
onChange: value => validateColor(value, "oscilloscopeColor", "255, 255, 255"),
|
||||
},
|
||||
spectrographSolidColor: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Use solid color for spectrograph",
|
||||
description: "Use a solid color for the spectrograph.",
|
||||
default: false,
|
||||
},
|
||||
spectrographColor: {
|
||||
type: OptionType.STRING,
|
||||
description: "Color for spectrograph",
|
||||
description: "Color for the spectrograph (R, G, B or #hex).",
|
||||
default: "33, 150, 243",
|
||||
onChange: value => handleColorChange(value, "spectrographColor", "33, 150, 243"),
|
||||
},
|
||||
forceMoveBelow: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Force the visualizer below the audio player",
|
||||
default: true,
|
||||
onChange: value => validateColor(value, "spectrographColor", "33, 150, 243"),
|
||||
},
|
||||
});
|
||||
|
||||
let observer: MutationObserver | null = null;
|
||||
|
||||
export default definePlugin({
|
||||
name: "BetterAudioPlayer",
|
||||
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players",
|
||||
description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players.",
|
||||
authors: [EquicordDevs.creations],
|
||||
settings,
|
||||
start() {
|
||||
const waitForContent = () => {
|
||||
const targetNode = document.querySelector("[class*='content']");
|
||||
if (targetNode) {
|
||||
scanForAudioElements(targetNode as HTMLElement);
|
||||
observer = createObserver(targetNode as HTMLElement);
|
||||
} else {
|
||||
requestAnimationFrame(waitForContent);
|
||||
}
|
||||
};
|
||||
waitForContent();
|
||||
},
|
||||
stop() {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: "}renderPlayIcon(){",
|
||||
replacement: {
|
||||
match: /this\.renderAudio\(\):this\.renderVideo\(\)/,
|
||||
replace: "$&,$self.renderVisualizer(this)",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
renderVisualizer(player: PlayerInstance) {
|
||||
if (player.props.type !== "AUDIO") return null;
|
||||
return <Visualizer playerRef={player.mediaRef} src={player.props.src} key={player.props.src} />;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.better-audio-visualizer {
|
||||
.vc-better-audio-player-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
|
@ -7,4 +7,5 @@
|
|||
pointer-events: none;
|
||||
z-index: 1;
|
||||
border: none;
|
||||
}
|
||||
border-radius: inherit;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue