mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add tooltip for errors\warnings
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { RefreshCw } from "@lucide/svelte";
|
import { RefreshCw } from "@lucide/svelte";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import Badge from "$lib/components/ui/Badge.svelte";
|
import Badge from "$lib/components/ui/Badge.svelte";
|
||||||
|
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,11 +16,11 @@
|
|||||||
dims?: string;
|
dims?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
noteTone?: "warning" | "info";
|
noteTone?: "warning" | "info";
|
||||||
// Runtime warnings (chain/download events): shown as badges next to the
|
// Runtime warnings (chain/source/download events): shown as badges next
|
||||||
// derived result note, but from a different source.
|
// to the derived result note, full message in the "?" popover.
|
||||||
warnings?: string[];
|
warnings?: { label: string; detail?: string }[];
|
||||||
// Runtime errors: danger badges; detail goes to the native tooltip until
|
// Runtime errors: danger badges; the full message lives in the
|
||||||
// a popover with full text exists.
|
// DetailBadge popover behind the "?" button.
|
||||||
errors?: { label: string; detail?: string }[];
|
errors?: { label: string; detail?: string }[];
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
}
|
}
|
||||||
@@ -61,15 +62,20 @@
|
|||||||
<Badge tone={noteTone} label={note} />
|
<Badge tone={noteTone} label={note} />
|
||||||
{/if}
|
{/if}
|
||||||
{#each errors as error (error.label + (error.detail ?? ""))}
|
{#each errors as error (error.label + (error.detail ?? ""))}
|
||||||
<Badge
|
<DetailBadge
|
||||||
tone="danger"
|
tone="danger"
|
||||||
label={error.label}
|
label={error.label}
|
||||||
title={error.detail}
|
detail={error.detail}
|
||||||
role="alert"
|
role="alert"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{#each warnings as warning (warning)}
|
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
|
||||||
<Badge tone="warning" label={warning} role="status" />
|
<DetailBadge
|
||||||
|
tone="warning"
|
||||||
|
label={warning.label}
|
||||||
|
detail={warning.detail}
|
||||||
|
role="status"
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
|
||||||
import UploadButton from "$lib/components/ui/UploadButton.svelte";
|
import UploadButton from "$lib/components/ui/UploadButton.svelte";
|
||||||
import Badge from "$lib/components/ui/Badge.svelte";
|
import DetailBadge from "$lib/components/ui/DetailBadge.svelte";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
import { outputFormatByMime } from "$lib/output-formats";
|
import { outputFormatByMime } from "$lib/output-formats";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
limitKb?: number;
|
limitKb?: number;
|
||||||
alphaLoss?: boolean;
|
alphaLoss?: boolean;
|
||||||
// Download-zone feedback: size-limit misses and action failures.
|
// Download-zone feedback: size-limit misses and action failures.
|
||||||
warnings?: string[];
|
warnings?: { label: string; detail?: string }[];
|
||||||
error?: { label: string; detail?: string } | null;
|
error?: { label: string; detail?: string } | null;
|
||||||
onupload: (file: File) => void;
|
onupload: (file: File) => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
@@ -67,14 +67,19 @@
|
|||||||
disabled={running}
|
disabled={running}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#each warnings as warning (warning)}
|
{#each warnings as warning (warning.label + (warning.detail ?? ""))}
|
||||||
<Badge tone="warning" label={warning} role="status" />
|
<DetailBadge
|
||||||
|
tone="warning"
|
||||||
|
label={warning.label}
|
||||||
|
detail={warning.detail}
|
||||||
|
role="status"
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{#if error}
|
{#if error}
|
||||||
<Badge
|
<DetailBadge
|
||||||
tone="danger"
|
tone="danger"
|
||||||
label={error.label}
|
label={error.label}
|
||||||
title={error.detail}
|
detail={error.detail}
|
||||||
role="alert"
|
role="alert"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
resultKind?: ResultKind;
|
resultKind?: ResultKind;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
resultNote?: ResultNote | null;
|
resultNote?: ResultNote | null;
|
||||||
warnings?: string[];
|
warnings?: { label: string; detail?: string }[];
|
||||||
errors?: { label: string; detail?: string }[];
|
errors?: { label: string; detail?: string }[];
|
||||||
fileResult?: FileResult | null;
|
fileResult?: FileResult | null;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
textSource?: string;
|
textSource?: string;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
fileCount?: number;
|
fileCount?: number;
|
||||||
warnings?: string[];
|
warnings?: { label: string; detail?: string }[];
|
||||||
errors?: { label: string; detail?: string }[];
|
errors?: { label: string; detail?: string }[];
|
||||||
ontextinput?: (text: string) => void;
|
ontextinput?: (text: string) => void;
|
||||||
onrendertext?: () => void;
|
onrendertext?: () => void;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import {
|
import {
|
||||||
chainStepTitle,
|
chainStepTitle,
|
||||||
chainWarningText,
|
chainWarningNotice,
|
||||||
pageDescription,
|
pageDescription,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
|
type WarningNotice,
|
||||||
} from "$lib/i18n/schema-tool-strings";
|
} from "$lib/i18n/schema-tool-strings";
|
||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
|
import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
|
||||||
@@ -124,20 +125,18 @@
|
|||||||
const stepFileSets = $derived(runner.stepFileSets);
|
const stepFileSets = $derived(runner.stepFileSets);
|
||||||
const runWarnings = $derived(runner.runWarnings);
|
const runWarnings = $derived(runner.runWarnings);
|
||||||
// Feedback zones: source tile / step tiles / download button area.
|
// Feedback zones: source tile / step tiles / download button area.
|
||||||
const sourceWarningTexts = $derived(
|
const sourceNotices = $derived(upload.sourceWarnings.map(chainWarningNotice));
|
||||||
upload.sourceWarnings.map(chainWarningText),
|
|
||||||
);
|
|
||||||
const warningsByStep = $derived.by(() => {
|
const warningsByStep = $derived.by(() => {
|
||||||
const byStep: string[][] = steps.map(() => []);
|
const byStep: WarningNotice[][] = steps.map(() => []);
|
||||||
for (const w of runWarnings) {
|
for (const w of runWarnings) {
|
||||||
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
|
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
|
||||||
byStep[w.step - 1].push(chainWarningText(w));
|
byStep[w.step - 1].push(chainWarningNotice(w));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return byStep;
|
return byStep;
|
||||||
});
|
});
|
||||||
const downloadWarningTexts = $derived(
|
const downloadNotices = $derived(
|
||||||
runner.downloadWarnings.map(chainWarningText),
|
runner.downloadWarnings.map(chainWarningNotice),
|
||||||
);
|
);
|
||||||
const stepError = $derived(runner.stepError);
|
const stepError = $derived(runner.stepError);
|
||||||
const sourceError = $derived(runner.sourceError);
|
const sourceError = $derived(runner.sourceError);
|
||||||
@@ -305,7 +304,7 @@
|
|||||||
textSource={upload.textSource}
|
textSource={upload.textSource}
|
||||||
running={busy}
|
running={busy}
|
||||||
fileCount={upload.sourceFiles.length}
|
fileCount={upload.sourceFiles.length}
|
||||||
warnings={sourceWarningTexts}
|
warnings={sourceNotices}
|
||||||
errors={errorBadge(sourceError)}
|
errors={errorBadge(sourceError)}
|
||||||
ontextinput={(v) => (upload.textSource = v)}
|
ontextinput={(v) => (upload.textSource = v)}
|
||||||
onrendertext={run}
|
onrendertext={run}
|
||||||
@@ -407,7 +406,7 @@
|
|||||||
quality={output.quality}
|
quality={output.quality}
|
||||||
limitKb={output.limitKb}
|
limitKb={output.limitKb}
|
||||||
alphaLoss={output.alphaLoss}
|
alphaLoss={output.alphaLoss}
|
||||||
warnings={downloadWarningTexts}
|
warnings={downloadNotices}
|
||||||
error={actionError
|
error={actionError
|
||||||
? {
|
? {
|
||||||
label: t("actions.actionFailed"),
|
label: t("actions.actionFailed"),
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n/t";
|
||||||
|
import Badge from "./Badge.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tone: "warning" | "danger" | "info";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
role?: "alert" | "status";
|
||||||
|
}
|
||||||
|
let { tone, label, detail = undefined, role = "status" }: Props = $props();
|
||||||
|
|
||||||
|
const id = $props.id();
|
||||||
|
// Anchor name is per-instance: with a shared name several popovers would
|
||||||
|
// fight over the same anchor element.
|
||||||
|
let anchorName = `--badge-${id}`;
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="detail-badge">
|
||||||
|
<Badge {tone} {label} {role} />
|
||||||
|
{#if detail}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="more"
|
||||||
|
style:anchor-name={anchorName}
|
||||||
|
aria-label={t("ui.detailsAria")}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
popovertarget={id}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
{id}
|
||||||
|
class="popover"
|
||||||
|
role="tooltip"
|
||||||
|
popover="auto"
|
||||||
|
style:position-anchor={anchorName}
|
||||||
|
onbeforetoggle={(e) => (open = e.newState === "open")}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
.more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Мини-размер цели в 24px: попадание пальцем без промаха. */
|
||||||
|
width: calc(var(--space-xxxl) * 0.66);
|
||||||
|
height: calc(var(--space-xxxl) * 0.66);
|
||||||
|
padding: 0;
|
||||||
|
border: var(--size-border) solid var(--color-border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--color-panel);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.more:hover,
|
||||||
|
.more:focus-visible {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-main);
|
||||||
|
}
|
||||||
|
/* Нативный popover живёт в top layer: light dismiss, Escape и раскладка
|
||||||
|
поверх всего — из коробки, без z-index и ручных подписок. */
|
||||||
|
.popover {
|
||||||
|
position: fixed;
|
||||||
|
margin: 0;
|
||||||
|
top: anchor(bottom);
|
||||||
|
left: anchor(left);
|
||||||
|
translate: 0 var(--space-s);
|
||||||
|
/* Не уезжает за правый край: доступная ширина считается от якоря. */
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - anchor(left) - var(--space-xl));
|
||||||
|
padding: var(--space-m) var(--space-l);
|
||||||
|
border: var(--size-border-thick) solid var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: var(--font-size-s) var(--font-mono);
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -70,6 +70,10 @@ export const en: Dict = {
|
|||||||
warnSizeLimitMiss:
|
warnSizeLimitMiss:
|
||||||
"Could not fit into {limit} KB — downloaded file is {actual} KB",
|
"Could not fit into {limit} KB — downloaded file is {actual} KB",
|
||||||
stepFailed: "Step {n} failed",
|
stepFailed: "Step {n} failed",
|
||||||
|
warnPartialShort: "Some files failed",
|
||||||
|
warnFirstOnlyShort: "First file only",
|
||||||
|
warnSourceSkipShort: "Files skipped",
|
||||||
|
warnSizeLimitMissShort: "Over size limit",
|
||||||
},
|
},
|
||||||
savedChains: {
|
savedChains: {
|
||||||
pageTitle: "Saved pipelines — easy-png-tools",
|
pageTitle: "Saved pipelines — easy-png-tools",
|
||||||
@@ -172,6 +176,7 @@ export const en: Dict = {
|
|||||||
nothingFound: "Nothing found — try another word.",
|
nothingFound: "Nothing found — try another word.",
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
|
detailsAria: "Show details",
|
||||||
showMask: "Show mask",
|
showMask: "Show mask",
|
||||||
decrease: "Decrease",
|
decrease: "Decrease",
|
||||||
increase: "Increase",
|
increase: "Increase",
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ export const ru: Dict = {
|
|||||||
warnSizeLimitMiss:
|
warnSizeLimitMiss:
|
||||||
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
|
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
|
||||||
stepFailed: "Шаг {n}: ошибка",
|
stepFailed: "Шаг {n}: ошибка",
|
||||||
|
warnPartialShort: "Часть файлов с ошибкой",
|
||||||
|
warnFirstOnlyShort: "Только первый файл",
|
||||||
|
warnSourceSkipShort: "Файлы пропущены",
|
||||||
|
warnSizeLimitMissShort: "Больше лимита",
|
||||||
},
|
},
|
||||||
savedChains: {
|
savedChains: {
|
||||||
pageTitle: "Сохранённые пайплайны — easy-png-tools",
|
pageTitle: "Сохранённые пайплайны — easy-png-tools",
|
||||||
@@ -173,6 +177,7 @@ export const ru: Dict = {
|
|||||||
nothingFound: "Ничего не найдено — попробуйте другое слово.",
|
nothingFound: "Ничего не найдено — попробуйте другое слово.",
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
|
detailsAria: "Показать подробности",
|
||||||
showMask: "Показать маску",
|
showMask: "Показать маску",
|
||||||
decrease: "Уменьшить",
|
decrease: "Уменьшить",
|
||||||
increase: "Увеличить",
|
increase: "Увеличить",
|
||||||
|
|||||||
@@ -68,23 +68,41 @@ export function verdictTone(key: string): "success" | "danger" | "info" {
|
|||||||
return "info";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable text for a chain run/source warning. */
|
/** Short label for a warning badge; detail holds the full message (popover). */
|
||||||
export function chainWarningText(w: ChainWarning): string {
|
export interface WarningNotice {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable badge + popover text for a chain run/source warning. */
|
||||||
|
export function chainWarningNotice(w: ChainWarning): WarningNotice {
|
||||||
switch (w.kind) {
|
switch (w.kind) {
|
||||||
case "partial":
|
case "partial":
|
||||||
return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total });
|
return {
|
||||||
|
label: t("chain.warnPartialShort"),
|
||||||
|
detail: t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total }),
|
||||||
|
};
|
||||||
case "firstOnly":
|
case "firstOnly":
|
||||||
return t("chain.warnFirstOnly", { n: w.step, total: w.total });
|
return {
|
||||||
|
label: t("chain.warnFirstOnlyShort"),
|
||||||
|
detail: t("chain.warnFirstOnly", { n: w.step, total: w.total }),
|
||||||
|
};
|
||||||
case "sourceSkip":
|
case "sourceSkip":
|
||||||
return t("chain.warnSourceSkip", {
|
return {
|
||||||
|
label: t("chain.warnSourceSkipShort"),
|
||||||
|
detail: t("chain.warnSourceSkip", {
|
||||||
skipped: w.skipped,
|
skipped: w.skipped,
|
||||||
total: w.total,
|
total: w.total,
|
||||||
});
|
}),
|
||||||
|
};
|
||||||
case "sizeLimitMiss":
|
case "sizeLimitMiss":
|
||||||
return t("chain.warnSizeLimitMiss", {
|
return {
|
||||||
|
label: t("chain.warnSizeLimitMissShort"),
|
||||||
|
detail: t("chain.warnSizeLimitMiss", {
|
||||||
limit: w.limitKb,
|
limit: w.limitKb,
|
||||||
actual: w.actualKb,
|
actual: w.actualKb,
|
||||||
});
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user