Spaces:
Running
Running
| <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: { | |
| token: string; | |
| class_or_confidence: 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 | null = null; | |
| 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].token.toString().includes(selection.toString()) | |
| ) { | |
| const tempFlag = Symbol(); | |
| const str = value[activeElementIndex].token; | |
| const [before, selected, after] = [ | |
| str.substring(0, startIndex), | |
| str.substring(startIndex, endIndex), | |
| str.substring(endIndex) | |
| ]; | |
| let tempValue: { | |
| token: string; | |
| class_or_confidence: string | number | null; | |
| flag?: symbol; | |
| }[] = [ | |
| ...value.slice(0, activeElementIndex), | |
| { token: before, class_or_confidence: null }, | |
| { | |
| token: selected, | |
| class_or_confidence: mode === "scores" ? 1 : "label", | |
| flag: tempFlag | |
| }, // add a temp flag to the new highlighted text element | |
| { token: after, class_or_confidence: 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.token.trim() !== ""); | |
| value = tempValue.map(({ flag, ...rest }) => rest); | |
| 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 (!value || index < 0 || index >= value.length) return; | |
| value[index].class_or_confidence = 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 entry of value) { | |
| if (entry.class_or_confidence !== null) { | |
| if (typeof entry.class_or_confidence === "string") { | |
| mode = "categories"; | |
| if (!(entry.class_or_confidence in color_map)) { | |
| let color = get_next_color(Object.keys(color_map).length); | |
| color_map[entry.class_or_confidence] = 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, | |
| class_or_confidence: string | number | null | |
| ): void { | |
| dispatch("select", { | |
| index: i, | |
| value: [text, class_or_confidence] | |
| }); | |
| } | |
| </script> | |
| <div class="container"> | |
| {#if mode === "categories"} | |
| {#if show_legend} | |
| <div | |
| class="class_or_confidence-legend" | |
| data-testid="highlighted-text:class_or_confidence-legend" | |
| > | |
| {#if _color_map} | |
| {#each Object.entries(_color_map) as [class_or_confidence, color], i} | |
| <div | |
| role="button" | |
| aria-roledescription="Categories of highlighted text. Hover to see text with this class_or_confidence highlighted." | |
| tabindex="0" | |
| on:mouseover={() => handle_mouseover(class_or_confidence)} | |
| on:focus={() => handle_mouseover(class_or_confidence)} | |
| on:mouseout={() => handle_mouseout()} | |
| on:blur={() => handle_mouseout()} | |
| class="class_or_confidence-label" | |
| style={"background-color:" + color.secondary} | |
| > | |
| {class_or_confidence} | |
| </div> | |
| {/each} | |
| {/if} | |
| </div> | |
| {/if} | |
| <div class="textfield"> | |
| {#each value as { token, class_or_confidence }, i} | |
| {#each splitTextByNewline(token) as line, j} | |
| {#if line.trim() !== ""} | |
| <span class="text-class_or_confidence-container"> | |
| <span | |
| role="button" | |
| tabindex="0" | |
| class="textspan" | |
| style:background-color={class_or_confidence === null || | |
| (active && active !== class_or_confidence) | |
| ? "" | |
| : class_or_confidence && _color_map[class_or_confidence] | |
| ? _color_map[class_or_confidence].secondary | |
| : ""} | |
| class:no-cat={class_or_confidence === null || | |
| (active && active !== class_or_confidence)} | |
| class:hl={class_or_confidence !== null} | |
| class:selectable | |
| on:click={() => { | |
| if (class_or_confidence !== null) { | |
| handleSelect(i, token, class_or_confidence); | |
| } | |
| }} | |
| on:keydown={(e) => { | |
| if (class_or_confidence !== null) { | |
| labelToEdit = i; | |
| handleSelect(i, token, class_or_confidence); | |
| } else { | |
| handleKeydownSelection(e); | |
| } | |
| }} | |
| on:focus={() => (activeElementIndex = i)} | |
| on:mouseover={() => (activeElementIndex = i)} | |
| > | |
| <span | |
| class:no-label={class_or_confidence === 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 && class_or_confidence !== null && labelToEdit !== i} | |
| <span | |
| id={`label-tag-${i}`} | |
| class="label" | |
| role="button" | |
| tabindex="0" | |
| style:background-color={class_or_confidence === null || | |
| (active && active !== class_or_confidence) | |
| ? "" | |
| : _color_map[class_or_confidence].primary} | |
| on:click={() => (labelToEdit = i)} | |
| on:keydown={() => (labelToEdit = i)} | |
| > | |
| {class_or_confidence} | |
| </span> | |
| {/if} | |
| {#if labelToEdit === i && class_or_confidence !== null} | |
| | |
| <LabelInput | |
| bind:value | |
| {labelToEdit} | |
| category={class_or_confidence} | |
| {active} | |
| {_color_map} | |
| indexOfLabel={i} | |
| text={token} | |
| {handleValueChange} | |
| /> | |
| {/if} | |
| </span> | |
| {#if class_or_confidence !== 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(token).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 { token, class_or_confidence }, i} | |
| {@const score = | |
| typeof class_or_confidence === "string" | |
| ? parseInt(class_or_confidence) | |
| : class_or_confidence} | |
| <span class="score-text-container"> | |
| <span | |
| class="textspan score-text" | |
| role="button" | |
| tabindex="0" | |
| class:no-cat={class_or_confidence === null || | |
| (active && active !== class_or_confidence)} | |
| class:hl={class_or_confidence !== 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">{token}</span> | |
| {#if class_or_confidence && labelToEdit === i} | |
| <LabelInput | |
| bind:value | |
| {labelToEdit} | |
| {_color_map} | |
| category={class_or_confidence} | |
| {active} | |
| indexOfLabel={i} | |
| text={token} | |
| {handleValueChange} | |
| isScoresMode | |
| /> | |
| {/if} | |
| </span> | |
| {#if class_or_confidence && 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-class_or_confidence-container:hover .label-clear-button, | |
| .text-class_or_confidence-container:focus-within .label-clear-button, | |
| .score-text-container:hover .label-clear-button, | |
| .score-text-container:focus-within .label-clear-button { | |
| display: inline; | |
| } | |
| .text-class_or_confidence-container:hover .textspan.hl, | |
| .text-class_or_confidence-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; | |
| } | |
| .class_or_confidence-legend { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: var(--spacing-sm); | |
| color: black; | |
| } | |
| .class_or_confidence-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> | |