|
<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}</mark>`; |
|
} 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 <mark class="hl red" style="background-color:#fee2e2">world!</mark> 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> |