Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

775 lines
18 KiB
Svelte

<script lang="ts">
import type {
WFNode,
PortType,
NodeDataValue,
FileValue
} from "./workflow-types";
import { BaseTextbox } from "@gradio/textbox";
import { BaseStaticImage } from "@gradio/image";
import DownloadIcon from "./icons/DownloadIcon.svelte";
interface Props {
node: WFNode;
widgetPortId: string;
widgetType: PortType;
isReadonly: boolean;
ondatachange: (
nodeId: string,
portId: string,
value: NodeDataValue
) => void;
}
let { node, widgetPortId, widgetType, isReadonly, ondatachange }: Props =
$props();
const widgetPort = $derived(
node.outputs.find((p) => p.id === widgetPortId) ??
node.inputs.find((p) => p.id === widgetPortId)
);
const choices = $derived(widgetPort?.choices ?? null);
const hasChoices = $derived(!!choices?.length);
const multiselect = $derived(!!widgetPort?.multiselect);
let fileInputEl: HTMLInputElement | undefined = $state();
function getTextValue(): string {
const v = node.data?.[widgetPortId];
return typeof v === "string" ? v : "";
}
function getFileValue(): FileValue | null {
const v = node.data?.[widgetPortId];
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
}
function getNumberValue(): number {
const v = node.data?.[widgetPortId];
return typeof v === "number" ? v : 0;
}
function getBooleanValue(): boolean {
const v = node.data?.[widgetPortId];
return typeof v === "boolean" ? v : false;
}
function handleNumberInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
ondatachange(node.id, widgetPortId, parseFloat(target.value) || 0);
}
function handleBooleanInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
ondatachange(node.id, widgetPortId, target.checked);
}
function revoke_old_blob(): void {
const old = getFileValue();
if (old?.url?.startsWith("blob:")) URL.revokeObjectURL(old.url);
}
function adopt_file(file: File): void {
revoke_old_blob();
ondatachange(node.id, widgetPortId, {
name: file.name,
url: URL.createObjectURL(file),
mime: file.type
});
}
function handleFileSelect(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0];
if (file) adopt_file(file);
}
function handleFileDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer?.files?.[0];
if (file) adopt_file(file);
}
function clearFile() {
revoke_old_blob();
ondatachange(node.id, widgetPortId, null);
}
/**
* Download the current file value. Same-origin and blob/data URLs work
* via a direct `<a download>`. Cross-origin URLs (e.g. HF Inference
* results) often ignore the download attribute and navigate instead,
* so we fetch into a blob first and then trigger the link on the
* resulting object URL — that forces a save instead of a tab swap.
*/
async function downloadFile() {
const v = getFileValue();
if (!v?.url) return;
const name = v.name || "download";
const sameOrigin =
v.url.startsWith("blob:") ||
v.url.startsWith("data:") ||
(v.url.startsWith("http") &&
new URL(v.url, window.location.origin).origin ===
window.location.origin) ||
!v.url.startsWith("http");
try {
const url = sameOrigin
? v.url
: await fetch(v.url)
.then((r) => r.blob())
.then((b) => URL.createObjectURL(b));
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (!sameOrigin) setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch {
// Fall back to navigating; user can right-click → save.
window.open(v.url, "_blank", "noopener");
}
}
const i18n = (key: string) => key;
async function stubUpload(files: File[]): Promise<any[]> {
return files.map((f) => ({
url: URL.createObjectURL(f),
orig_name: f.name,
path: f.name,
mime_type: f.type,
size: f.size
}));
}
async function stubStream(): Promise<any> {
return;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="widget-zone nodrag nopan"
class:text-full={(widgetType === "text" || widgetType === "json") &&
!hasChoices}
onmousedown={(e) => e.stopPropagation()}
onpointerdown={(e) => e.stopPropagation()}
>
{#if hasChoices && choices}
{@const rawValue = node.data?.[widgetPortId]}
{@const selected = multiselect && Array.isArray(rawValue) ? rawValue : []}
{@const current =
!multiselect && typeof rawValue === "string" ? rawValue : ""}
<div class="widget-choices">
{#each choices as choice}
<label class="widget-choice-row">
{#if multiselect}
<input
type="checkbox"
class="widget-checkbox widget-checkbox-choice"
checked={selected.includes(choice)}
disabled={isReadonly}
onchange={(e) =>
ondatachange(
node.id,
widgetPortId,
e.currentTarget.checked
? [...selected, choice]
: selected.filter((c) => c !== choice)
)}
/>
{:else}
<input
type="radio"
class="widget-radio"
name="{node.id}-{widgetPortId}"
value={choice}
checked={current === choice}
disabled={isReadonly}
onchange={() => ondatachange(node.id, widgetPortId, choice)}
/>
{/if}
<span class="widget-checkbox-label">{choice}</span>
</label>
{/each}
</div>
{:else if widgetType === "text" || widgetType === "json"}
<div class="widget-text-wrap">
<div class="widget-gradio-wrap">
<BaseTextbox
value={getTextValue()}
label="text"
show_label={false}
lines={widgetType === "json" ? 4 : 3}
max_lines={8}
placeholder={isReadonly
? "Waiting for output..."
: widgetType === "json"
? '{"key": "value"}'
: "Enter text..."}
disabled={isReadonly}
onchange={(val) => {
if (node.data?.[widgetPortId] !== val)
ondatachange(node.id, widgetPortId, val);
}}
/>
</div>
</div>
{:else if widgetType === "number"}
<div class="widget-number-wrap">
{#if isReadonly}
<div class="widget-text-display">
{getNumberValue()}
</div>
{:else}
<input
class="widget-number"
type="number"
value={getNumberValue()}
oninput={handleNumberInput}
step="any"
/>
{/if}
</div>
{:else if widgetType === "boolean"}
<div class="widget-bool-wrap">
<label class="widget-checkbox-row">
<input
class="widget-checkbox"
type="checkbox"
checked={getBooleanValue()}
disabled={isReadonly}
onchange={handleBooleanInput}
/>
<span class="widget-checkbox-label"
>{getBooleanValue() ? "On" : "Off"}</span
>
</label>
</div>
{:else if widgetType === "image" || widgetType === "audio" || widgetType === "video" || widgetType === "file" || widgetType === "gallery" || widgetType === "model3d"}
{@const fileVal = getFileValue()}
{#if fileVal}
<div class="widget-preview">
{#if (widgetType === "image" || widgetType === "gallery") && isReadonly}
<div class="widget-gradio-wrap widget-gradio-image">
<BaseStaticImage
value={{
url: fileVal.url,
orig_name: fileVal.name,
path: fileVal.url,
mime_type: fileVal.mime,
meta: { _type: "gradio.FileData" }
}}
show_label={false}
{i18n}
buttons={[]}
/>
</div>
{:else if widgetType === "image" || widgetType === "gallery"}
<img class="widget-img" src={fileVal.url} alt={fileVal.name} />
{:else if widgetType === "audio"}
<audio class="widget-audio" controls src={fileVal.url}></audio>
{:else if widgetType === "video"}
<video class="widget-video" controls src={fileVal.url}></video>
{:else}
<div class="widget-file-info">
<span class="widget-file-name">{fileVal.name}</span>
</div>
{/if}
<div class="widget-preview-actions">
<button
class="widget-action"
onclick={downloadFile}
title="Download {fileVal.name}"
aria-label="Download"
>
<DownloadIcon />
</button>
{#if !isReadonly}
<button
class="widget-action widget-clear"
onclick={clearFile}
title="Clear"
aria-label="Clear">&times;</button
>
{/if}
</div>
</div>
{:else if isReadonly}
<div class="widget-placeholder">Waiting for output...</div>
{:else}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="widget-file-drop nodrag nopan"
role="button"
tabindex="0"
onclick={() => fileInputEl?.click()}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") fileInputEl?.click();
}}
onmousedown={(e) => e.stopPropagation()}
onpointerdown={(e) => e.stopPropagation()}
ondragover={(e) => {
e.preventDefault();
e.stopPropagation();
}}
ondrop={handleFileDrop}
>
<input
bind:this={fileInputEl}
type="file"
accept={widgetType === "image"
? "image/*"
: widgetType === "audio"
? "audio/*"
: widgetType === "video"
? "video/*"
: widgetType === "model3d"
? ".glb,.gltf,.obj,.stl"
: "*"}
onchange={handleFileSelect}
style="display: none"
/>
<span class="widget-drop-text">
Drop {widgetType} or click
</span>
</div>
{/if}
{/if}
</div>
<style>
/* ─── Gradio Component Wrapper ─── */
.widget-text-wrap,
.widget-number-wrap,
.widget-bool-wrap {
padding: 6px 12px 8px;
}
.widget-gradio-wrap {
font-size: 12px;
--input-text-size: 11px;
--input-text-weight: 400;
--input-padding: 8px 10px;
--input-background-fill: #101118;
--input-background-fill-focus: #101118;
--input-border-color: #1e1f2a;
--input-border-color-focus: var(--accent);
--input-border-width: 1px;
--input-radius: 6px;
--input-shadow: none;
--input-shadow-focus: 0 0 0 2px var(--accent-dim);
--input-placeholder-color: #4a4b58;
--body-text-color: #c8c9d2;
--font-sans: "JetBrains Mono", monospace;
--line-sm: 1.4;
--spacing-sm: 4px;
--weight-semibold: 600;
--layer-1: #101118;
--shadow-inset: none;
--button-secondary-background-fill: #1e1f2a;
--button-secondary-background-fill-hover: #2a2b36;
--button-secondary-text-color: #8b8d98;
--button-shadow-active: none;
--error-icon-color: #ef4444;
}
.widget-gradio-wrap :global(textarea),
.widget-gradio-wrap :global(input) {
font-family: "JetBrains Mono", monospace !important;
font-size: 11px !important;
line-height: 1.4 !important;
background: #101118 !important;
color: #c8c9d2 !important;
border: 1px solid #1e1f2a !important;
border-radius: 6px !important;
padding: 8px 10px !important;
outline: none !important;
box-shadow: none !important;
}
.widget-gradio-wrap :global(textarea:focus),
.widget-gradio-wrap :global(input:focus) {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent-dim) !important;
}
.widget-gradio-wrap :global(textarea::placeholder),
.widget-gradio-wrap :global(input::placeholder) {
color: #4a4b58 !important;
}
.widget-gradio-wrap :global(.block),
.widget-gradio-wrap :global(.wrap),
.widget-gradio-wrap :global(.container) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
gap: 0 !important;
}
.widget-gradio-wrap :global(.block.padded) {
padding: 0 !important;
}
.widget-gradio-wrap :global(.label-wrap),
.widget-gradio-wrap :global(.info-text),
.widget-gradio-wrap :global(.icon-button-wrapper),
.widget-gradio-wrap :global(.icon-buttons) {
display: none !important;
}
.widget-gradio-image {
overflow: hidden;
border-radius: 6px;
}
.widget-gradio-image :global(img) {
max-height: 140px !important;
width: 100% !important;
object-fit: contain !important;
display: block !important;
}
.widget-gradio-image :global(.image-container) {
max-height: 140px !important;
overflow: hidden !important;
}
.widget-gradio-image :global(.empty-wrapper) {
display: none !important;
}
.widget-gradio-wrap :global(textarea) {
min-height: 60px !important;
height: auto !important;
resize: vertical !important;
}
.widget-gradio-wrap :global(label) {
display: block !important;
}
.widget-gradio-wrap :global(.input-container) {
display: flex !important;
}
/* ─── Widget Zone ─── */
.widget-zone {
padding: 0;
border-top: 1px solid #1e1f2a;
/* Canvas blocks user-select to stop dbl-click selecting random
* UI text; opt the widget back in so users can highlight + copy
* text inside textboxes / display zones. */
user-select: text;
-webkit-user-select: text;
}
.widget-zone.text-full {
border-top: none;
}
.widget-zone.text-full .widget-text-wrap {
padding: 0;
}
.widget-zone.text-full :global(textarea) {
border-radius: 0 0 9px 9px !important;
border-top: 1px solid #1e1f2a !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
min-height: 120px !important;
width: 100% !important;
box-sizing: border-box !important;
resize: vertical !important;
}
.widget-zone.text-full :global(textarea:focus) {
border-top-color: var(--accent) !important;
box-shadow: none !important;
}
.widget-text-display {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
line-height: 1.4;
padding: 8px 10px;
border: 1px solid #1e1f2a;
border-radius: 6px;
background: #101118;
color: #5c5e6a;
min-height: 42px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.widget-number {
width: 100%;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
border: 1px solid #1e1f2a;
border-radius: 6px;
padding: 8px 10px;
background: #101118;
color: #c8c9d2;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.widget-number:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.widget-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
cursor: pointer;
}
.widget-choices {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px 10px;
}
.widget-choice-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.widget-radio {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
border-radius: 50%;
appearance: auto;
-webkit-appearance: radio;
}
.widget-checkbox-choice {
width: 14px;
height: 14px;
accent-color: var(--accent);
cursor: pointer;
appearance: auto;
-webkit-appearance: checkbox;
}
.widget-checkbox {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.widget-checkbox-label {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: #8b8d98;
}
.widget-file-info {
padding: 10px 12px;
}
.widget-file-name {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
color: #8b8d98;
word-break: break-all;
}
.widget-file-drop {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
border: none;
border-radius: 0 0 10px 10px;
background: #101118;
cursor: pointer;
transition: background 0.15s;
}
.widget-file-drop:hover {
background: #14151a;
}
.widget-file-drop input {
display: none;
}
.widget-drop-text {
font-size: 10.5px;
color: #4a4b58;
}
.widget-placeholder {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: #2e2f3d;
text-align: center;
padding: 24px 0;
background: #101118;
border-radius: 0 0 10px 10px;
}
.widget-preview {
position: relative;
overflow: hidden;
border-radius: 0 0 10px 10px;
}
.widget-img {
display: block;
width: 100%;
max-height: 320px;
object-fit: contain;
background: #101118;
}
.widget-audio {
display: block;
width: 100%;
height: 36px;
border-radius: 5px;
}
.widget-video {
display: block;
width: 100%;
max-height: 100px;
object-fit: contain;
border-radius: 5px;
}
.widget-preview-actions {
position: absolute;
top: 4px;
right: 4px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.widget-preview:hover .widget-preview-actions {
opacity: 1;
}
.widget-action {
width: 22px;
height: 22px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #d5d6de;
font-size: 13px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
background 0.15s,
color 0.15s;
}
.widget-action:hover {
background: rgba(0, 0, 0, 0.85);
color: #fff;
}
.widget-clear:hover {
background: rgba(239, 68, 68, 0.85);
color: #fff;
}
/* ─── Light mode ─── */
:global(body:not(.dark)) .widget-zone {
border-top-color: #e2e4ea;
}
:global(body:not(.dark)) .widget-gradio-wrap {
--input-background-fill: #f8f9fb;
--input-background-fill-focus: #ffffff;
--input-border-color: #e2e4ea;
--input-placeholder-color: #c0c2cc;
--body-text-color: #1a1b25;
--layer-1: #f8f9fb;
--button-secondary-background-fill: #f0f1f5;
--button-secondary-background-fill-hover: #e2e4ea;
--button-secondary-text-color: #6b6e78;
}
:global(body:not(.dark)) .widget-gradio-wrap :global(textarea),
:global(body:not(.dark)) .widget-gradio-wrap :global(input) {
background: #f8f9fb !important;
color: #1a1b25 !important;
border-color: #e2e4ea !important;
}
:global(body:not(.dark)) .widget-gradio-wrap :global(textarea::placeholder),
:global(body:not(.dark)) .widget-gradio-wrap :global(input::placeholder) {
color: #c0c2cc !important;
}
:global(body:not(.dark)) .widget-text-display {
background: #f8f9fb;
border-color: #e2e4ea;
color: #6b6e78;
}
:global(body:not(.dark)) .widget-number {
background: #f8f9fb;
border-color: #e2e4ea;
color: #1a1b25;
}
:global(body:not(.dark)) .widget-checkbox-label {
color: #6b6e78;
}
:global(body:not(.dark)) .widget-file-name {
color: #6b6e78;
}
:global(body:not(.dark)) .widget-file-drop {
background: #f8f9fb;
border-color: #d0d2dc;
}
:global(body:not(.dark)) .widget-file-drop:hover {
background: #f0f1f5;
}
:global(body:not(.dark)) .widget-drop-text {
color: #b0b2bc;
}
:global(body:not(.dark)) .widget-placeholder {
background: #f8f9fb;
border-color: #e2e4ea;
color: #b0b2bc;
}
:global(body:not(.dark)) .widget-preview {
background: #f8f9fb;
border-color: #e2e4ea;
}
</style>