gradio_highlightedtextbox / src /frontend /HighlightedTextbox.svelte
gsarti's picture
Update src/frontend/HighlightedTextbox.svelte
063d3f1 verified
raw
history blame
9.18 kB
<script lang="ts">
import {
beforeUpdate,
afterUpdate,
createEventDispatcher,
} from "svelte";
import BlockTitleWithHighlights from "./BlockTitleWithHighlights.svelte";
import Widgets from "./Widgets.svelte";
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 show_remove_tags_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 tags_removed = false;
let timer: ReturnType<typeof setTimeout>;
let can_scroll: boolean;
let tagged_text: string = "";
function set_color_map(): void {
current_color_map = !color_map || Object.keys(color_map).length === 0 ? {} : color_map;
// if a label in the color map is not in the value, remove it from the color map
for (let label in current_color_map) {
if (!value.map(([_, label]) => label).includes(label)) {
delete current_color_map[label];
}
}
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 escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function set_text_from_value(as_output: boolean): void {
if (value.length > 0 && as_output) {
el_text = value.map(([text, _]) => text).join("");
marked_el_text = value.map(([text, category]) => {
// Escape HTML entities in the text
const escapedText = escapeHtml(text);
if (category !== null) {
return `<mark class="hl ${category}" style="background-color:${_color_map[category].secondary}">${escapedText}</mark>`;
} else {
return escapedText;
}
}).join("");
tagged_text = value.map(([text, category]) => {
if (category !== null) {
return `<${category}>${text}</${category}>`;
} else {
return text;
}
}).join("");
}
}
$: set_color_map();
$: set_text_from_value(true);
const dispatch = createEventDispatcher<{
change: [string, string | null][];
input: [string, string | null][];
submit: undefined;
blur: undefined;
select: SelectData;
focus: undefined;
clear: undefined;
}>();
beforeUpdate(() => {
can_scroll = el && el.offsetHeight + el.scrollTop > el.scrollHeight - 100;
});
function handle_change(): void {
checkAndRemoveHighlight();
set_value_from_marked_span();
dispatch("change", value);
if (!value_is_output) {
dispatch("input", value);
}
}
afterUpdate(() => {
set_color_map();
set_text_from_value(value_is_output);
value_is_output = false;
});
function set_value_from_marked_span(): void {
let new_value: [string, string | null][] = [];
let text = "";
let category = null;
// Create a temporary container to parse the HTML properly
const tempDiv = document.createElement('div');
tempDiv.innerHTML = marked_el_text;
// Function to recursively process nodes
function processNode(node: Node) {
if (node.nodeType === Node.TEXT_NODE) {
// Text node - add to current text
text += node.textContent || '';
} else if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
if (element.tagName.toLowerCase() === 'mark') {
// Save any accumulated text before the mark
if (text) {
new_value.push([text, category]);
text = "";
}
// Extract category from mark class
const classList = element.className.split(' ');
const hlIndex = classList.indexOf('hl');
category = hlIndex >= 0 && hlIndex + 1 < classList.length ? classList[hlIndex + 1] : null;
// Process mark content
for (let child of element.childNodes) {
processNode(child);
}
// Save marked text
if (text) {
new_value.push([text, category]);
text = "";
category = null;
}
} else {
// Other elements - process children
for (let child of element.childNodes) {
processNode(child);
}
}
}
}
// Process all nodes
for (let node of tempDiv.childNodes) {
processNode(node);
}
// Add any remaining text
if (text) {
new_value.push([text, category]);
}
value = new_value;
}
function handle_remove_tags(): void {
marked_el_text = el_text;
handle_change();
tags_removed = true;
dispatch("clear");
}
// 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);
}
}
}
</script>
<label class:container for="highlighted-textbox">
<BlockTitleWithHighlights {show_label} {show_legend} {show_legend_label} {legend_label} {_color_map} {info}>{label}</BlockTitleWithHighlights>
<Widgets
{show_copy_button}
show_remove_tags_button={show_remove_tags_button && !tags_removed}
value={tagged_text}
on:clear={handle_remove_tags}
/>
{#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"
role="textbox"
tabindex="0"
bind:this={el}
bind:textContent={el_text}
bind:innerHTML={marked_el_text}
on:blur
on:keypress
on:select
on:scroll
on:input={handle_change}
on:focus
on:change={handle_change}
/>
{/if}
</label>
<style>
label {
display: block;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.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;
white-space: break-spaces;
}
.textfield:focus {
box-shadow: var(--input-shadow-focus);
border-color: var(--input-border-color-focus);
}
:global(mark) {
border-radius: 3px;
}
</style>