implement Webview

This commit is contained in:
Aran-Fey
2024-11-25 21:26:55 +01:00
parent 57e20a750a
commit 461d3a88ef
9 changed files with 317 additions and 237 deletions
+2 -4
View File
@@ -25,7 +25,6 @@ import { FundamentalRootComponent } from "./components/fundamentalRootComponent"
import { GridComponent } from "./components/grid";
import { HeadingListItemComponent } from "./components/headingListItem";
import { HighLevelComponent as HighLevelComponent } from "./components/highLevelComponent";
import { HtmlComponent } from "./components/html";
import { IconComponent } from "./components/icon";
import { ImageComponent } from "./components/image";
import { KeyEventListenerComponent } from "./components/keyEventListener";
@@ -62,7 +61,7 @@ import { TextComponent } from "./components/text";
import { TextInputComponent } from "./components/textInput";
import { ThemeContextSwitcherComponent } from "./components/themeContextSwitcher";
import { TooltipComponent } from "./components/tooltip";
import { WebsiteComponent } from "./components/website";
import { WebviewComponent } from "./components/webview";
import { GraphEditorComponent } from "./components/graphEditor/graphEditor";
const COMPONENT_CLASSES = {
@@ -90,7 +89,6 @@ const COMPONENT_CLASSES = {
"Grid-builtin": GridComponent,
"HeadingListItem-builtin": HeadingListItemComponent,
"HighLevelComponent-builtin": HighLevelComponent,
"Html-builtin": HtmlComponent,
"Icon-builtin": IconComponent,
"IconButton-builtin": IconButtonComponent,
"Image-builtin": ImageComponent,
@@ -128,7 +126,7 @@ const COMPONENT_CLASSES = {
"TextInput-builtin": TextInputComponent,
"ThemeContextSwitcher-builtin": ThemeContextSwitcherComponent,
"Tooltip-builtin": TooltipComponent,
"Website-builtin": WebsiteComponent,
"Webview-builtin": WebviewComponent,
};
globalThis.COMPONENT_CLASSES = COMPONENT_CLASSES;
-87
View File
@@ -1,87 +0,0 @@
import { ComponentBase, ComponentState } from "./componentBase";
export type HtmlState = ComponentState & {
_type_: "Html-builtin";
html?: string;
enable_pointer_events?: boolean;
};
export class HtmlComponent extends ComponentBase {
declare state: Required<HtmlState>;
private isInitialized = false;
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-html");
return element;
}
runScriptsInElement(): void {
for (let oldScriptElement of this.element.querySelectorAll("script")) {
// Create a new script element
const newScriptElement = document.createElement("script");
// Copy over all attributes
for (let i = 0; i < oldScriptElement.attributes.length; i++) {
const attr = oldScriptElement.attributes[i];
newScriptElement.setAttribute(attr.name, attr.value);
}
// And the source itself
newScriptElement.appendChild(
document.createTextNode(oldScriptElement.innerHTML)
);
// Finally replace the old script element with the new one so
// the browser executes it
oldScriptElement.parentNode!.replaceChild(
newScriptElement,
oldScriptElement
);
}
}
updateElement(
deltaState: HtmlState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.html !== undefined) {
// If the HTML hasn't actually changed from last time, don't do
// anything. This is important so scripts don't get re-executed each
// time the component is updated.
if (deltaState.html === this.state.html && this.isInitialized) {
return;
}
if (requiresIframe(deltaState.html)) {
this.element.innerHTML = "";
let iframe = document.createElement("iframe");
iframe.srcdoc = deltaState.html;
this.element.appendChild(iframe);
} else {
// Load the HTML
this.element.innerHTML = deltaState.html;
// Just setting the innerHTML doesn't run scripts. Do that manually.
this.runScriptsInElement();
}
this.isInitialized = true;
}
if (deltaState.enable_pointer_events !== undefined) {
this.element.style.pointerEvents = deltaState.enable_pointer_events
? "auto"
: "none";
}
}
}
function requiresIframe(html: string): boolean {
return html.match(/^\s*(<!doctype |<html[ >])/i) !== null;
}
-31
View File
@@ -1,31 +0,0 @@
import { ComponentBase, ComponentState } from "./componentBase";
export type WebsiteState = ComponentState & {
_type_: "Website-builtin";
url?: string;
};
export class WebsiteComponent extends ComponentBase {
declare state: Required<WebsiteState>;
element: HTMLIFrameElement;
createElement(): HTMLElement {
let element = document.createElement("iframe");
element.classList.add("rio-website");
return element;
}
updateElement(
deltaState: WebsiteState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (
deltaState.url !== undefined &&
deltaState.url !== this.element.src
) {
this.element.src = deltaState.url;
}
}
}
+172
View File
@@ -0,0 +1,172 @@
import { ComponentBase, ComponentState } from "./componentBase";
export type WebviewState = ComponentState & {
_type_: "Webview-builtin";
content?: string; // Url or Html code
enable_pointer_events?: boolean;
resize_to_fit_content?: boolean;
};
export class WebviewComponent extends ComponentBase {
declare state: Required<WebviewState>;
private iframe: HTMLIFrameElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private isInitialized = false;
createElement(): HTMLElement {
let element = document.createElement("div");
element.classList.add("rio-webview");
return element;
}
updateElement(
deltaState: WebviewState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
if (deltaState.content !== undefined) {
// If the URL/HTML hasn't actually changed from last time, don't do
// anything. This is important so scripts don't get re-executed each
// time the component is updated.
if (
deltaState.content !== this.state.content ||
!this.isInitialized
) {
if (isUrl(deltaState.content)) {
this.element.innerHTML = "";
this.iframe = this.createIframe();
this.iframe.src = deltaState.content;
this.element.appendChild(this.iframe);
} else if (requiresIframe(deltaState.content)) {
this.element.innerHTML = "";
this.iframe = this.createIframe();
this.iframe.srcdoc = deltaState.content;
this.element.appendChild(this.iframe);
} else {
// Clean up stuff we no longer need
this.iframe = null;
this.resizeObserver = null;
// Load the HTML
this.element.innerHTML = deltaState.content;
// Just setting the innerHTML doesn't run scripts. Do that manually.
this.runScriptsInElement();
}
this.isInitialized = true;
}
}
if (deltaState.enable_pointer_events !== undefined) {
this.element.style.pointerEvents = deltaState.enable_pointer_events
? "auto"
: "none";
}
if (
deltaState.resize_to_fit_content !== undefined &&
this.iframe !== null
) {
if (deltaState.resize_to_fit_content) {
if (this.resizeObserver === null) {
this.resizeObserver = tryCreateIframeResizeObserver(
this.iframe
);
}
} else {
if (this.resizeObserver !== null) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
this.iframe.style.removeProperty("width");
this.iframe.style.removeProperty("height");
}
}
}
}
createIframe(): HTMLIFrameElement {
let iframe = document.createElement("iframe");
let self = this;
iframe.addEventListener("load", function () {
// Careful, this code runs with a delay! If this iframe has
// already been replaced by other content, do nothing.
if (
self.iframe !== iframe ||
self.resizeObserver !== null ||
!self.state.resize_to_fit_content
) {
return;
}
self.resizeObserver = tryCreateIframeResizeObserver(iframe);
});
return iframe;
}
runScriptsInElement(): void {
for (let oldScriptElement of this.element.querySelectorAll("script")) {
// Create a new script element
const newScriptElement = document.createElement("script");
// Copy over all attributes
for (let i = 0; i < oldScriptElement.attributes.length; i++) {
const attr = oldScriptElement.attributes[i];
newScriptElement.setAttribute(attr.name, attr.value);
}
// And the source itself
newScriptElement.appendChild(
document.createTextNode(oldScriptElement.innerHTML)
);
// Finally replace the old script element with the new one so
// the browser executes it
oldScriptElement.parentNode!.replaceChild(
newScriptElement,
oldScriptElement
);
}
}
}
function isUrl(urlOrHtml: string): boolean {
try {
new URL(urlOrHtml);
return true;
} catch (error) {
return false;
}
}
function requiresIframe(html: string): boolean {
return html.match(/^\s*(<!doctype |<html[ >])/i) !== null;
}
function tryCreateIframeResizeObserver(
iframe: HTMLIFrameElement
): ResizeObserver | null {
let contentDoc = iframe.contentDocument;
if (contentDoc === null) {
return null;
}
let docElement = contentDoc.documentElement;
let resizeObserver = new ResizeObserver(function () {
iframe.style.width = `${docElement.scrollWidth}px`;
iframe.style.height = `${docElement.scrollHeight}px`;
});
resizeObserver.observe(docElement);
return resizeObserver;
}
+56 -99
View File
@@ -167,6 +167,14 @@ $monospace-fonts: var(--rio-global-monospace-font), monospace;
flex-grow: 1;
}
// Fallback dialog for requestFileUpload function
.request-file-upload-fallback-dialog {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
// General
a {
color: var(--rio-local-level-2-bg);
@@ -196,7 +204,17 @@ body {
font-family: var(--rio-global-font, sans-serif);
@include single-container();
// It's pretty common for random elements to be added to the <body> by
// browser extensions or JS libraries, so we can't simply use
// `@single-container()`.
//
// I'm not really sure what purpose(s) those elements serve, but it seems
// unwise to simply hide them. For now, we'll turn the body into a Stack.
display: inline-grid;
& > * {
grid-row: 1;
grid-column: 1;
}
}
// Force input elements to use the font-family we specified. For some reason
@@ -355,9 +373,7 @@ select {
background-color: transparent;
opacity: 0;
transition:
opacity 0.3s ease-in-out,
background-color 1s ease-in-out;
transition: opacity 0.3s ease-in-out, background-color 1s ease-in-out;
& > * {
transform: translateY(-5rem);
@@ -843,9 +859,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
outline: 0.15rem solid var(--rio-global-disabled-bg-variant);
transition:
all 0.3s ease-in-out,
outline 0.15s linear;
transition: all 0.3s ease-in-out, outline 0.15s linear;
}
svg {
@@ -969,9 +983,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
}
.rio-dropdown-popup {
transition:
max-height 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
transition: max-height 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.rio-dropdown-popup:not(.rio-popup-manager-open) {
@@ -1215,9 +1227,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
// styles depend on them.
--outer-text-color: var(--rio-local-text-color);
--outer-bg-active-color: var(--rio-local-bg-active);
transition:
color 0.1s ease-in-out,
border-color 0.1s ease-in-out;
transition: color 0.1s ease-in-out, border-color 0.1s ease-in-out;
// Create a stacking context. This is needed so the `colored-text` and
// `plain-text` styles can reliably create an ::after element behind the
@@ -1236,9 +1246,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
background-color: var(--rio-local-bg);
box-shadow: 0 0 0 transparent;
transition:
background-color 0.1s ease-in-out,
box-shadow 0.2s ease-in-out;
transition: background-color 0.1s ease-in-out, box-shadow 0.2s ease-in-out;
}
.rio-buttonstyle-major:hover {
@@ -1407,9 +1415,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
opacity: 0;
transform: translateY(-50%);
transition:
opacity 0.45s ease-in-out,
transform 0.35s ease;
transition: opacity 0.45s ease-in-out, transform 0.35s ease;
}
.rio-revealer-open > * > .rio-revealer-content-inner {
@@ -1519,10 +1525,8 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
background-color: var(--rio-local-level-2-bg);
opacity: 0%;
transition:
left var(--rio-slider-position-transition-time) ease-in-out,
width 0.15s ease-in-out,
height 0.15s ease-in-out,
transition: left var(--rio-slider-position-transition-time) ease-in-out,
width 0.15s ease-in-out, height 0.15s ease-in-out,
opacity 0.15s ease-in-out;
}
@@ -1547,8 +1551,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
box-shadow: 0 0.1rem 0.2rem var(--rio-global-shadow-color);
transition:
left var(--rio-slider-position-transition-time) ease-in-out,
transition: left var(--rio-slider-position-transition-time) ease-in-out,
background-color 0.1s ease-in-out;
}
@@ -1699,9 +1702,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
border-radius: 50%;
transform: translate(-50%, -50%);
transition:
width 0.2s ease-in-out,
height 0.2s ease-in-out;
transition: width 0.2s ease-in-out, height 0.2s ease-in-out;
}
.rio-media-player-timeline:hover .rio-media-player-timeline-knob {
@@ -1897,7 +1898,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
// directions. That's not what we want - we want to increase the parent's
// width instead.
&[data-scroll-x="never"][data-scroll-y="auto"] > * {
scrollbar-gutter: stable;
scrollbar-gutter: stable !important;
}
}
@@ -2377,9 +2378,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
padding: 0.3rem;
border-radius: 0.5rem;
transition:
opacity 0.1s ease-in-out,
color 0.1s ease-in-out,
transition: opacity 0.1s ease-in-out, color 0.1s ease-in-out,
background-color 0.1s ease-in-out;
}
@@ -2405,9 +2404,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
linear-gradient(45deg, var(--checker-color) 25%, transparent 25%);
background-size: var(--checker-size) var(--checker-size);
background-position:
0 0,
0 0,
background-position: 0 0, 0 0,
calc(var(--checker-size) * -0.5) calc(var(--checker-size) * -0.5),
calc(var(--checker-size) * 0.5) calc(var(--checker-size) * 0.5);
}
@@ -2611,9 +2608,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
background-color: var(--rio-local-bg);
box-shadow: 0 0 0 var(--rio-global-shadow-color);
transition:
box-shadow 0.15s ease-out,
background-color 0.1s ease-out;
transition: box-shadow 0.15s ease-out, background-color 0.1s ease-out;
}
.rio-card-elevate-on-hover:hover {
@@ -2667,9 +2662,7 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
color: var(--rio-local-text-color);
transition:
background-color 0.1s ease-out,
color 0.1s ease-out;
transition: background-color 0.1s ease-out, color 0.1s ease-out;
}
.rio-switcher-bar-option > .rio-switcher-bar-icon {
@@ -3136,11 +3129,8 @@ $rio-input-box-small-label-spacing-top: 0.5rem;
position: fixed;
z-index: $z-index-dev-tools-highlighter;
transition:
left 0.3s ease-in-out,
top 0.3s ease-in-out,
width 0.3s ease-in-out,
height 0.3s ease-in-out;
transition: left 0.3s ease-in-out, top 0.3s ease-in-out,
width 0.3s ease-in-out, height 0.3s ease-in-out;
}
@keyframes pulse {
@@ -3279,8 +3269,7 @@ html.picking-component * {
}
.rio-switcher-resizer {
transition:
min-width var(--rio-switcher-transition-time) ease-in-out,
transition: min-width var(--rio-switcher-transition-time) ease-in-out,
min-height var(--rio-switcher-transition-time) ease-in-out;
}
@@ -3427,12 +3416,8 @@ html.picking-component * {
opacity: 0;
transition:
opacity 0.3s ease-in-out,
left 0.3s ease-in-out,
top 0.3s ease-in-out,
width 0.3s ease-in-out,
height 0.3s ease-in-out;
transition: opacity 0.3s ease-in-out, left 0.3s ease-in-out,
top 0.3s ease-in-out, width 0.3s ease-in-out, height 0.3s ease-in-out;
}
.rio-code-explorer-highlighter::after {
@@ -3506,11 +3491,8 @@ html.picking-component * {
transform: translate(-50%, -50%);
transition:
top var(--rio-ripple-duration),
left var(--rio-ripple-duration),
width var(--rio-ripple-duration),
height var(--rio-ripple-duration),
transition: top var(--rio-ripple-duration), left var(--rio-ripple-duration),
width var(--rio-ripple-duration), height var(--rio-ripple-duration),
opacity var(--rio-ripple-duration);
}
@@ -3527,17 +3509,14 @@ html.picking-component * {
transform: scale(0);
opacity: 0;
transition:
transform 0.2s linear,
opacity 0.1s ease-in-out;
transition: transform 0.2s linear, opacity 0.1s ease-in-out;
}
.rio-popup-animation-scale.rio-popup-manager-open {
transform: scale(1);
opacity: 1;
transition:
transform 0.2s $transition-timing-overshoot,
transition: transform 0.2s $transition-timing-overshoot,
opacity 0.1s ease-in-out;
}
@@ -3696,9 +3675,7 @@ html.picking-component * {
-moz-user-select: none;
-ms-user-select: none;
transition:
background-color 0.1s ease-out,
box-shadow 0.15s ease-out;
transition: background-color 0.1s ease-out, box-shadow 0.15s ease-out;
}
.rio-layout-display-child:not(.rio-layout-display-target) {
@@ -3757,11 +3734,8 @@ html.picking-component * {
width: 1.4rem;
height: 1.4rem;
transition:
opacity 0.2s ease-in-out,
border-width 0.2s ease-in-out,
border-color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
transition: opacity 0.2s ease-in-out, border-width 0.2s ease-in-out,
border-color 0.2s ease-in-out, background-color 0.2s ease-in-out;
}
.rio-checkbox.is-on .rio-checkbox-border {
@@ -3787,10 +3761,9 @@ html.picking-component * {
transform: scale(1);
}
// Html
.rio-html {
// Disable pointer events, the user can always turn them back on if desired
pointer-events: none;
// Webview
.rio-webview {
// `pointer-events` is controlled via JS.
@include single-container(); // FIXME: Should we do this?
}
@@ -3810,9 +3783,7 @@ html.picking-component * {
opacity: 0;
// Theses durations are also referenced in code!
transition:
opacity 0.2s ease-in-out,
background-color 0.5s ease-in-out;
transition: opacity 0.2s ease-in-out, background-color 0.5s ease-in-out;
& > * {
transform: translateY(-2rem);
@@ -3831,12 +3802,7 @@ html.picking-component * {
}
}
// Website
.rio-website {
pointer-events: auto;
}
// File Picker Area
// Upload Area
.rio-file-picker-area {
pointer-events: auto;
@@ -3865,9 +3831,7 @@ html.picking-component * {
color: var(--rio-local-fg);
transition:
background-color 0.1s ease-in-out,
color 0.1s ease-in-out;
transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out;
&::after {
// Using the full foreground color for the outline is brutal. The ::after
@@ -4059,9 +4023,7 @@ html.picking-component * {
opacity: 0;
transform: scale(0.5);
transition:
background-color 0.1s ease-in-out,
opacity 0.1s ease-in-out,
transition: background-color 0.1s ease-in-out, opacity 0.1s ease-in-out,
transform 0.1s ease-in-out;
}
@@ -4166,9 +4128,7 @@ $graph-editor-port-size: 1.4rem;
outline: 0 solid var(--rio-global-primary-bg);
transition:
background-color 0.1s ease-in-out,
box-shadow 0.1s ease-in-out,
transition: background-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out,
outline 0.1s $transition-timing-overshoot;
&:has(.rio-graph-editor-node-header:hover) {
@@ -4258,11 +4218,8 @@ $graph-editor-port-size: 1.4rem;
border-radius: 50%;
background-color: var(--port-color);
transition:
left 0.1s ease-in-out,
top 0.1s ease-in-out,
right 0.1s ease-in-out,
bottom 0.1s ease-in-out;
transition: left 0.1s ease-in-out, top 0.1s ease-in-out,
right 0.1s ease-in-out, bottom 0.1s ease-in-out;
}
.rio-graph-editor-port-circle:hover::after {
+10 -4
View File
@@ -1,13 +1,16 @@
import dataclasses
import typing as t
from .fundamental_component import FundamentalComponent
from ..deprecations import deprecated
from .component import Component
from .webview import Webview
__all__ = ["Html"]
@t.final
class Html(FundamentalComponent):
@deprecated(since="0.11", replacement=Webview)
class Html(Component):
"""
Displays raw HTML.
@@ -42,5 +45,8 @@ class Html(FundamentalComponent):
_: dataclasses.KW_ONLY
enable_pointer_events: bool = True
Html._unique_id_ = "Html-builtin"
def build(self):
return Webview(
self.html,
enable_pointer_events=self.enable_pointer_events,
)
+1 -1
View File
@@ -113,7 +113,7 @@ class Switch(FundamentalComponent):
if "is_on" in delta_state and not self.is_sensitive:
raise AssertionError(
f"Frontend tried to set `Switch.is_on` even though `is_sensitive` is `False`"
"Frontend tried to set `Switch.is_on` even though `is_sensitive` is `False`"
)
async def _call_event_handlers_for_delta_state(
+7 -11
View File
@@ -1,9 +1,9 @@
import typing as t
from uniserde import JsonDoc
from ..deprecations import deprecated
from ..utils import URL
from .fundamental_component import FundamentalComponent
from .component import Component
from .webview import Webview
__all__ = [
"Website",
@@ -11,7 +11,8 @@ __all__ = [
@t.final
class Website(FundamentalComponent):
@deprecated(since="0.11", replacement=Webview)
class Website(Component):
"""
Displays a website.
@@ -37,10 +38,5 @@ class Website(FundamentalComponent):
url: URL
def _custom_serialize_(self) -> JsonDoc:
return {
"url": str(self.url),
}
Website._unique_id_ = "Website-builtin"
def build(self):
return Webview(self.url)
+69
View File
@@ -0,0 +1,69 @@
import dataclasses
import typing as t
from uniserde import JsonDoc
from ..utils import URL
from .fundamental_component import FundamentalComponent
__all__ = ["Webview"]
@t.final
class Webview(FundamentalComponent):
"""
Displays a website or renders HTML.
`Webview` takes a URL or HTML markup as input and displays the website
or the rendered HTML in your app.
## Attributes
`content`: The URL of the website you want to display, or the HTML
you want to render.
`enable_pointer_events`: Whether the `Webview` component (and its contents)
are clickable.
`resize_to_fit_content`: Whether the `Webview` component should automatically
update its size to match the size of its content. Note that this won't
work if the displayed website's domain doesn't match your own domain.
## Examples
This will display a website based on its URL:
```python
rio.Webview(
rio.URL("https://www.example.com"),
)
```
While this will render the given HTML markup:
```python
rio.Webview('<html><body>Hello World</body></html>')
```
The HTML doesn't necessarily have to be an entire website; something
like this will also work just fine:
```python
rio.Webview('<p>Hello World</p>')
```
"""
content: URL | str
_: dataclasses.KW_ONLY
enable_pointer_events: bool = True
resize_to_fit_content: bool = True
def _custom_serialize_(self) -> JsonDoc:
return {
"content": str(self.content),
}
Webview._unique_id_ = "Webview-builtin"