cobalt/web/src/components/save/Omnibox.svelte
wukko 7e9b7542ac
web/Omnibox: workarounds for border rendering bugs in browsers
- fixes wonky input border in webkit
- fix bleeding rounded edges when focused in blink (caused by imperfect stacking of inset box-shadow and outset outline)

WOC (wukko-only-change) but it makes a huge difference imo
2025-05-22 18:16:32 +06:00

365 lines
9.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import env, { officialApiURL } from "$lib/env";
import { tick } from "svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
import { t } from "$lib/i18n/translations";
import dialogs from "$lib/state/dialogs";
import { link } from "$lib/state/omnibox";
import { hapticSwitch } from "$lib/haptics";
import { updateSetting } from "$lib/state/settings";
import { savingHandler } from "$lib/api/saving-handler";
import { pasteLinkFromClipboard } from "$lib/clipboard";
import { turnstileEnabled, turnstileSolved } from "$lib/state/turnstile";
import type { Optional } from "$lib/types/generic";
import type { DownloadModeOption } from "$lib/types/settings";
import ClearButton from "$components/save/buttons/ClearButton.svelte";
import DownloadButton from "$components/save/buttons/DownloadButton.svelte";
import Switcher from "$components/buttons/Switcher.svelte";
import OmniboxIcon from "$components/save/OmniboxIcon.svelte";
import ActionButton from "$components/buttons/ActionButton.svelte";
import SettingsButton from "$components/buttons/SettingsButton.svelte";
import IconMute from "$components/icons/Mute.svelte";
import IconMusic from "$components/icons/Music.svelte";
import IconSparkles from "$components/icons/Sparkles.svelte";
import IconClipboard from "$components/icons/Clipboard.svelte";
let linkInput: Optional<HTMLInputElement>;
const validLink = (url: string) => {
try {
return /^https?\:/i.test(new URL(url).protocol);
} catch {}
};
let isFocused = $state(false);
let isDisabled = $state(false);
let isLoading = $state(false);
let isBotCheckOngoing = $derived($turnstileEnabled && !$turnstileSolved);
let linkPrefill = $derived(
page.url.hash.replace("#", "")
|| (browser ? page.url.searchParams.get("u") : "")
|| ""
);
let downloadable = $derived(validLink($link));
let clearVisible = $derived($link && !isLoading);
$effect (() => {
if (linkPrefill) {
// prefilled link may be uri encoded
linkPrefill = decodeURIComponent(linkPrefill);
if (validLink(linkPrefill)) {
$link = linkPrefill;
}
// clear hash and query to prevent bookmarking unwanted links
if (browser) goto("/", { replaceState: true });
}
});
const pasteClipboard = async () => {
if ($dialogs.length > 0 || isDisabled || isLoading) {
return;
}
hapticSwitch();
const pastedData = await pasteLinkFromClipboard();
if (!pastedData) return;
const linkMatch = pastedData.match(/https?\:\/\/[^\s]+/g);
if (linkMatch) {
$link = linkMatch[0].split('')[0];
if (!isBotCheckOngoing) {
await tick(); // wait for button to render
savingHandler({ url: $link });
}
}
};
const changeDownloadMode = (mode: DownloadModeOption) => {
updateSetting({ save: { downloadMode: mode } });
};
const handleKeydown = (e: KeyboardEvent) => {
if (!linkInput || $dialogs.length > 0 || isDisabled || isLoading) {
return;
}
if (e.metaKey || e.ctrlKey || e.key === "/") {
linkInput.focus();
}
if (e.key === "Enter" && validLink($link) && isFocused) {
savingHandler({ url: $link });
}
if (["Escape", "Clear"].includes(e.key) && isFocused) {
$link = "";
}
if (e.target === linkInput) {
return;
}
switch (e.key) {
case "D":
pasteClipboard();
break;
case "J":
changeDownloadMode("auto");
break;
case "K":
changeDownloadMode("audio");
break;
case "L":
changeDownloadMode("mute");
break;
default:
break;
}
};
</script>
<svelte:window onkeydown={handleKeydown} />
<!--
if you want to remove the community instance label,
refer to the license first https://github.com/imputnet/cobalt/tree/main/web#license
-->
{#if env.DEFAULT_API !== officialApiURL}
<div id="instance-label">
{$t("save.label.community_instance")}
</div>
{/if}
<div id="omnibox">
<div
id="input-container"
class:focused={isFocused}
class:downloadable
class:clear-visible={clearVisible}
>
<OmniboxIcon loading={isLoading || isBotCheckOngoing} />
<input
id="link-area"
bind:value={$link}
bind:this={linkInput}
oninput={() => (isFocused = true)}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
maxlength="512"
placeholder={$t("save.input.placeholder")}
aria-label={isBotCheckOngoing
? $t("a11y.save.link_area.turnstile")
: $t("a11y.save.link_area")}
data-form-type="other"
disabled={isDisabled}
/>
<ClearButton click={() => ($link = "")} />
<DownloadButton
url={$link}
bind:disabled={isDisabled}
bind:loading={isLoading}
/>
</div>
<div id="action-container">
<Switcher>
<SettingsButton
settingContext="save"
settingId="downloadMode"
settingValue="auto"
>
<IconSparkles />
{$t("save.auto")}
</SettingsButton>
<SettingsButton
settingContext="save"
settingId="downloadMode"
settingValue="audio"
>
<IconMusic />
{$t("save.audio")}
</SettingsButton>
<SettingsButton
settingContext="save"
settingId="downloadMode"
settingValue="mute"
>
<IconMute />
{$t("save.mute")}
</SettingsButton>
</Switcher>
<ActionButton id="paste" click={pasteClipboard}>
<IconClipboard />
<span id="paste-desktop-text">{$t("save.paste")}</span>
<span id="paste-mobile-text">{$t("save.paste.long")}</span>
</ActionButton>
</div>
</div>
<style>
#omnibox {
display: flex;
flex-direction: column;
max-width: 640px;
width: 100%;
gap: 6px;
}
#input-container {
--input-padding: 10px;
display: flex;
box-shadow: 0 0 0 1.5px var(--input-border) inset;
/* webkit can't render the 1.5px box shadow properly,
so we duplicate the border as outline to fix it visually */
outline: 1.5px solid var(--input-border);
outline-offset: -1.5px;
border-radius: var(--border-radius);
align-items: center;
gap: var(--input-padding);
font-size: 14px;
flex: 1;
}
#input-container:not(.clear-visible) :global(#clear-button) {
display: none;
}
#input-container:not(.downloadable) :global(#download-button) {
display: none;
}
#input-container.clear-visible {
padding-right: var(--input-padding);
}
:global([dir="rtl"]) #input-container.clear-visible {
padding-right: unset;
padding-left: var(--input-padding);
}
#input-container.downloadable {
padding-right: 0;
}
#input-container.downloadable:dir(rtl) {
padding-left: 0;
}
#input-container.focused {
box-shadow: none;
outline: var(--secondary) 2px solid;
outline-offset: -1px;
}
#input-container.focused :global(#input-icons svg) {
stroke: var(--secondary);
}
#input-container.downloadable :global(#input-icons svg) {
stroke: var(--secondary);
}
#link-area {
display: flex;
width: 100%;
margin: 0;
padding: var(--input-padding) 0;
padding-left: calc(var(--input-padding) + 28px);
height: 18px;
align-items: center;
border: none;
outline: none;
background-color: transparent;
color: var(--secondary);
-webkit-tap-highlight-color: transparent;
flex: 1;
font-weight: 500;
/* workaround for safari */
font-size: inherit;
/* prevents input from poking outside of rounded corners */
border-radius: var(--border-radius);
}
:global([dir="rtl"]) #link-area {
padding-left: unset;
padding-right: calc(var(--input-padding) + 28px);
}
#link-area::placeholder {
color: var(--gray);
/* fix for firefox */
opacity: 1;
}
/* fix for safari */
input:disabled {
opacity: 1;
}
#action-container {
display: flex;
flex-direction: row;
}
#action-container {
justify-content: space-between;
}
#paste-mobile-text {
display: none;
}
#instance-label {
font-size: 13px;
color: var(--gray);
font-weight: 500;
}
@media screen and (max-width: 440px) {
#action-container {
flex-direction: column;
gap: 5px;
}
#action-container :global(.button) {
width: 100%;
}
#paste-mobile-text {
display: block;
}
#paste-desktop-text {
display: none;
}
}
</style>