diff --git a/examples/reference/panes/HTML.ipynb b/examples/reference/panes/HTML.ipynb index 1bccb8372e..73862ca03f 100644 --- a/examples/reference/panes/HTML.ipynb +++ b/examples/reference/panes/HTML.ipynb @@ -22,6 +22,7 @@ "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", "\n", "* **`disable_math`** (boolean, `default=True`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`object`** (str or object): The string or object with ``_repr_html_`` method to display\n", "* **`sanitize_html`** (boolean, `default=False`): Whether to sanitize HTML sent to the frontend.\n", "* **`sanitize_hook`** (Callable, `default=bleach.clean`): Sanitization callback to apply if `sanitize_html=True`.\n", diff --git a/examples/reference/panes/Markdown.ipynb b/examples/reference/panes/Markdown.ipynb index 93eeeb7541..ef4f0055e1 100644 --- a/examples/reference/panes/Markdown.ipynb +++ b/examples/reference/panes/Markdown.ipynb @@ -25,6 +25,7 @@ "\n", "* **`dedent`** (bool): Whether to dedent common whitespace across all lines.\n", "* **`disable_math`** (boolean, `default=False`): Whether to disable MathJax math rendering for strings escaped with `$$` delimiters.\n", + "* **`enable_streaming`** (boolean, `default=False`): Whether to enable streaming of text snippets. This will diff the `object` when it is updated and only send the trailing chunk that was added.\n", "* **`extensions`** (list): A list of [Python-Markdown extensions](https://python-markdown.github.io/extensions/) to use (does not apply for 'markdown-it' and 'myst' renderers).\n", "* **`object`** (str or object): A string containing Markdown, or an object with a ``_repr_markdown_`` method.\n", "* **`plugins`** (function): A list of additional markdown-it-py plugins to apply.\n", diff --git a/panel/chat/icon.py b/panel/chat/icon.py index d7c2729323..0d0db1dadd 100644 --- a/panel/chat/icon.py +++ b/panel/chat/icon.py @@ -93,24 +93,33 @@ def _update_value(self, event): class ChatCopyIcon(ReactiveHTML): + """ + ChatCopyIcon copies the value to the clipboard when clicked. + To avoid sending the value to the frontend the value is only + synced after the icon is clicked. + """ + + css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") fill = param.String(default="none", doc="The fill color of the icon.") - value = param.String(default=None, doc="The text to copy to the clipboard.") + value = param.String(default=None, doc="The text to copy to the clipboard.", precedence=-1) - css_classes = param.List(default=["copy-icon"], doc="The CSS classes of the widget.") + _synced = param.String(default=None, doc="The text to copy to the clipboard.") + + _request_sync = param.Integer(default=0) _template = """
""" - _scripts = {"copy_to_clipboard": """ - navigator.clipboard.writeText(`${data.value}`); - data.fill = "currentColor"; - setTimeout(() => data.fill = "none", 50); - """} + _scripts = { + "render": "copy_icon.setAttribute('fill', data.fill)", + "fill": "copy_icon.setAttribute('fill', data.fill)", + "request_value": """ + data._request_sync += 1; + data.fill = "currentColor"; + """, + "_synced": """ + navigator.clipboard.writeText(`${data._synced}`); + data.fill = "none"; + """ + } _stylesheets: ClassVar[list[str]] = [f"{CDN_DIST}css/chat_copy_icon.css"] + + @param.depends('_request_sync', watch=True) + def _sync(self): + self._synced = self.value diff --git a/panel/chat/message.py b/panel/chat/message.py index 26313d433b..11a8a8c9f9 100644 --- a/panel/chat/message.py +++ b/panel/chat/message.py @@ -487,7 +487,7 @@ def _create_panel(self, value, old=None): pass else: if isinstance(old, Markdown) and isinstance(value, str): - self._set_params(old, object=value) + self._set_params(old, enable_streaming=True, object=value) return old object_panel = _panel(value) diff --git a/panel/models/html.ts b/panel/models/html.ts index ec0d2b4800..fe549519e2 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -1,4 +1,4 @@ -import {ModelEvent} from "@bokehjs/core/bokeh_events" +import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" import type * as p from "@bokehjs/core/properties" import type {Attrs, Dict} from "@bokehjs/core/types" import {entries} from "@bokehjs/core/util/object" @@ -6,6 +6,25 @@ import {Markup} from "@bokehjs/models/widgets/markup" import {PanelMarkupView} from "./layout" import {serializeEvent} from "./event-to-object" +@server_event("html_stream") +export class HTMLStreamEvent extends ModelEvent { + constructor(readonly model: HTML, readonly patch: string, readonly start: number) { + super() + this.patch = patch + this.start = start + this.origin = model + } + + protected override get event_values(): Attrs { + return {model: this.origin, patch: this.patch, start: this.start} + } + + static override from_values(values: object) { + const {model, patch, start} = values as {model: HTML, patch: string, start: number} + return new HTMLStreamEvent(model, patch, start) + } +} + export class DOMEvent extends ModelEvent { constructor(readonly node: string, readonly data: unknown) { super() @@ -39,8 +58,33 @@ export function run_scripts(node: Element): void { } } +function throttle(func: Function, limit: number): any { + let lastFunc: number + let lastRan: number + + return function(...args: any) { + // @ts-ignore + const context = this + + if (!lastRan) { + func.apply(context, args) + lastRan = Date.now() + } else { + clearTimeout(lastFunc) + + lastFunc = setTimeout(function() { + if ((Date.now() - lastRan) >= limit) { + func.apply(context, args) + lastRan = Date.now() + } + }, limit - (Date.now() - lastRan)) + } + } +} + export class HTMLView extends PanelMarkupView { declare model: HTML + _buffer: string | null = null protected readonly _event_listeners: Map