Spaces:
Runtime error
Runtime error
<script lang="ts"> | |
import { | |
beforeUpdate, | |
afterUpdate, | |
createEventDispatcher, | |
tick, | |
} from "svelte"; | |
import { BlockTitle } from "@gradio/atoms"; | |
import { Copy, Check } from "@gradio/icons"; | |
import { fade } from "svelte/transition"; | |
import type { SelectData } from "@gradio/utils"; | |
import { get_next_color } from "@gradio/utils"; | |
import { correct_color_map, getParentCursorPosition, getNodeAndOffset } from "./utils"; | |
const browser = typeof document !== "undefined"; | |
export let value: [string, string | null][] = []; | |
export let value_is_output: boolean = false; | |
export let label: string; | |
export let legend_label: string; | |
export let info: string | undefined = undefined; | |
export let show_label = true; | |
export let show_legend = false; | |
export let show_legend_label = false; | |
export let container = true; | |
export let color_map: Record<string, string> = {}; | |
export let show_copy_button = false; | |
export let disabled: boolean; | |
let el: HTMLDivElement; | |
let el_text: string = ""; | |
let marked_el_text: string = ""; | |
let ctx: CanvasRenderingContext2D; | |
let current_color_map: Record<string, string> = {}; | |
let _color_map: Record<string, { primary: string; secondary: string }> = {}; | |
let copied = false; | |
let timer: ReturnType<typeof setTimeout>; | |
let can_scroll: boolean; | |
function set_color_map(): void { | |
if (!color_map || Object.keys(color_map).length === 0) { | |
current_color_map = {}; | |
} | |
else { | |
current_color_map = color_map; | |
} | |
if (value.length > 0) { | |
for (let [_, label] of value) { | |
if (label !== null && !(label in current_color_map)) { | |
let color = get_next_color(Object.keys(current_color_map).length); | |
current_color_map[label] = color; | |
} | |
} | |
} | |
_color_map = correct_color_map(current_color_map, browser, ctx); | |
} | |
function set_text_from_value(): void { | |
if (value.length > 0 && value_is_output) { | |
el_text = value.map(([text, _]) => text).join(" "); | |
marked_el_text = value.map(([text, category]) => { | |
if (category !== null) { | |
return `<mark class="hl ${category}" style="background-color:${_color_map[category].secondary}">${text}`; | |
} else { | |
return text; | |
} | |
}).join(" ") + " "; | |
} | |
} | |
$: set_text_from_value(); | |
$: set_color_map(); | |
const dispatch = createEventDispatcher<{ | |
change: string; | |
submit: undefined; | |
blur: undefined; | |
select: SelectData; | |
input: undefined; | |
focus: undefined; | |
}>(); | |
beforeUpdate(() => { | |
can_scroll = el && el.offsetHeight + el.scrollTop > el.scrollHeight - 100; | |
}); | |
function handle_change(): void { | |
dispatch("change", marked_el_text); | |
if (!value_is_output) { | |
dispatch("input"); | |
} | |
} | |
afterUpdate(() => { | |
set_color_map(); | |
set_text_from_value(); | |
value_is_output = false; | |
}); | |
$: marked_el_text, handle_change(); | |
// Given a string like Hello This is cool. | |
// for marked_el_text and its previous parsed version (value) like [["Hello ", null], ["world!", "red"], [" This is ", null], ["nice", "blue"]], | |
// update value such that it matches marked_el_text (i.e. [["Hello ", null], ["world!", "red"], [" This is cool.", null]]) | |
function handle_blur(): void { | |
let new_value: [string, string | null][] = []; | |
let text = ""; | |
let category = null; | |
let in_tag = false; | |
let tag = ""; | |
for (let i = 0; i < marked_el_text.length; i++) { | |
let char = marked_el_text[i]; | |
if (char === "<") { | |
in_tag = true; | |
if (text) { | |
new_value.push([text, category]); | |
} | |
text = ""; | |
category = null; | |
} else if (char === ">") { | |
in_tag = false; | |
if (tag.startsWith("mark")) { | |
category = tag.match(/class="hl ([^"]+)"/)?.[1] || null; | |
} | |
tag = ""; | |
} else if (in_tag) { | |
tag += char; | |
} else { | |
text += char; | |
} | |
} | |
if (text) { | |
new_value.push([text, category]); | |
} | |
value = new_value; | |
} | |
async function handle_copy(): Promise<void> { | |
if ("clipboard" in navigator) { | |
await navigator.clipboard.writeText(el_text); | |
copy_feedback(); | |
} | |
} | |
function copy_feedback(): void { | |
copied = true; | |
if (timer) clearTimeout(timer); | |
timer = setTimeout(() => { | |
copied = false; | |
}, 1000); | |
} | |
// Method to remove highlight if cursor is inside | |
function checkAndRemoveHighlight() { | |
const selection = window.getSelection(); | |
const cursorPosition = selection.anchorOffset; | |
if (selection.rangeCount > 0) { | |
var currParent = selection.getRangeAt(0).commonAncestorContainer.parentElement; | |
if (currParent && currParent.tagName.toLowerCase() === 'mark') { | |
const text = currParent.textContent; | |
// replace the mark tag with its text content | |
var textContainer = currParent.parentElement; | |
var newTextNode = document.createTextNode(text); | |
textContainer.replaceChild(newTextNode, currParent); | |
marked_el_text = textContainer.innerHTML; | |
// set the cursor position to the same position as before | |
var range = document.createRange() | |
var newSelection = window.getSelection() | |
const newCursorPosition = cursorPosition + getParentCursorPosition(textContainer) | |
var nodeAndOffset = getNodeAndOffset(textContainer, newCursorPosition); | |
range.setStart(nodeAndOffset.node, nodeAndOffset.offset); | |
range.setEnd(nodeAndOffset.node, nodeAndOffset.offset); | |
newSelection.removeAllRanges(); | |
newSelection.addRange(range); | |
handle_blur(); | |
} | |
} | |
} | |
</script> | |
<!-- svelte-ignore a11y-no-static-element-interactions --> | |
<!-- svelte-ignore a11y-click-events-have-key-events--> | |
<label class:container> | |
{#if show_legend} | |
<div | |
class="category-legend" | |
data-testid="highlighted-text:category-legend" | |
> | |
{#if show_legend_label} | |
<div class="legend-description">{legend_label}</div> | |
{/if} | |
{#each Object.entries(_color_map) as [category, color], i} | |
<!-- svelte-ignore a11y-no-static-element-interactions --> | |
<div class="category-label" style={"background-color:" + color.secondary}> | |
{category} | |
</div> | |
{/each} | |
</div> | |
{/if} | |
<BlockTitle {show_label} {info}>{label}</BlockTitle> | |
{#if show_copy_button} | |
{#if copied} | |
<button | |
in:fade={{ duration: 300 }} | |
aria-label="Copied" | |
aria-roledescription="Text copied"><Check /></button | |
> | |
{:else} | |
<button | |
on:click={handle_copy} | |
aria-label="Copy" | |
aria-roledescription="Copy text"><Copy /></button | |
> | |
{/if} | |
{/if} | |
{#if disabled} | |
<div | |
class="textfield" | |
data-testid="highlighted-textbox" | |
contenteditable="false" | |
bind:this={el} | |
bind:textContent={el_text} | |
bind:innerHTML={marked_el_text} | |
/> | |
{:else} | |
<div | |
class="textfield" | |
data-testid="highlighted-textbox" | |
contenteditable="true" | |
bind:this={el} | |
bind:textContent={el_text} | |
bind:innerHTML={marked_el_text} | |
on:blur={handle_blur} | |
on:keypress | |
on:select | |
on:scroll | |
on:input | |
on:focus | |
on:change | |
on:mousedown={checkAndRemoveHighlight} | |
on:keydown={checkAndRemoveHighlight} | |
on:mouseup={checkAndRemoveHighlight} | |
on:keyup={checkAndRemoveHighlight} | |
/> | |
{/if} | |
</label> | |
<style> | |
label { | |
display: block; | |
width: 100%; | |
} | |
button { | |
display: flex; | |
position: absolute; | |
top: var(--block-label-margin); | |
right: var(--block-label-margin); | |
align-items: center; | |
box-shadow: var(--shadow-drop); | |
border: 1px solid var(--color-border-primary); | |
border-top: none; | |
border-right: none; | |
border-radius: var(--block-label-right-radius); | |
background: var(--block-label-background-fill); | |
padding: 5px; | |
width: 22px; | |
height: 22px; | |
overflow: hidden; | |
color: var(--block-label-color); | |
font: var(--font-sans); | |
font-size: var(--button-small-text-size); | |
} | |
.container { | |
display: flex; | |
flex-direction: column; | |
gap: var(--spacing-sm); | |
padding: var(--block-padding); | |
} | |
.category-legend { | |
display: flex; | |
flex-wrap: wrap; | |
gap: var(--spacing-sm); | |
color: black; | |
margin-bottom: var(--spacing-sm) | |
} | |
.category-label { | |
border-radius: var(--radius-xs); | |
padding-right: var(--size-2); | |
padding-left: var(--size-2); | |
font-weight: var(--weight-semibold); | |
} | |
.legend-description { | |
background-color: transparent; | |
color: var(--block-title-text-color); | |
padding-left: 0px; | |
border-radius: var(--radius-xs); | |
font-weight: var(--input-text-weight); | |
} | |
.textfield { | |
box-sizing: border-box; | |
outline: none !important; | |
box-shadow: var(--input-shadow); | |
padding: var(--input-padding); | |
border-radius: var(--radius-md); | |
background: var(--input-background-fill); | |
background-color: transparent; | |
font-weight: var(--input-text-weight); | |
font-size: var(--input-text-size); | |
width: 100%; | |
line-height: var(--line-sm); | |
word-break: break-word; | |
border: var(--input-border-width) solid var(--input-border-color); | |
cursor: text; | |
} | |
.textfield:focus { | |
box-shadow: var(--input-shadow-focus); | |
border-color: var(--input-border-color-focus); | |
} | |
:global(mark) { | |
border-radius: 3px; | |
} | |
</style> |