Skip to content

Commit

Permalink
Improvements for ReactiveHTML scripts (#2393)
Browse files Browse the repository at this point in the history
* Improvements for ReactiveHTML scripts

* Allow running script inline

* Add to docstring

* Update docs

* Improve validation and docs
  • Loading branch information
philippjfr committed Jun 16, 2021
1 parent 2f8a920 commit 483d000
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 79 deletions.
242 changes: 194 additions & 48 deletions examples/user_guide/Custom_Components.ipynb
Expand Up @@ -88,12 +88,21 @@
"source": [
"## ReactiveHTML components\n",
"\n",
"The `ReactiveHTML` provides bi-directional syncing of arbitrary HTML attributes and DOM properties with parameters on the subclass. This kind of component must declare a few class-attributes which declare \n",
"The `ReactiveHTML` provides bi-directional syncing of arbitrary HTML attributes and DOM properties with parameters on the subclass. This kind of component must declare a HTML template written using Javascript template variables (`${}`) and optionally Jinja2 syntax:\n",
"\n",
"- `_template`: The HTML template to render declaring how to link parameters on the class to HTML attributes.\n",
"- `_dom_events` (optional): Optional mapping of named nodes to DOM events to add event listeners to.\n",
"- `_scripts` (optional): Optional mapping of Javascript to execute on specific parameter changes.\n",
"\n",
"Additionally the component may declare some additional attributes providing further functionality \n",
"\n",
"- `_child_config` (optional): Optional mapping that controls how children are rendered.\n",
"- `_dom_events` (optional): Optional mapping of named nodes to DOM events to add event listeners to.\n",
"- `_scripts` (optional): Optional mapping of Javascript to execute on specific parameter changes."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### HTML templates\n",
"\n",
"A ReactiveHTML component is declared by providing an HTML template on the `_template` attribute on the class. Parameters are synced by inserting them as template variables of the form `${parameter}`, e.g.:\n",
Expand All @@ -102,15 +111,19 @@
" _template = '<div class=\"${div_class}\">${children}</div>'\n",
"```\n",
"\n",
"will interpolate the div_class parameter on the `class` attribute of the HTML element. In addition to providing attributes we can also provide children to an HTML tag. Any child parameter will be treated as other Panel components to render into the containing HTML. This makes it possible to use `ReactiveHTML` to lay out other components.\n",
"will interpolate the `div_class` parameter on the `class` attribute of the HTML element. In addition to providing attributes we can also provide children to an HTML tag. Any child parameter will be treated as other Panel components to render into the containing HTML. This makes it possible to use `ReactiveHTML` to lay out other components.\n",
"\n",
"The HTML templates also support [Jinja2](https://jinja.palletsprojects.com/en/2.11.x/) syntax to template parameter variables and template child objs. The Jinja2 templating engine is automatically given a few context variables:\n",
"The HTML templates also support [Jinja2](https://jinja.palletsprojects.com/en/2.11.x/) syntax to template parameter variables and template child objects. The Jinja2 templating engine is automatically given a few context variables:\n",
"\n",
"- `param`: The param namespace object allows templating parameter names, labels, docstrings and other attributes.\n",
"- `__doc__`: The class docstring\n",
"\n",
"\n",
"#### Children\n",
"- `__doc__`: The class docstring"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Children\n",
"\n",
"In order to template other parameters as child objects there are a few options. By default all parameters referenced using `${child}` syntax are treated as if they were Panel components, e.g.:\n",
"\n",
Expand All @@ -124,18 +137,24 @@
"_child_config = {'parameter': 'literal'}\n",
"```\n",
"\n",
"If the parameter is a list each item in the list will be inserted in sequence unless declared otherwise. However if you want to wrap each child in some custom HTML you will have to use Jinja2 loop syntax to number the `id` attribute of the child tags and provide an index into the list parameter value: \n",
"If the parameter is a list each item in the list will be inserted in sequence unless declared otherwise. However if you want to wrap each child in some custom HTML you will have to use Jinja2 loop syntax: \n",
"\n",
"```html\n",
"<select>\n",
"{% for obj in parameter %}\n",
"<option id=\"option-{{ loop.index0 }}\">${options[{{ loop.index0 }}]}</option>\n",
"{% endfor %}\n",
"```\n",
"\n",
" {% for obj in parameter %}\n",
" <option id=\"option\">${obj}</option>\n",
" {% endfor %}\n",
"</select>\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### DOM Events\n",
"\n",
"In certain cases it is necessary to explicitly declare event listeners on the DOM node to ensure that changes in their properties are synced when an event is fired. To make this possible the HTML element in question must be given a unique id, e.g.:\n",
"In certain cases it is necessary to explicitly declare event listeners on the DOM node to ensure that changes in their properties are synced when an event is fired. To make this possible the HTML element in question must be given an `id`, e.g.:\n",
"\n",
"```html\n",
" _template = '<input id=\"input\"></input>'\n",
Expand All @@ -147,8 +166,48 @@
" _dom_events = {'input': ['change']}\n",
"```\n",
"\n",
"Once subscribed the class may also define a method following the `_{node}_{event}` naming convention which will fire when the DOM event triggers, e.g. we could define a `_input_change` method. Any such callback will be given a DOMEvent object as the first and only argument. The DOMEvent contains information about the event on the .data attribute and declares the type of event on the .type attribute.\n",
"Once subscribed the class may also define a method following the `_{node}_{event}` naming convention which will fire when the DOM event triggers, e.g. we could define a `_input_change` method. Any such callback will be given a `DOMEvent` object as the first and only argument. The `DOMEvent` contains information about the event on the `.data` attribute and declares the type of event on the `.type` attribute."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Scripts\n",
" \n",
"In addition to declaring callbacks in Python it is also possible to declare Javascript callbacks to execute when a parameter changes. Let us say we have declared an input element with a synced value parameter:\n",
"\n",
"```html\n",
" _template = '<input id=\"input\" value=\"${value}\"></input>'\n",
"```\n",
"\n",
"We can now declare a set of `_scripts`, which will fire whenever the value updates:\n",
"\n",
"```python\n",
" _scripts = {\n",
" 'value': 'console.log(model, data, state, view, input)'\n",
" }\n",
"```\n",
"\n",
"The Javascript is provided multiple objects in its namespace including:\n",
"\n",
"* `data`: The data model holds the current values of the synced parameters, e.g. `data.value` will reflect the current value of the input node.\n",
"* `model`: The `ReactiveHTML` model which holds layout information and information about the children and events.\n",
"* `state`: An empty state dictionary which scripts can use to store state for the lifetime of the view.\n",
"* `view`: The Bokeh View class responsible for rendering the component. This provides access to method like `view.resize_layout()` to signal to Bokeh that it should recompute the layout of the element.\n",
"* `script`: The `script` function allows us to invoke other scripts by name.\n",
"* `<node>`: All named DOM nodes in the HTML template, e.g. the `input` node in the example above.\n",
"\n",
"In addition to scripts invoked when a parameter changes there are two other events that can invoke a script:\n",
"\n",
"1. The `'render'` key in the script dictionary is invoked automatically when the component is first rendered. This can be used to set up state or initialize the component.\n",
"2. Inline callbacks can explicitly invoke a script using the syntax described below."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Inline callbacks\n",
" \n",
"Instead of declaring explicit DOM events Python callbacks can also be declared inline, e.g.:\n",
Expand All @@ -159,36 +218,26 @@
"\n",
"will look for an `_input_change` method on the `ReactiveHTML` component and call it when the event is fired.\n",
"\n",
"### Scripts\n",
" \n",
"In addition to declaring callbacks in Python it is also possible to declare Javascript callbacks to execute when any sync attribute changes. Let us say we have declared an input element with a synced value parameter:\n",
"Additionally we can invoke the Javascript code declared in the `_scripts` dictionary by name using the `script` function, e.g.:\n",
"\n",
"```html\n",
" _template = '<input id=\"input\" value=\"${value}\"></input>'\n",
" <input id=\"input\" onchange=\"${script('some_script')}\"></input>\n",
"```\n",
"\n",
"We can now declare a set of `_scripts`, which will fire whenever the value updates:\n",
"will invoke the following script if it is defined on the class:\n",
"\n",
"```python\n",
" _scripts = {\n",
" 'value': ['console.log(model, data, state, input)']\n",
" _scripts = {\n",
" 'some_script': 'console.log(model, data, input, view)'\n",
" }\n",
"```\n",
"\n",
"The Javascript is provided multiple objects in its namespace including:\n",
"\n",
"\n",
"* `data`: The data model holds the current values of the synced parameters, e.g. data.value will reflect the current value of the input node.\n",
"* `model`: The ReactiveHTML model which holds layout information and information about the children and events.\n",
"* `state`: An empty state dictionary which scripts can use to store state for the lifetime of the view.\n",
"* `<node>`: All named DOM nodes in the HTML template, e.g. the `input` node in the example above."
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Example\n",
"### Examples\n",
"\n",
"#### Callbacks\n",
"\n",
Expand All @@ -207,16 +256,8 @@
" \n",
" index = param.Integer(default=0)\n",
" \n",
" _template = '<img id=\"img\" src=\"https://picsum.photos/800/300?image=${index}\"></img>'\n",
" _template = '<img id=\"img\" src=\"https://picsum.photos/800/300?image=${index}\" onclick=\"${_img_click}\"></img>'\n",
"\n",
" _scripts = {\n",
" 'index': ['console.log(data.index, img)']\n",
" }\n",
"\n",
" _dom_events = {\n",
" 'img': ['click']\n",
" }\n",
" \n",
" def _img_click(self, event):\n",
" self.index += 1\n",
" \n",
Expand All @@ -227,7 +268,24 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"As we can see this approach lets us quickly build custom HTML components with complex interactivity."
"As we can see this approach lets us quickly build custom HTML components with complex interactivity. However if we do not need any complex computations in Python we can also construct a pure JS equivalent:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class JSSlideshow(ReactiveHTML):\n",
" \n",
" index = param.Integer(default=0)\n",
" \n",
" _template = \"\"\"<img id=\"img\" src=\"https://picsum.photos/800/300?image=${index}\" onclick=\"${script('click')}\"></img>\"\"\"\n",
"\n",
" _scripts = {'click': 'data.index += 1'}\n",
" \n",
"JSSlideshow(width=800, height=300)"
]
},
{
Expand All @@ -236,7 +294,7 @@
"source": [
"#### Child templates\n",
"\n",
"If we want to provide a template for the children of an HTML node we have to use Jinja2 syntax to loop over the parameter. The component will automatically assign each `<option>` tag a unique id and insert the loop variable `obj` into each of the tags:"
"If we want to provide a template for the children of an HTML node we have to use Jinja2 syntax to loop over the parameter. The component will automatically assign each `<option>` tag a unique id and insert the loop variable `option` into each of the tags:"
]
},
{
Expand All @@ -253,18 +311,106 @@
" \n",
" _template = \"\"\"\n",
" <select id=\"select\" value=\"${value}\">\n",
" {% for obj in options %}\n",
" <option id=\"option\">${obj}</option>\n",
" {% for option in options %}\n",
" <option id=\"option\">${option}</option>\n",
" {% endfor %}\n",
" </select>\n",
" \"\"\"\n",
" \n",
" _dom_events = {'select': ['change']}\n",
" \n",
"select = Select(options=['A', 'B', 'C'])\n",
"select = Select(options=['A', 'B', 'C'], width=100)\n",
"select"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"#### Pure Javascript events\n",
"\n",
"Next we will build a more complex example using pure Javascript events to draw on a canvas."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"tags": []
},
"outputs": [],
"source": [
"class Canvas(ReactiveHTML):\n",
" \n",
" color = param.Color(default='#000000')\n",
" \n",
" line_width = param.Number(default=1, bounds=(0.1, 10))\n",
"\n",
" _template = \"\"\"\n",
" <canvas \n",
" id=\"canvas\"\n",
" style=\"border: 1px solid;\"\n",
" width=\"${model.width}\"\n",
" height=\"${model.height}\"\n",
" onmousedown=\"${script('start')}\"\n",
" onmousemove=\"${script('draw')}\"\n",
" onmouseup=\"${script('end')}\"\n",
" >\n",
" </canvas>\n",
" <button id=\"clear\" onclick='${script(\"clear\")}' height=\"20px\">Clear</button>\n",
" \"\"\"\n",
" \n",
" _scripts = {\n",
" 'render': \"\"\"\n",
" state.ctx = canvas.getContext(\"2d\");\n",
" \"\"\",\n",
" 'start': \"\"\"\n",
" state.start = state.event\n",
" state.ctx.beginPath();\n",
" state.ctx.moveTo(state.start.offsetX, state.start.offsetY);\n",
" \"\"\",\n",
" 'draw': \"\"\"\n",
" if (state.start == null)\n",
" return\n",
" state.ctx.lineTo(state.event.offsetX, state.event.offsetY);\n",
" state.ctx.stroke();\n",
" \"\"\",\n",
" 'end': \"\"\"\n",
" delete state.start\n",
" \"\"\",\n",
" 'clear': \"\"\"\n",
" state.ctx.clearRect(0, 0, canvas.width, canvas.height);\n",
" \"\"\",\n",
" 'line_width': \"\"\"\n",
" state.ctx.lineWidth = data.line_width;\n",
" \"\"\",\n",
" 'color': \"\"\"\n",
" state.ctx.strokeStyle = data.color;\n",
" \"\"\"\n",
" }\n",
"\n",
"canvas = Canvas(width=400, height=400)\n",
" \n",
"pn.Column(\n",
" '# Drag on canvas to draw',\n",
" pn.Row(\n",
" canvas.controls(['color', 'line_width']),\n",
" canvas\n",
" )\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This example leverages all three ways a script is invoked:\n",
"\n",
"1. `'render'` is called on initialization\n",
"2. `'start'`, `'draw'` and `'end'` are explicitly invoked using the `${script(...)}` syntax in inline callbacks\n",
"3. `'line_width'` and `'color'` are invoked when the parameters change (i.e. when a widget is updated)"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down

0 comments on commit 483d000

Please sign in to comment.