mirror of
https://github.com/gradio-app/gradio.git
synced 2025-02-23 11:39:17 +08:00
Allow interactive input in gr.HighlightedText
(#5400)
* add preprocess param to highlighted_text.py * add params * static tweaks * add interactive highlight container * highlight selection logic * allow editing label value and move shared funcs * add changeset * remove py code * wait for input render * remove redundant event listeners * accessibility enhancements and remove label logic * add keyboard navigation and interaction * merge adjacent empty elements and split input element * add interactive support for scores mode * remove merge adjacent logic and move to frontend * tweak * add changeset * format backend * tweaks * backend test tweaks * set the interactive default to None * BE tweak * unit tests and stories * be formatting * fix label errors * tweak * fix tests * fix tests * fix test --------- Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com> Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
This commit is contained in:
parent
05715f5599
commit
d112e2611b
7
.changeset/ripe-ideas-rest.md
Normal file
7
.changeset/ripe-ideas-rest.md
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
"@gradio/app": minor
|
||||
"@gradio/highlightedtext": minor
|
||||
"gradio": minor
|
||||
---
|
||||
|
||||
feat:Allow interactive input in `gr.HighlightedText`
|
@ -11,11 +11,7 @@ from gradio_client.serializing import (
|
||||
|
||||
from gradio.components.base import IOComponent, _Keywords
|
||||
from gradio.deprecation import warn_style_method_deprecation
|
||||
from gradio.events import (
|
||||
Changeable,
|
||||
EventListenerMethod,
|
||||
Selectable,
|
||||
)
|
||||
from gradio.events import Changeable, EventListenerMethod, Selectable
|
||||
|
||||
set_documentation_group("component")
|
||||
|
||||
@ -24,7 +20,7 @@ set_documentation_group("component")
|
||||
class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
"""
|
||||
Displays text that contains spans that are highlighted by category or numerical value.
|
||||
Preprocessing: this component does *not* accept input.
|
||||
Preprocessing: passes a list of tuples as a {List[Tuple[str, float | str | None]]]} into the function. If no labels are provided, the text will be displayed as a single span.
|
||||
Postprocessing: expects a {List[Tuple[str, float | str]]]} consisting of spans of text and their associated labels, or a {Dict} with two keys: (1) "text" whose value is the complete text, and (2) "entities", which is a list of dictionaries, each of which have the keys: "entity" (consisting of the entity label, can alternatively be called "entity_group"), "start" (the character index where the label starts), and "end" (the character index where the label ends). Entities should not overlap.
|
||||
|
||||
Demos: diff_texts, text_analysis
|
||||
@ -49,6 +45,7 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
visible: bool = True,
|
||||
elem_id: str | None = None,
|
||||
elem_classes: list[str] | str | None = None,
|
||||
interactive: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@ -66,6 +63,8 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
visible: If False, component will be hidden.
|
||||
elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles.
|
||||
elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles.
|
||||
interactive: If True, the component will be editable, and allow user to select spans of text and label them.
|
||||
|
||||
"""
|
||||
self.color_map = color_map
|
||||
self.show_legend = show_legend
|
||||
@ -89,6 +88,7 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
elem_id=elem_id,
|
||||
elem_classes=elem_classes,
|
||||
value=value,
|
||||
interactive=interactive,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@ -98,6 +98,7 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
"show_legend": self.show_legend,
|
||||
"value": self.value,
|
||||
"selectable": self.selectable,
|
||||
"combine_adjacent": self.combine_adjacent,
|
||||
**IOComponent.get_config(self),
|
||||
}
|
||||
|
||||
@ -115,6 +116,7 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
scale: int | None = None,
|
||||
min_width: int | None = None,
|
||||
visible: bool | None = None,
|
||||
interactive: bool | None = None,
|
||||
):
|
||||
updated_config = {
|
||||
"color_map": color_map,
|
||||
@ -126,6 +128,7 @@ class HighlightedText(Changeable, Selectable, IOComponent, JSONSerializable):
|
||||
"min_width": min_width,
|
||||
"visible": visible,
|
||||
"value": value,
|
||||
"interactive": interactive,
|
||||
"__type__": "update",
|
||||
}
|
||||
return updated_config
|
||||
|
@ -62,7 +62,8 @@ export const component_map = {
|
||||
static: () => import("@gradio/group/static")
|
||||
},
|
||||
highlightedtext: {
|
||||
static: () => import("@gradio/highlightedtext/static")
|
||||
static: () => import("@gradio/highlightedtext/static"),
|
||||
interactive: () => import("@gradio/highlightedtext/interactive")
|
||||
},
|
||||
html: {
|
||||
static: () => import("@gradio/html/static")
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { Meta, Template, Story } from "@storybook/addon-svelte-csf";
|
||||
import HighlightedText from "./static";
|
||||
import HighlightedText from "./interactive/InteractiveHighlightedText.svelte";
|
||||
import { Gradio } from "../app/src/gradio_helper";
|
||||
</script>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
value={[
|
||||
["zebras", "+"],
|
||||
["dogs", "-"],
|
||||
["elephants", "+"]
|
||||
["elephants", "+"],
|
||||
]}
|
||||
gradio={new Gradio(
|
||||
0,
|
||||
@ -30,10 +30,72 @@
|
||||
<Story
|
||||
name="Highlighted Text with new lines"
|
||||
args={{
|
||||
value: [["zebras", "+"], ["\n"], ["dogs", "-"], ["\n"], ["elephants", "+"]]
|
||||
value: [["zebras", "+"], ["\n"], ["dogs", "-"], ["\n"], ["elephants", "+"]],
|
||||
}}
|
||||
/>
|
||||
<Story
|
||||
name="Highlighted Text with color map"
|
||||
args={{ color_map: { "+": "green", "-": "red" } }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Highlighted Text with combine adjacent"
|
||||
args={{
|
||||
value: [
|
||||
["The", null],
|
||||
["quick", "adjective"],
|
||||
[" sneaky", "adjective"],
|
||||
["fox", "subject"],
|
||||
[" jumped ", "past tense verb"],
|
||||
["over the", null],
|
||||
["lazy dog", "object"],
|
||||
],
|
||||
combine_adjacent: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Highlighted Text without combine adjacent"
|
||||
args={{
|
||||
value: [
|
||||
["The", null],
|
||||
["quick", "adjective"],
|
||||
[" sneaky", "adjective"],
|
||||
["fox", "subject"],
|
||||
[" jumped ", "past tense verb"],
|
||||
["over the", null],
|
||||
["lazy dog", "object"],
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Highlighted Text with combine adjacent and new lines"
|
||||
args={{
|
||||
value: [
|
||||
["The", null],
|
||||
["quick", "adjective"],
|
||||
[" sneaky", "adjective"],
|
||||
["fox", "subject"],
|
||||
["\n"],
|
||||
["jumped", "past tense verb"],
|
||||
["\n"],
|
||||
["over the", null],
|
||||
["lazy dog", "object"],
|
||||
],
|
||||
combine_adjacent: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Highlighted Text in scores mode"
|
||||
args={{
|
||||
value: [
|
||||
["the", -1],
|
||||
["quick", 1],
|
||||
["fox", 0.3],
|
||||
],
|
||||
|
||||
show_legend: true,
|
||||
}}
|
||||
/>
|
||||
|
75
js/highlightedtext/highlightedtext.test.ts
Normal file
75
js/highlightedtext/highlightedtext.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { test, describe, assert, afterEach } from "vitest";
|
||||
import { cleanup, fireEvent, render } from "@gradio/tootils";
|
||||
import { setupi18n } from "../app/src/i18n";
|
||||
|
||||
import HighlightedText from "./interactive";
|
||||
import type { LoadingStatus } from "@gradio/statustracker";
|
||||
|
||||
const loading_status: LoadingStatus = {
|
||||
eta: 0,
|
||||
queue_position: 1,
|
||||
queue_size: 1,
|
||||
status: "complete" as LoadingStatus["status"],
|
||||
scroll_to_output: false,
|
||||
visible: true,
|
||||
fn_index: 0,
|
||||
show_progress: "full"
|
||||
};
|
||||
|
||||
describe("HighlightedText", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
setupi18n();
|
||||
|
||||
test("renders provided text and labels", async () => {
|
||||
const { getByText, getByTestId, getAllByText } = await render(
|
||||
HighlightedText,
|
||||
{
|
||||
loading_status,
|
||||
value: [
|
||||
["The", null],
|
||||
["quick", "adjective"],
|
||||
[" sneaky", "adjective"],
|
||||
["fox", "subject"],
|
||||
[" jumped ", "past tense verb"],
|
||||
["over the", null],
|
||||
["lazy dog", "object"]
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
const quick = getByText("quick");
|
||||
const adjectiveLabels = getAllByText("adjective");
|
||||
|
||||
assert.exists(quick);
|
||||
assert.exists(adjectiveLabels);
|
||||
assert.equal(adjectiveLabels.length, 2);
|
||||
});
|
||||
|
||||
test("renders labels with remove label buttons which trigger change", async () => {
|
||||
const { getAllByText, listen } = await render(HighlightedText, {
|
||||
loading_status,
|
||||
value: [
|
||||
["The", null],
|
||||
["quick", "adjective"],
|
||||
[" sneaky", "adjective"],
|
||||
["fox", "subject"],
|
||||
[" jumped ", "past tense verb"],
|
||||
["over the", null],
|
||||
["lazy dog", "object"]
|
||||
]
|
||||
});
|
||||
|
||||
const mock = listen("change");
|
||||
|
||||
const removeButtons = getAllByText("×");
|
||||
|
||||
assert.equal(removeButtons.length, 5);
|
||||
|
||||
assert.equal(mock.callCount, 0);
|
||||
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
assert.equal(mock.callCount, 1);
|
||||
});
|
||||
});
|
495
js/highlightedtext/interactive/Highlightedtext.svelte
Normal file
495
js/highlightedtext/interactive/Highlightedtext.svelte
Normal file
@ -0,0 +1,495 @@
|
||||
<script lang="ts">
|
||||
const browser = typeof document !== "undefined";
|
||||
import { get_next_color } from "@gradio/utils";
|
||||
import type { SelectData } from "@gradio/utils";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { correct_color_map, merge_elements } from "../utils";
|
||||
import LabelInput from "./LabelInput.svelte";
|
||||
|
||||
export let value: [string, string | number | null][] = [];
|
||||
export let show_legend = false;
|
||||
export let color_map: Record<string, string> = {};
|
||||
export let selectable = false;
|
||||
|
||||
let activeElementIndex = -1;
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
let _color_map: Record<string, { primary: string; secondary: string }> = {};
|
||||
let active = "";
|
||||
let selection: Selection | null;
|
||||
let labelToEdit = -1;
|
||||
|
||||
onMount(() => {
|
||||
const mouseUpHandler = (): void => {
|
||||
selection = window.getSelection();
|
||||
handleSelectionComplete();
|
||||
window.removeEventListener("mouseup", mouseUpHandler);
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", () => {
|
||||
window.addEventListener("mouseup", mouseUpHandler);
|
||||
});
|
||||
});
|
||||
|
||||
async function handleTextSelected(
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): Promise<void> {
|
||||
if (
|
||||
selection?.toString() &&
|
||||
activeElementIndex !== -1 &&
|
||||
value[activeElementIndex][0].toString().includes(selection.toString())
|
||||
) {
|
||||
const tempFlag = Symbol();
|
||||
|
||||
const str = value[activeElementIndex][0];
|
||||
const [before, selected, after] = [
|
||||
str.substring(0, startIndex),
|
||||
str.substring(startIndex, endIndex),
|
||||
str.substring(endIndex),
|
||||
];
|
||||
|
||||
let tempValue: [string, string | number | null, symbol?][] = [
|
||||
...value.slice(0, activeElementIndex),
|
||||
[before, null],
|
||||
[selected, mode === "scores" ? 1 : "label", tempFlag], // add a temp flag to the new highlighted text element
|
||||
[after, null],
|
||||
...value.slice(activeElementIndex + 1),
|
||||
];
|
||||
|
||||
// store the index of the new highlighted text element and remove the flag
|
||||
labelToEdit = tempValue.findIndex(([_, __, flag]) => flag === tempFlag);
|
||||
tempValue[labelToEdit].pop();
|
||||
|
||||
// remove elements with empty labels
|
||||
tempValue = tempValue.filter((item) => item[0].trim() !== "");
|
||||
value = tempValue as [string, string | number | null][];
|
||||
|
||||
handleValueChange();
|
||||
document.getElementById(`label-input-${labelToEdit}`)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: SelectData;
|
||||
change: typeof value;
|
||||
input: never;
|
||||
}>();
|
||||
|
||||
function splitTextByNewline(text: string): string[] {
|
||||
return text.split("\n");
|
||||
}
|
||||
|
||||
function removeHighlightedText(index: number): void {
|
||||
if (index < 0 || index >= value.length) return;
|
||||
value[index][1] = null;
|
||||
value = merge_elements(value, "equal");
|
||||
handleValueChange();
|
||||
window.getSelection()?.empty();
|
||||
}
|
||||
|
||||
function handleValueChange(): void {
|
||||
dispatch("change", value);
|
||||
labelToEdit = -1;
|
||||
|
||||
// reset legend color maps
|
||||
if (show_legend) {
|
||||
color_map = {};
|
||||
_color_map = {};
|
||||
}
|
||||
}
|
||||
|
||||
let mode: "categories" | "scores";
|
||||
|
||||
$: {
|
||||
if (!color_map) {
|
||||
color_map = {};
|
||||
}
|
||||
if (value.length > 0) {
|
||||
for (let [_, label] of value) {
|
||||
if (label !== null) {
|
||||
if (typeof label === "string") {
|
||||
mode = "categories";
|
||||
if (!(label in color_map)) {
|
||||
let color = get_next_color(Object.keys(color_map).length);
|
||||
color_map[label] = color;
|
||||
}
|
||||
} else {
|
||||
mode = "scores";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
correct_color_map(color_map, _color_map, browser, ctx);
|
||||
}
|
||||
|
||||
function handle_mouseover(label: string): void {
|
||||
active = label;
|
||||
}
|
||||
function handle_mouseout(): void {
|
||||
active = "";
|
||||
}
|
||||
|
||||
async function handleKeydownSelection(event: KeyboardEvent): Promise<void> {
|
||||
selection = window.getSelection();
|
||||
|
||||
if (event.key === "Enter") {
|
||||
handleSelectionComplete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectionComplete(): void {
|
||||
if (selection && selection?.toString().trim() !== "") {
|
||||
const textBeginningIndex = selection.getRangeAt(0).startOffset;
|
||||
const textEndIndex = selection.getRangeAt(0).endOffset;
|
||||
handleTextSelected(textBeginningIndex, textEndIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(
|
||||
i: number,
|
||||
text: string,
|
||||
category: string | number | null
|
||||
): void {
|
||||
dispatch("select", {
|
||||
index: i,
|
||||
value: [text, category],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if mode === "categories"}
|
||||
{#if show_legend}
|
||||
<div
|
||||
class="category-legend"
|
||||
data-testid="highlighted-text:category-legend"
|
||||
>
|
||||
{#if _color_map}
|
||||
{#each Object.entries(_color_map) as [category, color], i}
|
||||
<div
|
||||
role="button"
|
||||
aria-roledescription="Categories of highlighted text. Hover to see text with this category highlighted."
|
||||
tabindex="0"
|
||||
on:mouseover={() => handle_mouseover(category)}
|
||||
on:focus={() => handle_mouseover(category)}
|
||||
on:mouseout={() => handle_mouseout()}
|
||||
on:blur={() => handle_mouseout()}
|
||||
class="category-label"
|
||||
style={"background-color:" + color.secondary}
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="textfield">
|
||||
{#each value as [text, category], i}
|
||||
{#each splitTextByNewline(text) as line, j}
|
||||
{#if line.trim() !== ""}
|
||||
<span class="text-category-container">
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="textspan"
|
||||
style:background-color={category === null ||
|
||||
(active && active !== category)
|
||||
? ""
|
||||
: category && _color_map[category]
|
||||
? _color_map[category].secondary
|
||||
: ""}
|
||||
class:no-cat={category === null ||
|
||||
(active && active !== category)}
|
||||
class:hl={category !== null}
|
||||
class:selectable
|
||||
on:click={() => {
|
||||
if (category !== null) {
|
||||
handleSelect(i, text, category);
|
||||
}
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (category !== null) {
|
||||
labelToEdit = i;
|
||||
handleSelect(i, text, category);
|
||||
} else {
|
||||
handleKeydownSelection(e);
|
||||
}
|
||||
}}
|
||||
on:focus={() => (activeElementIndex = i)}
|
||||
on:mouseover={() => (activeElementIndex = i)}
|
||||
>
|
||||
<span
|
||||
class:no-label={category === null}
|
||||
class="text"
|
||||
role="button"
|
||||
on:keydown={(e) => handleKeydownSelection(e)}
|
||||
on:focus={() => (activeElementIndex = i)}
|
||||
on:mouseover={() => (activeElementIndex = i)}
|
||||
on:click={() => (labelToEdit = i)}
|
||||
tabindex="0">{line}</span
|
||||
>
|
||||
{#if !show_legend && category !== null && labelToEdit !== i}
|
||||
<span
|
||||
id={`label-tag-${i}`}
|
||||
class="label"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style:background-color={category === null ||
|
||||
(active && active !== category)
|
||||
? ""
|
||||
: _color_map[category].primary}
|
||||
on:click={() => (labelToEdit = i)}
|
||||
on:keydown={() => (labelToEdit = i)}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
{/if}
|
||||
{#if labelToEdit === i && category !== null}
|
||||
|
||||
<LabelInput
|
||||
bind:value
|
||||
{labelToEdit}
|
||||
{category}
|
||||
{active}
|
||||
{_color_map}
|
||||
indexOfLabel={i}
|
||||
{text}
|
||||
{handleValueChange}
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{#if category !== null}
|
||||
<span
|
||||
class="label-clear-button"
|
||||
role="button"
|
||||
aria-roledescription="Remove label from text"
|
||||
tabindex="0"
|
||||
on:click={() => removeHighlightedText(i)}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
removeHighlightedText(i);
|
||||
}
|
||||
}}
|
||||
>×
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if j < splitTextByNewline(text).length - 1}
|
||||
<br />
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if show_legend}
|
||||
<div class="color-legend" data-testid="highlighted-text:color-legend">
|
||||
<span>-1</span>
|
||||
<span>0</span>
|
||||
<span>+1</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="textfield" data-testid="highlighted-text:textfield">
|
||||
{#each value as [text, _score], i}
|
||||
{@const score = typeof _score === "string" ? parseInt(_score) : _score}
|
||||
<span class="score-text-container">
|
||||
<span
|
||||
class="textspan score-text"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class:no-cat={_score === null || (active && active !== _score)}
|
||||
class:hl={_score !== null}
|
||||
on:mouseover={() => (activeElementIndex = i)}
|
||||
on:focus={() => (activeElementIndex = i)}
|
||||
on:click={() => (labelToEdit = i)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
labelToEdit = i;
|
||||
}
|
||||
}}
|
||||
style={"background-color: rgba(" +
|
||||
(score && score < 0
|
||||
? "128, 90, 213," + -score
|
||||
: "239, 68, 60," + score) +
|
||||
")"}
|
||||
>
|
||||
<span class="text">{text}</span>
|
||||
{#if _score && labelToEdit === i}
|
||||
<LabelInput
|
||||
bind:value
|
||||
{labelToEdit}
|
||||
{_color_map}
|
||||
category={_score}
|
||||
{active}
|
||||
indexOfLabel={i}
|
||||
{text}
|
||||
{handleValueChange}
|
||||
isScoresMode
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
{#if _score && activeElementIndex === i}
|
||||
<span
|
||||
class="label-clear-button"
|
||||
role="button"
|
||||
aria-roledescription="Remove label from text"
|
||||
tabindex="0"
|
||||
on:click={() => removeHighlightedText(i)}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
removeHighlightedText(i);
|
||||
}
|
||||
}}
|
||||
>×
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.label-clear-button {
|
||||
display: none;
|
||||
border-radius: var(--radius-xs);
|
||||
padding-top: 2.5px;
|
||||
padding-right: var(--size-1);
|
||||
padding-bottom: 3.5px;
|
||||
padding-left: var(--size-1);
|
||||
color: black;
|
||||
background-color: var(--background-fill-secondary);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
left: -3px;
|
||||
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
|
||||
color: var(--block-label-text-color);
|
||||
}
|
||||
|
||||
.text-category-container:hover .label-clear-button,
|
||||
.text-category-container:focus-within .label-clear-button,
|
||||
.score-text-container:hover .label-clear-button,
|
||||
.score-text-container:focus-within .label-clear-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.text-category-container:hover .textspan.hl,
|
||||
.text-category-container:focus-within .textspan.hl,
|
||||
.score-text:hover {
|
||||
border-radius: var(--radius-xs) 0 0 var(--radius-xs);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--block-padding);
|
||||
}
|
||||
|
||||
.hl {
|
||||
margin-left: var(--size-1);
|
||||
transition: background-color 0.3s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.textspan:last-child > .label {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.category-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
padding-right: var(--size-2);
|
||||
padding-left: var(--size-2);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.color-legend {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--radius-xs);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-purple),
|
||||
rgba(255, 255, 255, 0),
|
||||
var(--color-red)
|
||||
);
|
||||
padding: var(--size-1) var(--size-2);
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
|
||||
.textfield {
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--background-fill-primary);
|
||||
background-color: transparent;
|
||||
max-width: var(--size-full);
|
||||
line-height: var(--scale-4);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.textspan {
|
||||
transition: 150ms;
|
||||
border-radius: var(--radius-xs);
|
||||
padding-top: 2.5px;
|
||||
padding-right: var(--size-1);
|
||||
padding-bottom: 3.5px;
|
||||
padding-left: var(--size-1);
|
||||
color: black;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.label {
|
||||
transition: 150ms;
|
||||
margin-top: 1px;
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 1px 5px;
|
||||
color: var(--body-text-color);
|
||||
color: white;
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: black;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.textspan.hl {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.score-text-container {
|
||||
margin-right: var(--size-1);
|
||||
}
|
||||
|
||||
.score-text .text {
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.no-cat {
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
.no-label {
|
||||
color: var(--body-text-color);
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.selectable {
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { Gradio, SelectData } from "@gradio/utils";
|
||||
import HighlightedText from "./Highlightedtext.svelte";
|
||||
import { Block, BlockLabel, Empty } from "@gradio/atoms";
|
||||
import { TextHighlight } from "@gradio/icons";
|
||||
import { StatusTracker } from "@gradio/statustracker";
|
||||
import type { LoadingStatus } from "@gradio/statustracker";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { merge_elements } from "../utils";
|
||||
|
||||
export let elem_id = "";
|
||||
export let elem_classes: string[] = [];
|
||||
export let visible = true;
|
||||
export let value: [string, string | number | null][];
|
||||
export let mode: "static" | "interactive";
|
||||
export let show_legend: boolean;
|
||||
export let color_map: Record<string, string> = {};
|
||||
export let label = $_("highlighted_text.highlighted_text");
|
||||
export let container = true;
|
||||
export let scale: number | null = null;
|
||||
export let min_width: number | undefined = undefined;
|
||||
export let selectable = false;
|
||||
export let combine_adjacent = false;
|
||||
export let gradio: Gradio<{
|
||||
select: SelectData;
|
||||
change: typeof value;
|
||||
input: never;
|
||||
}>;
|
||||
|
||||
$: if (!color_map && Object.keys(color_map).length) {
|
||||
color_map = color_map;
|
||||
}
|
||||
|
||||
export let loading_status: LoadingStatus;
|
||||
|
||||
$: if (combine_adjacent) {
|
||||
value = merge_elements(value, "equal");
|
||||
}
|
||||
</script>
|
||||
|
||||
<Block
|
||||
variant={mode === "interactive" ? "dashed" : "solid"}
|
||||
test_id="highlighted-text"
|
||||
{visible}
|
||||
{elem_id}
|
||||
{elem_classes}
|
||||
padding={false}
|
||||
{container}
|
||||
{scale}
|
||||
{min_width}
|
||||
>
|
||||
<StatusTracker {...loading_status} />
|
||||
{#if label}
|
||||
<BlockLabel
|
||||
Icon={TextHighlight}
|
||||
{label}
|
||||
float={false}
|
||||
disable={container === false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if value}
|
||||
<HighlightedText
|
||||
bind:value
|
||||
on:change={() => gradio.dispatch("change")}
|
||||
{selectable}
|
||||
{show_legend}
|
||||
{color_map}
|
||||
/>
|
||||
{:else}
|
||||
<Empty>
|
||||
<TextHighlight />
|
||||
</Empty>
|
||||
{/if}
|
||||
</Block>
|
120
js/highlightedtext/interactive/LabelInput.svelte
Normal file
120
js/highlightedtext/interactive/LabelInput.svelte
Normal file
@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
type HighlightedTextType = [string, string | number | null, symbol?];
|
||||
|
||||
export let value: HighlightedTextType[];
|
||||
export let category: string | number | null;
|
||||
export let active: string;
|
||||
export let labelToEdit: number;
|
||||
export let indexOfLabel: number;
|
||||
export let text: string;
|
||||
export let handleValueChange: () => void;
|
||||
export let isScoresMode = false;
|
||||
export let _color_map: Record<string, { primary: string; secondary: string }>;
|
||||
|
||||
let _input_value = category;
|
||||
|
||||
function handleInput(e: Event): void {
|
||||
let target = e.target as HTMLInputElement;
|
||||
if (target) {
|
||||
_input_value = target.value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateLabelValue(
|
||||
e: Event,
|
||||
elementIndex: number,
|
||||
text: string
|
||||
): void {
|
||||
let target = e.target as HTMLInputElement;
|
||||
value = [
|
||||
...value.slice(0, elementIndex),
|
||||
[
|
||||
text,
|
||||
target.value === ""
|
||||
? null
|
||||
: isScoresMode
|
||||
? Number(target.value)
|
||||
: target.value,
|
||||
],
|
||||
...value.slice(elementIndex + 1),
|
||||
];
|
||||
|
||||
handleValueChange();
|
||||
}
|
||||
|
||||
function clearPlaceHolderOnFocus(e: FocusEvent): void {
|
||||
let target = e.target as HTMLInputElement;
|
||||
if (target && target.placeholder) target.placeholder = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<!-- autofocus should not be disorienting for a screen reader users
|
||||
as input is only rendered once a new label is created -->
|
||||
{#if !isScoresMode}
|
||||
<input
|
||||
class="label-input"
|
||||
autofocus
|
||||
id={`label-input-${indexOfLabel}`}
|
||||
type="text"
|
||||
placeholder="label"
|
||||
value={category}
|
||||
style:background-color={category === null || (active && active !== category)
|
||||
? ""
|
||||
: _color_map[category].primary}
|
||||
style:width={_input_value
|
||||
? _input_value.toString()?.length + 4 + "ch"
|
||||
: "8ch"}
|
||||
on:input={handleInput}
|
||||
on:blur={(e) => updateLabelValue(e, indexOfLabel, text)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
updateLabelValue(e, indexOfLabel, text);
|
||||
labelToEdit = -1;
|
||||
}
|
||||
}}
|
||||
on:focus={clearPlaceHolderOnFocus}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="label-input"
|
||||
autofocus
|
||||
type="number"
|
||||
step="0.1"
|
||||
style={"background-color: rgba(" +
|
||||
(typeof category === "number" && category < 0
|
||||
? "128, 90, 213," + -category
|
||||
: "239, 68, 60," + category) +
|
||||
")"}
|
||||
value={category}
|
||||
style:width="7ch"
|
||||
on:input={handleInput}
|
||||
on:blur={(e) => updateLabelValue(e, indexOfLabel, text)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
updateLabelValue(e, indexOfLabel, text);
|
||||
labelToEdit = -1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.label-input {
|
||||
transition: 150ms;
|
||||
margin-top: 1px;
|
||||
margin-right: calc(var(--size-1));
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 1px 5px;
|
||||
color: black;
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--text-sm);
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.label-input::placeholder {
|
||||
color: rgba(1, 1, 1, 0.5);
|
||||
}
|
||||
</style>
|
1
js/highlightedtext/interactive/index.ts
Normal file
1
js/highlightedtext/interactive/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./InteractiveHighlightedText.svelte";
|
@ -1,17 +1,16 @@
|
||||
<script lang="ts">
|
||||
const browser = typeof document !== "undefined";
|
||||
import { colors } from "@gradio/theme";
|
||||
import { get_next_color } from "@gradio/utils";
|
||||
import type { SelectData } from "@gradio/utils";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { correct_color_map } from "../utils";
|
||||
|
||||
export let value: [string, string | number][] = [];
|
||||
export let value: [string, string | number | null][] = [];
|
||||
export let show_legend = false;
|
||||
export let color_map: Record<string, string> = {};
|
||||
export let selectable = false;
|
||||
|
||||
let ctx: CanvasRenderingContext2D;
|
||||
|
||||
let _color_map: Record<string, { primary: string; secondary: string }> = {};
|
||||
let active = "";
|
||||
|
||||
@ -19,34 +18,6 @@
|
||||
return text.split("\n");
|
||||
}
|
||||
|
||||
function correct_color_map(): void {
|
||||
for (const col in color_map) {
|
||||
const _c = color_map[col].trim();
|
||||
if (_c in colors) {
|
||||
_color_map[col] = colors[_c as keyof typeof colors];
|
||||
} else {
|
||||
_color_map[col] = {
|
||||
primary: browser ? name_to_rgba(color_map[col], 1) : color_map[col],
|
||||
secondary: browser
|
||||
? name_to_rgba(color_map[col], 0.5)
|
||||
: color_map[col]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function name_to_rgba(name: string, a: number): string {
|
||||
if (!ctx) {
|
||||
var canvas = document.createElement("canvas");
|
||||
ctx = canvas.getContext("2d")!;
|
||||
}
|
||||
ctx.fillStyle = name;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
return `rgba(${r}, ${g}, ${b}, ${255 / a})`;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: SelectData;
|
||||
}>();
|
||||
@ -73,7 +44,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
correct_color_map();
|
||||
correct_color_map(color_map, _color_map, browser, ctx);
|
||||
}
|
||||
|
||||
function handle_mouseover(label: string): void {
|
||||
@ -139,12 +110,13 @@
|
||||
on:click={() => {
|
||||
dispatch("select", {
|
||||
index: i,
|
||||
value: [text, category]
|
||||
value: [text, category],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span class:no-label={!_color_map[category]} class="text"
|
||||
>{line}</span
|
||||
<span
|
||||
class:no-label={category && !_color_map[category]}
|
||||
class="text">{line}</span
|
||||
>
|
||||
{#if !show_legend && category !== null}
|
||||
|
||||
@ -180,7 +152,9 @@
|
||||
<span
|
||||
class="textspan score-text"
|
||||
style={"background-color: rgba(" +
|
||||
(score < 0 ? "128, 90, 213," + -score : "239, 68, 60," + score) +
|
||||
(score && score < 0
|
||||
? "128, 90, 213," + -score
|
||||
: "239, 68, 60," + score) +
|
||||
")"}
|
||||
>
|
||||
<span class="text">{text}</span>
|
||||
|
@ -6,12 +6,14 @@
|
||||
import { StatusTracker } from "@gradio/statustracker";
|
||||
import type { LoadingStatus } from "@gradio/statustracker";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { merge_elements } from "../utils";
|
||||
|
||||
export let elem_id = "";
|
||||
export let elem_classes: string[] = [];
|
||||
export let visible = true;
|
||||
export let value: [string, string | number][];
|
||||
let old_value: [string, string | number][];
|
||||
export let value: [string, string | number | null][];
|
||||
let old_value: [string, string | number | null][];
|
||||
export let mode: "static" | "interactive";
|
||||
export let show_legend: boolean;
|
||||
export let color_map: Record<string, string> = {};
|
||||
export let label = $_("highlighted_text.highlighted_text");
|
||||
@ -19,6 +21,7 @@
|
||||
export let scale: number | null = null;
|
||||
export let min_width: number | undefined = undefined;
|
||||
export let selectable = false;
|
||||
export let combine_adjacent = false;
|
||||
export let gradio: Gradio<{
|
||||
select: SelectData;
|
||||
change: never;
|
||||
@ -36,9 +39,14 @@
|
||||
gradio.dispatch("change");
|
||||
}
|
||||
}
|
||||
|
||||
$: if (combine_adjacent) {
|
||||
value = merge_elements(value, "equal");
|
||||
}
|
||||
</script>
|
||||
|
||||
<Block
|
||||
variant={mode === "interactive" ? "dashed" : "solid"}
|
||||
test_id="highlighted-text"
|
||||
{visible}
|
||||
{elem_id}
|
||||
|
73
js/highlightedtext/utils.ts
Normal file
73
js/highlightedtext/utils.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { colors } from "@gradio/theme";
|
||||
|
||||
type HighlightValueType = [string, string | number | null];
|
||||
|
||||
export function name_to_rgba(
|
||||
name: string,
|
||||
a: number,
|
||||
ctx: CanvasRenderingContext2D | null
|
||||
): string {
|
||||
if (!ctx) {
|
||||
var canvas = document.createElement("canvas");
|
||||
ctx = canvas.getContext("2d")!;
|
||||
}
|
||||
ctx.fillStyle = name;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
return `rgba(${r}, ${g}, ${b}, ${255 / a})`;
|
||||
}
|
||||
|
||||
export function correct_color_map(
|
||||
color_map: Record<string, string>,
|
||||
_color_map: Record<string, { primary: string; secondary: string }>,
|
||||
browser: any,
|
||||
ctx: CanvasRenderingContext2D | null
|
||||
): void {
|
||||
for (const col in color_map) {
|
||||
const _c = color_map[col].trim();
|
||||
|
||||
if (_c in colors) {
|
||||
_color_map[col] = colors[_c as keyof typeof colors];
|
||||
} else {
|
||||
_color_map[col] = {
|
||||
primary: browser
|
||||
? name_to_rgba(color_map[col], 1, ctx)
|
||||
: color_map[col],
|
||||
secondary: browser
|
||||
? name_to_rgba(color_map[col], 0.5, ctx)
|
||||
: color_map[col]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function merge_elements(
|
||||
value: HighlightValueType[],
|
||||
mergeMode: "empty" | "equal"
|
||||
): HighlightValueType[] {
|
||||
let result: HighlightValueType[] = [];
|
||||
let tempStr: string | null = null;
|
||||
let tempVal: string | number | null = null;
|
||||
|
||||
for (const [str, val] of value) {
|
||||
if (
|
||||
(mergeMode === "empty" && val === null) ||
|
||||
(mergeMode === "equal" && tempVal === val)
|
||||
) {
|
||||
tempStr = tempStr ? tempStr + str : str;
|
||||
} else {
|
||||
if (tempStr !== null) {
|
||||
result.push([tempStr, tempVal as string | number]);
|
||||
}
|
||||
tempStr = str;
|
||||
tempVal = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (tempStr !== null) {
|
||||
result.push([tempStr, tempVal as string | number]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
@ -1838,6 +1838,7 @@ class TestHighlightedText:
|
||||
"interactive": None,
|
||||
"root_url": None,
|
||||
"selectable": False,
|
||||
"combine_adjacent": False,
|
||||
}
|
||||
|
||||
def test_in_interface(self):
|
||||
|
Loading…
Reference in New Issue
Block a user