Repository: gradio-app/gradio
Pull request: Allow interactive input in `gr.HighlightedText`
## Description

This PR allows gr.HighlightedText to be used as an input. 

- Supports categories mode and scores mode
- Double click on a word or highlight any selection of words within the value passed in to the component
    - A label will appear, with the text `label` by default (if in `categories` mode; will be 1 in `scores` mode). 
    - Clicking away from this label or pressing enter will set it to the value of the input, and clicking back onto the label will allow you to edit it again
    - You can't highlight text which already has a highlight; we don't support overlapping entities
    - If you select text across multiple labels, (e.g non-highlighted text before a highlight and text after a highlight) this intentionally won't do anything (to prevent any overlapping entity weirdness)
- Hovering on a label will display the remove button, and the selected highlight's corresponding label will be removed, and the value of that label will be `null`. If there are adjacent elements also with a label value of`null`, they will be merged
- Everything is accessible via the keyboard :tada:
    - You can try navigating solely via the keyboard with caret browsing mode in your browser (F7 in Chrome). You can tab to the text you want to highlight, press enter to highlight it, and tab to the remove label button to clear it. 
- The logic to merge adjacent values has been moved from `highlight_text.py` into the frontend, to a function called `merge_elements`. It takes a param `mergeMode` of type `"empty" | "equal"`. `mergeElements` is run with `"equal"` every time the value changes if `combine_adjacent` is `True`. 

Categories 
![Kapture 2023-09-06 at 17 12 07](https://github.com/gradio-app/gradio/assets/15165908/1a68fc63-ddf7-4c48-a550-b499b6b55bd5)

Scores
![Kapture 2023-09-06 at 17 14 02](https://github.com/gradio-app/gradio/assets/15165908/f725549e-705e-47a2-a548-dde18801764a)

Legend
![Kapture 2023-09-06 at 17 15 09](https://github.com/gradio-app/gradio/assets/15165908/8bafc802-55e5-481d-8b35-0b6b3ddcff7f)

Keyboard navigation 
![Kapture 2023-09-06 at 17 16 23](https://github.com/gradio-app/gradio/assets/15165908/c43425e5-91f2-42ea-beaf-13f4d64cc271)

**Notes**

- Is clicking to edit the label and pressing enter to finish editing the label clear enough for users and/or keyboard users? There isn't any explicit instruction to do this, but to me it feels like natural UX. 
- Theres a bug right now which means we're not detecting whether the component is an input or output, so you have to use `Interactive=True`. I could use some help figuring out why this is 🐛 
- ~Issue with substring selection - [see comment](https://github.com/gradio-app/gradio/pull/5400#issuecomment-1708307656). Thoughts on this plz~

Demo: 

```
import gradio as gr 

def output_text(text):
    return text

with gr.Blocks() as demo:
    hl1 = gr.HighlightedText(value= [("hello", "greeting"), ('goodbye', "farewell"),("howdy", "greeting")], show_legend=True, interactive=True)
    hl2 = gr.HighlightedText(interactive=True)
    btn = gr.Button(value="submit")
    btn.click(output_text, hl1, hl2)

    gr.HighlightedText(value=[('dog', 1), ('cat', 2), ('mouse', 3)], show_legend=True, interactive=True)
    gr.HighlightedText(value=[('michael', "1"), ('jim', "2"), ('dwight', "3")], interactive=True)

if __name__ == "__main__":
    demo.launch()
```

to do:

- [x] general clean up
- [x] make this accessible via the keyboard + a11y compliant
- [x] clean up adjacent list items with blank category names which are creating spacing between elements
- [x] tweak scores mode to be editable as well
- [x] remove label button
- [x] make this work for score mode

Closes: #2444

## 🎯 PRs Should Target Issues

Before your create a PR, please check to see if there is [an existing issue](https://github.com/gradio-app/gradio/issues) for this change. If not, please create an issue before you create this PR, unless the fix is very small. 

Not adhering to this guideline will result in the PR being closed. 

## Tests

1. PRs will only be merged if tests pass on CI. To run the tests locally, please set up [your Gradio environment locally](https://github.com/gradio-app/gradio/blob/main/CONTRIBUTING.md) and run the tests: `bash scripts/run_all_tests.sh`

2. You may need to run the linters: `bash scripts/format_backend.sh` and `bash scripts/format_frontend.sh`
  


Problem statement:
Input in HighlightedText
- [*] I have searched to see if a similar issue already exists.

**Is your feature request related to a problem? Please describe.**  
As it stands highlighted text can only be used for output (i.e. in case of demonstrating a NER model). There could be a lot of use to adding interactivity to it. i.e. allowing users to highlight text and add labels/comments to it (similar to inline comments in a google doc). 

**Describe the solution you'd like**  
The ability to make HighlightedText interactive, such that a user can highlight text and click a button to write a label/comment for their highlighted text.

**Additional context**  
I'm working on CHEESE, a labeling/evaluation/HITL platform that runs primarily off gradio. Having this kind of feature would allow us to add NER labelling tasks or generally any task that could make use of inline labels/comments.

Hints from issue discussion:
Hi @shahbuland thanks for creating the issue! We've talked a little bit about this internally but haven't had a chance to implement. I do think this would be a good addition to the library to work on in the next few weeks. @dawoodkhan82 thoughts on this? 
Any update on this? I'd also be interested in allowing users to highlight text

Commit message: backend test tweaks

Patch to review:
diff --git a/.changeset/ripe-ideas-rest.md b/.changeset/ripe-ideas-rest.md
new file mode 100644
index 0000000000..f45cd4927d
--- /dev/null
+++ b/.changeset/ripe-ideas-rest.md
@@ -0,0 +1,7 @@
+---
+"@gradio/app": minor
+"@gradio/highlightedtext": minor
+"gradio": minor
+---
+
+feat:Allow interactive input in `gr.HighlightedText`
diff --git a/gradio/components/highlighted_text.py b/gradio/components/highlighted_text.py
index 690a1f0e8e..d3c16f615b 100644
--- a/gradio/components/highlighted_text.py
+++ b/gradio/components/highlighted_text.py
@@ -11,11 +11,7 @@
 
 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, Inputable, Selectable
 
 set_documentation_group("component")
 
@@ -24,7 +20,7 @@
 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 @@ def __init__(
         visible: bool = True,
         elem_id: str | None = None,
         elem_classes: list[str] | str | None = None,
+        interactive: bool = False,
         **kwargs,
     ):
         """
@@ -66,6 +63,8 @@ def __init__(
             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 @@ def __init__(
             elem_id=elem_id,
             elem_classes=elem_classes,
             value=value,
+            interactive=interactive,
             **kwargs,
         )
 
@@ -98,6 +98,7 @@ def get_config(self):
             "show_legend": self.show_legend,
             "value": self.value,
             "selectable": self.selectable,
+            "combine_adjacent": self.combine_adjacent,
             **IOComponent.get_config(self),
         }
 
@@ -115,6 +116,7 @@ def update(
         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 @@ def update(
             "min_width": min_width,
             "visible": visible,
             "value": value,
+            "interactive": interactive,
             "__type__": "update",
         }
         return updated_config
diff --git a/js/app/src/components/directory.ts b/js/app/src/components/directory.ts
index 37b294616a..7edb4c6682 100644
--- a/js/app/src/components/directory.ts
+++ b/js/app/src/components/directory.ts
@@ -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")
diff --git a/js/highlightedtext/interactive/Highlightedtext.svelte b/js/highlightedtext/interactive/Highlightedtext.svelte
new file mode 100644
index 0000000000..aacb528e12
--- /dev/null
+++ b/js/highlightedtext/interactive/Highlightedtext.svelte
@@ -0,0 +1,490 @@
+<script lang="ts">
+	const browser = typeof document !== "undefined";
+	import { get_next_color } from "@gradio/utils";
+	import type { SelectData } from "@gradio/utils";
+	import { createEventDispatcher, tick, 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"
+			>
+				{#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}
+			</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)
+									? ""
+									: _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) {
+										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}
+									&nbsp;
+									<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>
diff --git a/js/highlightedtext/interactive/InteractiveHighlightedText.svelte b/js/highlightedtext/interactive/InteractiveHighlightedText.svelte
new file mode 100644
index 0000000000..521e9e5227
--- /dev/null
+++ b/js/highlightedtext/interactive/InteractiveHighlightedText.svelte
@@ -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>
diff --git a/js/highlightedtext/interactive/LabelInput.svelte b/js/highlightedtext/interactive/LabelInput.svelte
new file mode 100644
index 0000000000..7dc94893c5
--- /dev/null
+++ b/js/highlightedtext/interactive/LabelInput.svelte
@@ -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>
diff --git a/js/highlightedtext/interactive/index.ts b/js/highlightedtext/interactive/index.ts
new file mode 100644
index 0000000000..2df0072857
--- /dev/null
+++ b/js/highlightedtext/interactive/index.ts
@@ -0,0 +1 @@
+export { default } from "./InteractiveHighlightedText.svelte";
diff --git a/js/highlightedtext/static/Highlightedtext.svelte b/js/highlightedtext/static/Highlightedtext.svelte
index 2b4ba51a02..b113b4cd2d 100644
--- a/js/highlightedtext/static/Highlightedtext.svelte
+++ b/js/highlightedtext/static/Highlightedtext.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}
 								&nbsp;
@@ -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>
diff --git a/js/highlightedtext/static/StaticHighlightedtext.svelte b/js/highlightedtext/static/StaticHighlightedtext.svelte
index e54824d5f3..b55dc2345c 100644
--- a/js/highlightedtext/static/StaticHighlightedtext.svelte
+++ b/js/highlightedtext/static/StaticHighlightedtext.svelte
@@ -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}
diff --git a/js/highlightedtext/utils.ts b/js/highlightedtext/utils.ts
new file mode 100644
index 0000000000..b94fd06c10
--- /dev/null
+++ b/js/highlightedtext/utils.ts
@@ -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;
+}
diff --git a/test/test_components.py b/test/test_components.py
index a7da3710e7..1afba8c702 100644
--- a/test/test_components.py
+++ b/test/test_components.py
@@ -1828,9 +1828,10 @@ def test_component_functions(self):
             "elem_classes": None,
             "visible": True,
             "value": None,
-            "interactive": None,
+            "interactive": False,
             "root_url": None,
             "selectable": False,
+            "combine_adjacent": False
         }
 
     def test_in_interface(self):


Code review comments:
diff_hunk: @@ -49,6 +45,7 @@ def __init__(
         visible: bool = True,
         elem_id: str | None = None,
         elem_classes: list[str] | str | None = None,
+        interactive: bool = False,
path: gradio/components/highlighted_text.py
text: @user1:
> Theres a bug right now which means we're not detecting whether the component is an input or output, so you have to use Interactive=True

Set the default value to None and Blocks.svelte will set it to be interactive if it's used as input to an event!

@author:
Ah perfect, thank you! 😁