update
@@ -0,0 +1,21 @@
|
||||
# Node UI Basics
|
||||
> [!WARNING]
|
||||
> This module is purely experimental and for educational purpose use only.
|
||||
>
|
||||
> Do not use it in any environment but in an experimental one, definitely not in a production environment.
|
||||
>
|
||||
> I'm not responsible for any damage or harm by the use of anything from this repo.
|
||||
>
|
||||
> Use it at your own risk.
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not use this module unless you have reviewed the source codes thoroughly, understand what it does and in an experimental environment.
|
||||
|
||||
A demo of what a node based UI app looks like.
|
||||
|
||||
Please watch these videos for more details:
|
||||
|
||||
[](https://youtu.be/sMDIly3bddo)
|
||||
[](https://youtu.be/iFPyQjJ2Uyw)
|
||||
[](https://youtu.be/knC4BaGbWGo)
|
||||
[](https://youtu.be/v9TKiaKA_SE)
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "Node UI",
|
||||
'summary': """Node UI""",
|
||||
'description': """
|
||||
Node UI
|
||||
""",
|
||||
'author': "Yoni Tjio",
|
||||
'category': 'Productivity',
|
||||
'version': '18.0.1.0.0',
|
||||
'depends': ['web'],
|
||||
'data': [
|
||||
'views/node_ui_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
"web.assets_backend": [
|
||||
"node_ui/static/lib/FileSaver.js",
|
||||
"node_ui/static/src/**/*",
|
||||
],
|
||||
},
|
||||
"license":"Other proprietary",
|
||||
"application": True,
|
||||
"installable": True,
|
||||
"auto_install": False
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-center">
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5V6h-1V1.5A.5.5 0 0 1 8 1zm0 14a.5.5 0 0 1-.5-.5V10h1v4.5a.5.5 0 0 1-.5.5zM2 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-end">
|
||||
<path fill-rule="evenodd" d="M14.5 1a.5.5 0 0 0-.5.5v13a.5.5 0 0 0 1 0v-13a.5.5 0 0 0-.5-.5z"/>
|
||||
<path d="M13 7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-align-start">
|
||||
<path fill-rule="evenodd" d="M1.5 1a.5.5 0 0 1 .5.5v13a.5.5 0 0 1-1 0v-13a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M3 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 438 B |
@@ -0,0 +1 @@
|
||||
Svg files from svgrepo.com
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 36 36"><path fill="currentColor" d="M9.8 18.8h16.4v3.08h1.6V17.2h-9V14h-1.6v3.2h-9v4.68h1.6V18.8z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M14 23H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2ZM4 31v-6h10v6Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="currentColor" d="M32 23H22a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2Zm-10 8v-6h10v6Z" class="clr-i-outline clr-i-outline-path-3"/><path fill="currentColor" d="M13 13h10a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H13a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2Zm0-8h10v6H13Z" class="clr-i-outline clr-i-outline-path-4"/><path fill="none" d="M0 0h36v36H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 24 24"><path fill="currentColor" d="M15.27 19v-1.866H11.5V12.5H8.711v1.846H3V9.635h5.712V11.5H11.5V6.846h3.77V4.981H21v4.73h-5.73V7.847H12.5v8.289h2.77V14.29H21V19zm1-1H20v-2.712h-3.73zM4 13.346h3.712v-2.711H4zm12.27-4.634H20V5.98h-3.73zm0 9.288v-2.712zm-8.558-4.654v-2.711zm8.557-4.634V5.98z"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-node-minus">
|
||||
<path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1zM8 8a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5A.5.5 0 0 1 8 8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-node-plus">
|
||||
<path fill-rule="evenodd" d="M11 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM6.025 7.5a5 5 0 1 1 0 1H4A1.5 1.5 0 0 1 2.5 10h-1A1.5 1.5 0 0 1 0 8.5v-1A1.5 1.5 0 0 1 1.5 6h1A1.5 1.5 0 0 1 4 7.5h2.025zM11 5a.5.5 0 0 1 .5.5v2h2a.5.5 0 0 1 0 1h-2v2a.5.5 0 0 1-1 0v-2h-2a.5.5 0 0 1 0-1h2v-2A.5.5 0 0 1 11 5zM1.5 7a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="undefined" height="undefined" viewBox="0 0 24 24"><path fill="currentColor" d="M10 2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H8v2h5V9a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-1H8v6h5v-1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-1H7a1 1 0 0 1-1-1V8H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h6Zm9 16h-4v2h4v-2Zm0-8h-4v2h4v-2ZM9 4H5v2h4V4Z"/></svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#000000" fill-rule="evenodd" d="M4 6.25A2.25 2.25 0 016.25 4h3a2.25 2.25 0 012.25 2.25V7h3.25a.75.75 0 010 1.5H11.5v.75a2.25 2.25 0 01-2.25 2.25h-3A2.25 2.25 0 014 9.25V8.5H.75a.75.75 0 010-1.5H4v-.75zm6 0a.75.75 0 00-.75-.75h-3a.75.75 0 00-.75.75v3c0 .414.336.75.75.75h3a.75.75 0 00.75-.75v-3z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 555 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>reset</title>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Combined-Shape" fill="#000000" transform="translate(74.806872, 64.000000)">
|
||||
<path d="M351.859794,42.6666667 L351.859794,85.3333333 L283.193855,85.3303853 C319.271288,116.988529 341.381875,163.321355 341.339886,213.803851 C341.27474,291.98295 288.098183,360.121539 212.277591,379.179704 C136.456999,398.237869 57.3818117,363.341907 20.3580507,294.485411 C-16.6657103,225.628916 -2.17003698,140.420413 55.5397943,87.68 C63.6931909,100.652227 75.1888658,111.189929 88.8197943,118.186667 C59.4998648,141.873553 42.4797783,177.560832 42.5264609,215.253333 C43.5757012,285.194843 100.577082,341.341203 170.526461,341.333333 C234.598174,342.388718 289.235113,295.138227 297.4321,231.584253 C303.556287,184.101393 282.297007,138.84385 245.195596,112.637083 L245.193128,192 L202.526461,192 L202.526461,42.6666667 L351.859794,42.6666667 Z M127.859794,-1.42108547e-14 C151.423944,-1.42108547e-14 170.526461,19.1025173 170.526461,42.6666667 C170.526461,66.230816 151.423944,85.3333333 127.859794,85.3333333 C104.295645,85.3333333 85.1931276,66.230816 85.1931276,42.6666667 C85.1931276,19.1025173 104.295645,-1.42108547e-14 127.859794,-1.42108547e-14 Z">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23 5.5C23 7.98528 20.9853 10 18.5 10C17.0993 10 15.8481 9.36007 15.0228 8.35663L9.87308 10.9315C9.95603 11.2731 10 11.63 10 11.9971C10 12.3661 9.9556 12.7247 9.87184 13.0678L15.0228 15.6433C15.8482 14.6399 17.0993 14 18.5 14C20.9853 14 23 16.0147 23 18.5C23 20.9853 20.9853 23 18.5 23C16.0147 23 14 20.9853 14 18.5C14 18.1319 14.0442 17.7742 14.1276 17.4318L8.97554 14.8558C8.1502 15.8581 6.89973 16.4971 5.5 16.4971C3.01472 16.4971 1 14.4824 1 11.9971C1 9.51185 3.01472 7.49713 5.5 7.49713C6.90161 7.49713 8.15356 8.13793 8.97886 9.14254L14.1275 6.5682C14.0442 6.2258 14 5.86806 14 5.5C14 3.01472 16.0147 1 18.5 1C20.9853 1 23 3.01472 23 5.5ZM16.0029 5.5C16.0029 6.87913 17.1209 7.99713 18.5 7.99713C19.8791 7.99713 20.9971 6.87913 20.9971 5.5C20.9971 4.12087 19.8791 3.00287 18.5 3.00287C17.1209 3.00287 16.0029 4.12087 16.0029 5.5ZM16.0029 18.5C16.0029 19.8791 17.1209 20.9971 18.5 20.9971C19.8791 20.9971 20.9971 19.8791 20.9971 18.5C20.9971 17.1209 19.8791 16.0029 18.5 16.0029C17.1209 16.0029 16.0029 17.1209 16.0029 18.5ZM5.5 14.4943C4.12087 14.4943 3.00287 13.3763 3.00287 11.9971C3.00287 10.618 4.12087 9.5 5.5 9.5C6.87913 9.5 7.99713 10.618 7.99713 11.9971C7.99713 13.3763 6.87913 14.4943 5.5 14.4943Z" fill="#0F0F0F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.803 5.33333C13.803 3.49238 15.3022 2 17.1515 2C19.0008 2 20.5 3.49238 20.5 5.33333C20.5 7.17428 19.0008 8.66667 17.1515 8.66667C16.2177 8.66667 15.3738 8.28596 14.7671 7.67347L10.1317 10.8295C10.1745 11.0425 10.197 11.2625 10.197 11.4872C10.197 11.9322 10.109 12.3576 9.94959 12.7464L15.0323 16.0858C15.6092 15.6161 16.3473 15.3333 17.1515 15.3333C19.0008 15.3333 20.5 16.8257 20.5 18.6667C20.5 20.5076 19.0008 22 17.1515 22C15.3022 22 13.803 20.5076 13.803 18.6667C13.803 18.1845 13.9062 17.7255 14.0917 17.3111L9.05007 13.9987C8.46196 14.5098 7.6916 14.8205 6.84848 14.8205C4.99917 14.8205 3.5 13.3281 3.5 11.4872C3.5 9.64623 4.99917 8.15385 6.84848 8.15385C7.9119 8.15385 8.85853 8.64725 9.47145 9.41518L13.9639 6.35642C13.8594 6.03359 13.803 5.6896 13.803 5.33333Z" fill="#1C274C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg"><path d="M 2.9629 21.9230 C 4.1254 21.9230 4.8091 21.2392 4.8091 20.0540 L 4.8091 14.6523 C 4.8091 12.2820 6.0627 11.0740 8.3419 11.0740 L 47.6810 11.0740 C 49.9375 11.0740 51.2136 12.2820 51.2136 14.6523 L 51.2136 20.0540 C 51.2136 21.2392 51.8975 21.9230 53.0598 21.9230 C 54.2449 21.9230 54.8830 21.2392 54.8830 20.0540 L 54.8830 14.4700 C 54.8830 9.7748 52.5127 7.4044 47.7263 7.4044 L 8.2963 7.4044 C 3.5327 7.4044 1.1396 9.7520 1.1396 14.4700 L 1.1396 20.0540 C 1.1396 21.2392 1.8006 21.9230 2.9629 21.9230 Z M 27.9658 38.2648 C 29.1509 38.2648 29.8119 37.4671 29.8119 36.2135 L 29.8119 21.4443 L 35.4643 21.4443 C 36.3532 21.4443 36.9914 20.8517 36.9914 19.9400 C 36.9914 19.0056 36.3532 18.4586 35.4643 18.4586 L 20.5584 18.4586 C 19.6923 18.4586 19.0313 19.0056 19.0313 19.9400 C 19.0313 20.8517 19.6923 21.4443 20.5584 21.4443 L 26.1652 21.4443 L 26.1652 36.2135 C 26.1652 37.4215 26.8034 38.2648 27.9658 38.2648 Z M 2.9629 30.7663 C 4.6040 30.7663 5.9259 29.4215 5.9259 27.7805 C 5.9259 26.1623 4.6040 24.8404 2.9629 24.8404 C 1.3447 24.8404 0 26.1623 0 27.7805 C 0 29.4215 1.3447 30.7663 2.9629 30.7663 Z M 53.0598 30.7663 C 54.6781 30.7663 56 29.4215 56 27.7805 C 56 26.1623 54.6781 24.8404 53.0598 24.8404 C 51.3961 24.8404 50.0966 26.1395 50.0966 27.7805 C 50.0966 29.4215 51.3961 30.7663 53.0598 30.7663 Z M 8.2963 49.3645 L 47.7263 49.3645 C 52.5127 49.3645 54.8830 46.9942 54.8830 42.2990 L 54.8830 35.5754 C 54.8830 34.3902 54.2220 33.7292 53.0598 33.7292 C 51.8746 33.7292 51.2136 34.3902 51.2136 35.5754 L 51.2136 42.1167 C 51.2136 44.4870 49.9375 45.6950 47.6810 45.6950 L 8.3419 45.6950 C 6.0627 45.6950 4.8091 44.4870 4.8091 42.1167 L 4.8091 35.5754 C 4.8091 34.3902 4.1254 33.7292 2.9629 33.7292 C 1.8006 33.7292 1.1396 34.3902 1.1396 35.5754 L 1.1396 42.2990 C 1.1396 47.0170 3.5327 49.3645 8.2963 49.3645 Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,20 @@
|
||||
.menu-icon {
|
||||
filter: invert(0.80);
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
border: gray dashed 1px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.dragged-placeholder {
|
||||
width: 100%;
|
||||
margin-top: "gray dashed 1px";
|
||||
border: gray dashed 1px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.dragged-placeholder::after {
|
||||
content: '\200b';
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
import { useDraggable } from "@node_ui/node_ui/utils";
|
||||
|
||||
export class Path extends Component {
|
||||
static template = "node_ui.connection-path";
|
||||
static props = {
|
||||
path: Object,
|
||||
};
|
||||
}
|
||||
|
||||
export class Waypoint extends Component {
|
||||
static template = "node_ui.connection-waypoint";
|
||||
static props = {
|
||||
waypoint: Object,
|
||||
};
|
||||
}
|
||||
|
||||
export class Connection extends Component {
|
||||
static template = "node_ui.connection";
|
||||
static components = { Path, Waypoint };
|
||||
static props = {
|
||||
connection: Object
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
|
||||
useDraggable({
|
||||
ref: this.rootRef,
|
||||
elements: ".point",
|
||||
// @ts-ignore
|
||||
onWillStartDrag: ({ element: ctx, x, y}) => {
|
||||
const cnn = this.props.connection;
|
||||
const wp = cnn.waypoints.find(o => o.id == ctx.id);
|
||||
|
||||
ctx.startX = wp.pos.centerX;
|
||||
ctx.startY = wp.pos.centerY;
|
||||
|
||||
ctx.startPointerX = x;
|
||||
ctx.startPointerY = y;
|
||||
},
|
||||
onDrag: ({ element: ctx, x, y}) => {
|
||||
const deltaX = (x - ctx.startPointerX) / this.env.translation.zoom;
|
||||
const deltaY = (y - ctx.startPointerY) / this.env.translation.zoom;
|
||||
|
||||
const wpX = (ctx.startX + deltaX);
|
||||
const wpY = (ctx.startY + deltaY);
|
||||
|
||||
this.onMoveWaypoint(ctx.id, wpX, wpY);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
|
||||
}
|
||||
|
||||
onMoveWaypoint(id, x, y){
|
||||
const cnn = this.props.connection;
|
||||
cnn.moveWaypoint(id, x, y);
|
||||
}
|
||||
|
||||
_pointIsOnPath(pathEl, x, y) {
|
||||
const svg = this.rootRef.el;
|
||||
let point = svg.createSVGPoint();
|
||||
point.x = x;
|
||||
point.y = y;
|
||||
|
||||
return pathEl.isPointInStroke(point);
|
||||
}
|
||||
|
||||
_findPrecedingWaypoint(x, y){
|
||||
const cnn = this.props.connection;
|
||||
let onPath = false;
|
||||
for(let i = 0; i < cnn.paths.length; i++){
|
||||
const pathEl = this.rootRef.el.getElementById(cnn.paths[i].id);
|
||||
const onPath = this._pointIsOnPath(pathEl, x, y)
|
||||
if (onPath){
|
||||
const waypointIdx = cnn.waypoints.findIndex(o => o.endPathId == cnn.paths[i].id)
|
||||
return { idx: waypointIdx, onPath: onPath };
|
||||
}
|
||||
}
|
||||
return { idx: -1, onPath: onPath };
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.ctrlKey){
|
||||
const cnn = this.props.connection;
|
||||
if (event.target.nodeName === "circle") {
|
||||
const waypoint = cnn.waypoints.find(o => o.id == event.target.id);
|
||||
if (waypoint){
|
||||
cnn.removeWaypoint(waypoint);
|
||||
}
|
||||
} else if (event.target.nodeName === "path"){
|
||||
const path = cnn.paths.find(o => o.id == event.target.id);
|
||||
if (path){
|
||||
const docElement = document.querySelector(".node-ui-doc");
|
||||
const docRect = docElement.getBoundingClientRect();
|
||||
const evX = event.clientX;
|
||||
const evY = event.clientY;
|
||||
|
||||
const x = (evX - docRect.left) / this.env.translation.zoom;
|
||||
const y = (evY - docRect.top) / this.env.translation.zoom;
|
||||
|
||||
const precedingWaypoint = this._findPrecedingWaypoint(x, y);
|
||||
|
||||
if (precedingWaypoint.onPath){
|
||||
cnn.createWaypoint(path, x, y, precedingWaypoint.idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.env.bus.trigger(this.env.channel + "/selected", {
|
||||
id: this.props.connection.id,
|
||||
type: "connection"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDebug(ev){
|
||||
console.log("debug");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="node_ui.connection">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="connection" t-ref="root"
|
||||
t-on-click.stop.prevent="onClick">
|
||||
<g class="path" t-att-id="props.connection.id" >
|
||||
<t t-foreach="props.connection.paths" t-as="path" t-key="path.id">
|
||||
<Path path="path" />
|
||||
</t>
|
||||
</g>
|
||||
<t t-foreach="props.connection.waypoints" t-as="waypoint" t-key="waypoint.id">
|
||||
<Waypoint waypoint="waypoint"/>
|
||||
</t>
|
||||
</svg>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.connection-path">
|
||||
<path t-att-id="props.path.id" t-att-d="props.path.svgPath()"/>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.connection-waypoint">
|
||||
<circle xmlns="http://www.w3.org/2000/svg" class="point"
|
||||
t-att-id="props.waypoint.id" t-att-cx="props.waypoint.pos.centerX" t-att-cy="props.waypoint.pos.centerY"
|
||||
t-att-r="props.waypoint.pos.radius"/>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { useBus } from "@web/core/utils/hooks";
|
||||
import { useDebounced, useThrottleForAnimation } from "@web/core/utils/timing";
|
||||
|
||||
import { uuidv4 } from "@node_ui/node_ui/utils";
|
||||
|
||||
import { DumbNode, DumbNodeNoInput, DumbNodeNoOutput, DumbNodeWithTextArea,
|
||||
DumbNodeMultipleInputs, DumbNodeMultipleOutputs } from "@node_ui/node_ui/node";
|
||||
|
||||
import { Connection } from "@node_ui/node_ui/connection";
|
||||
|
||||
export class Document extends Component {
|
||||
static template = "node_ui.document";
|
||||
static props = {
|
||||
document: Object,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousemove", this.onMouseMove.bind(this));
|
||||
document.addEventListener("mouseup", this.onMouseUp.bind(this));
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
document.removeEventListener("mousemove", this.onMouseMove.bind(this));
|
||||
document.removeEventListener("mouseup", this.onMouseUp.bind(this));
|
||||
});
|
||||
|
||||
this.onMouseUp = useDebounced(this.onMouseUp, "animationFrame");
|
||||
this.onMouseMove = useThrottleForAnimation(this.onMouseMove);
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/new", this.onNewNode.bind(this));
|
||||
useBus(this.env.bus, this.env.channel + "/delete", this.onDeleteSelected.bind(this));
|
||||
useBus(this.env.bus, this.env.channel + "/reset", this.onReset.bind(this));
|
||||
useBus(this.env.bus, this.env.channel + "/selected", this.onSelected.bind(this));
|
||||
useBus(this.env.bus, this.env.channel + "/clear-selected", this.onClearSelected.bind(this));
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/out-connect", this.onOutConnect.bind(this));
|
||||
useBus(this.env.bus, this.env.channel + "/in-connect", this.onInConnect.bind(this));
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
|
||||
}
|
||||
|
||||
get connectionComponent() {
|
||||
return Connection;
|
||||
}
|
||||
|
||||
_createNode(title, component, x, y) {
|
||||
const id = uuidv4();
|
||||
|
||||
const doc = this.props.document;
|
||||
doc.addNode(id, title, component, x, y);
|
||||
}
|
||||
|
||||
onNewNode(event) {
|
||||
const type = event.detail.type;
|
||||
const x = event.detail.x;
|
||||
const y = event.detail.y;
|
||||
switch (type) {
|
||||
case "dumb": {
|
||||
this._createNode("Node", DumbNode, x, y);
|
||||
break;
|
||||
}
|
||||
case "dumb-no-input": {
|
||||
this._createNode("Node - No Input", DumbNodeNoInput, x, y);
|
||||
break;
|
||||
}
|
||||
case "dumb-no-output": {
|
||||
this._createNode("Node - No Output", DumbNodeNoOutput, x, y);
|
||||
break;
|
||||
}
|
||||
case "dumb-textarea": {
|
||||
this._createNode("Node - Textarea", DumbNodeWithTextArea, x, y);
|
||||
break;
|
||||
}
|
||||
case "dumb-multiple-outputs": {
|
||||
this._createNode("Node - Multiple Outputs", DumbNodeMultipleOutputs, x, y);
|
||||
break;
|
||||
}
|
||||
case "dumb-multiple-inputs": {
|
||||
this._createNode("Node - Multiple Inputs", DumbNodeMultipleInputs, x, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearSelected() {
|
||||
let els = document.getElementsByClassName("selected");
|
||||
while (els.length > 0) {
|
||||
els[0].classList.remove("selected");
|
||||
els = document.getElementsByClassName("selected");
|
||||
}
|
||||
|
||||
const doc = this.props.document;
|
||||
doc.clearSelected();
|
||||
}
|
||||
|
||||
onClearSelected() {
|
||||
this.clearSelected();
|
||||
}
|
||||
|
||||
onSelected(event) {
|
||||
const el = document.getElementById(event.detail.id);
|
||||
if (el) {
|
||||
el.classList.toggle("selected");
|
||||
|
||||
const doc = this.props.document;
|
||||
doc.toggleSelect(event.detail.type, event.detail.id);
|
||||
} else {
|
||||
this.clearSelected();
|
||||
}
|
||||
}
|
||||
|
||||
onDeleteSelected() {
|
||||
const doc = this.props.document;
|
||||
doc.deleteSelected();
|
||||
this.clearSelected();
|
||||
}
|
||||
|
||||
onReset() {
|
||||
const doc = this.props.document;
|
||||
doc.reset();
|
||||
}
|
||||
|
||||
onOutConnect(event) {
|
||||
const id = uuidv4();
|
||||
const portId = event.detail.id;
|
||||
const nodeId = event.detail.nodeId;
|
||||
|
||||
const doc = this.props.document;
|
||||
doc.prepareConnection(id, portId, nodeId, event.detail.x, event.detail.y);
|
||||
}
|
||||
|
||||
onInConnect(event) {
|
||||
const portId = event.detail.id;
|
||||
const nodeId = event.detail.nodeId;
|
||||
|
||||
const doc = this.props.document;
|
||||
doc.completeConnection(portId, nodeId, event.detail.x, event.detail.y);
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
const doc = this.props.document;
|
||||
if (doc && doc.newConnection !== undefined) {
|
||||
const docElement = document.querySelector(".node-ui-doc");
|
||||
const docRect = docElement.getBoundingClientRect();
|
||||
const x = (event.clientX - docRect.left) / this.env.translation.zoom;
|
||||
const y = (event.clientY - docRect.top) / this.env.translation.zoom;
|
||||
|
||||
doc.updateNewConnection(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
const doc = this.props.document;
|
||||
if (doc && doc.newConnection !== undefined) {
|
||||
doc.clearNewConnection();
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown(event) {
|
||||
if (event.key === "Delete" && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.onDeleteComponent();
|
||||
}
|
||||
}
|
||||
|
||||
onDebug(ev) {
|
||||
console.log("debug");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="node_ui.document">
|
||||
<div t-ref="root" tabindex="0" class="node-ui-doc" t-on-mouseup="onMouseUp"
|
||||
t-on-keydown="onKeydown">
|
||||
<t t-foreach="props.document.nodes" t-as="node" t-key="node.id">
|
||||
<t t-component="node.component" node="node"/>
|
||||
</t>
|
||||
<t t-foreach="props.document.connections" t-as="cnn" t-key="cnn.id">
|
||||
<t t-component="connectionComponent" connection="cnn" />
|
||||
</t>
|
||||
<t t-if="props.document.newConnection !== undefined">
|
||||
<t t-component="connectionComponent" connection="props.document.newConnection" />
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,575 @@
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
import { uuidv4, makeReactive, removeItem } from "@node_ui/node_ui/utils";
|
||||
import { BaseNode } from "@node_ui/node_ui/node";
|
||||
|
||||
import { DumbNode, DumbNodeNoInput, DumbNodeNoOutput, DumbNodeWithTextArea,
|
||||
DumbNodeMultipleInputs, DumbNodeMultipleOutputs } from "@node_ui/node_ui/node";
|
||||
|
||||
const CLASS_MAP = {
|
||||
DumbNode: DumbNode,
|
||||
DumbNodeNoInput: DumbNodeNoInput,
|
||||
DumbNodeNoOutput: DumbNodeNoOutput,
|
||||
DumbNodeWithTextArea: DumbNodeWithTextArea,
|
||||
DumbNodeMultipleInputs: DumbNodeMultipleInputs,
|
||||
DumbNodeMultipleOutputs: DumbNodeMultipleOutputs,
|
||||
}
|
||||
|
||||
class NodeUiBase {
|
||||
constructor(id = uuidv4()){
|
||||
this.id = id;
|
||||
this.loadedFromFile = false;
|
||||
}
|
||||
}
|
||||
|
||||
class TitledUiBase extends NodeUiBase {
|
||||
constructor(id, title){
|
||||
super(id);
|
||||
this.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
export class Waypoint extends NodeUiBase {
|
||||
constructor(id, centerX, centerY, radius, startPathId, endPathId){
|
||||
super(id);
|
||||
|
||||
const initPos = {
|
||||
centerX: centerX,
|
||||
centerY: centerY,
|
||||
radius: radius,
|
||||
}
|
||||
|
||||
this.pos = makeReactive(this, initPos);
|
||||
|
||||
this.startPathId = startPathId;
|
||||
this.endPathId = endPathId;
|
||||
}
|
||||
}
|
||||
|
||||
export class Path extends NodeUiBase {
|
||||
constructor(id, startX, startY, endX, endY){
|
||||
super(id);
|
||||
const initPos = {
|
||||
startX: startX,
|
||||
startY: startY,
|
||||
endX: endX,
|
||||
endY: endY
|
||||
}
|
||||
|
||||
this.pos = makeReactive(this, initPos);
|
||||
}
|
||||
|
||||
getSvgStraightPath() {
|
||||
const svgPath = [];
|
||||
|
||||
svgPath.push("M", this.pos.startX, this.pos.startY);
|
||||
svgPath.push("L", this.pos.endX, this.pos.endY);
|
||||
const res = svgPath.join(" ");
|
||||
return res;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/45245042
|
||||
_drawCurve(startX, startY, endX, endY) {
|
||||
// L
|
||||
let BX = Math.abs(endX - startX) * 0.05 + startX;
|
||||
let BY = startY;
|
||||
|
||||
// C
|
||||
let CX = startX + Math.abs(endX - startX) * 0.33;
|
||||
let CY = startY;
|
||||
let DX = endX - Math.abs(endX - startX) * 0.33;
|
||||
let DY = endY;
|
||||
let EX = -Math.abs(endX - startX) * 0.05 + endX;
|
||||
let EY = endY;
|
||||
|
||||
const svgPath = []
|
||||
svgPath.push("M", startX, startY);
|
||||
svgPath.push("L", BX, ",", BY);
|
||||
svgPath.push("C", CX, ",", CY);
|
||||
svgPath.push(DX, ",", DY);
|
||||
svgPath.push(EX, ",", EY);
|
||||
svgPath.push("L", endX, ",", endY);
|
||||
|
||||
const res = svgPath.join(" ");
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
svgCurvyPath() {
|
||||
const res = this._drawCurve(
|
||||
this.pos.startX,
|
||||
this.pos.startY,
|
||||
this.pos.endX,
|
||||
this.pos.endY
|
||||
);
|
||||
return res;
|
||||
}
|
||||
|
||||
svgPath() {
|
||||
return this.svgCurvyPath();
|
||||
}
|
||||
}
|
||||
|
||||
export class Connection extends NodeUiBase {
|
||||
constructor(id, startX, startY, endX, endY, inPortId, inNodeId, outPortId, outNodeId, {
|
||||
paths = [],
|
||||
waypoints = []
|
||||
} = {}){
|
||||
super(id);
|
||||
|
||||
this.paths = [].concat(paths);
|
||||
this.waypoints = [].concat(waypoints);
|
||||
this.lastPos = {};
|
||||
|
||||
const initPos = {
|
||||
startX: startX,
|
||||
startY: startY,
|
||||
endX: endX,
|
||||
endY: endY
|
||||
}
|
||||
|
||||
this.pos = makeReactive(this, initPos, { onChangedHandler: this.updatePaths });
|
||||
|
||||
this.inPortId = inPortId;
|
||||
this.inNodeId = inNodeId;
|
||||
this.outPortId = outPortId;
|
||||
this.outNodeId = outNodeId;
|
||||
|
||||
if (this.paths.length == 0){
|
||||
const firstPathId = uuidv4();
|
||||
const firstPath = new Path(firstPathId, this.pos.startX, this.pos.startY, this.pos.endX, this.pos.endY);
|
||||
this.paths.push(firstPath);
|
||||
}
|
||||
}
|
||||
|
||||
updatePaths(owner, pos) {
|
||||
if (owner.paths.length > 0){
|
||||
if (pos.startX == owner.lastPos.startX && pos.startY == owner.lastPos.startY){
|
||||
Object.assign(owner.paths[owner.paths.length - 1].pos, pick(pos, "endX", "endY"));
|
||||
} else if (pos.endX == owner.lastPos.endX && pos.endY == owner.lastPos.endY){
|
||||
Object.assign(owner.paths[0].pos, pick(pos, "startX", "startY"));
|
||||
}
|
||||
}
|
||||
Object.assign(owner.lastPos, pos);
|
||||
}
|
||||
|
||||
updateEndPos(x, y) {
|
||||
Object.assign(this.pos, {endX: x, endY: y});
|
||||
}
|
||||
|
||||
updateStartPos(x, y) {
|
||||
Object.assign(this.pos, {startX: x, startY: y});
|
||||
}
|
||||
|
||||
removeWaypoint(waypoint){
|
||||
const waypointIdx = this.waypoints.indexOf(waypoint);
|
||||
|
||||
const startPathIdx = this.paths.findIndex(o => o.id == waypoint.startPathId);
|
||||
const startPath = this.paths[startPathIdx];
|
||||
|
||||
if (this.waypoints.length > 1) {
|
||||
if (waypointIdx == this.waypoints.length - 1){
|
||||
startPath.pos.endX = this.pos.endX;
|
||||
startPath.pos.endY = this.pos.endY;
|
||||
} else {
|
||||
startPath.pos.endX = this.waypoints[waypointIdx + 1].pos.centerX;
|
||||
startPath.pos.endY = this.waypoints[waypointIdx + 1].pos.centerY;
|
||||
|
||||
this.waypoints[waypointIdx + 1].startPathId = startPath.id;
|
||||
}
|
||||
} else { // just one waypoint
|
||||
startPath.pos.endX = this.pos.endX;
|
||||
startPath.pos.endY = this.pos.endY;
|
||||
}
|
||||
|
||||
const endPathIdx = this.paths.findIndex(o => o.id == waypoint.endPathId);
|
||||
this.paths.splice(endPathIdx, 1); // remove path
|
||||
this.waypoints.splice(waypointIdx, 1); // remove waypoint
|
||||
}
|
||||
|
||||
createWaypoint(path, x, y, previousWaypointIndex){
|
||||
const pathIdx = this.paths.indexOf(path);
|
||||
|
||||
const newWaypointId = uuidv4();
|
||||
const newWaypoint = new Waypoint(newWaypointId, x, y, 6, path.id);
|
||||
|
||||
this.waypoints.splice(previousWaypointIndex + 1, 0, newWaypoint);
|
||||
|
||||
const oldEndX = path.pos.endX;
|
||||
const oldEndY = path.pos.endY;
|
||||
|
||||
path.pos.endX = x;
|
||||
path.pos.endY = y;
|
||||
|
||||
const newPathId = uuidv4();
|
||||
const newPath = new Path(newPathId, x, y, oldEndX, oldEndY)
|
||||
this.paths.splice(pathIdx + 1, 0, newPath);
|
||||
|
||||
newWaypoint.endPathId = newPathId;
|
||||
const newWaypointIdx = this.waypoints.findIndex(o => o.id == newWaypointId);
|
||||
if (this.waypoints[newWaypointIdx + 1]){
|
||||
this.waypoints[newWaypointIdx + 1].startPathId = newPathId;
|
||||
}
|
||||
}
|
||||
|
||||
moveWaypoint(id, x, y) {
|
||||
const wp = this.waypoints.find(o => o.id == id);
|
||||
if (wp){
|
||||
wp.pos.centerX = x;
|
||||
wp.pos.centerY = y;
|
||||
|
||||
const startPath = this.paths.find(o => o.id == wp.startPathId);
|
||||
startPath.pos.endX = x;
|
||||
startPath.pos.endY = y;
|
||||
|
||||
const endPath = this.paths.find(o => o.id == wp.endPathId);
|
||||
endPath.pos.startX = x;
|
||||
endPath.pos.startY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Port extends NodeUiBase {
|
||||
constructor(id, nodeId, type, maxLinks){
|
||||
super(id);
|
||||
this.nodeId = nodeId;
|
||||
this.type = type;
|
||||
this.maxLinks = maxLinks;
|
||||
|
||||
this.links = [];
|
||||
}
|
||||
|
||||
canAddLink() {
|
||||
return this.links.length < this.maxLinks
|
||||
}
|
||||
|
||||
addLink(cnn) {
|
||||
if (!this.canAddLink()){
|
||||
return;
|
||||
}
|
||||
if (cnn.id in this.links){
|
||||
return;
|
||||
}
|
||||
|
||||
this.links.push(cnn);
|
||||
}
|
||||
|
||||
removeLink(id){
|
||||
removeItem(this.links, id);
|
||||
}
|
||||
|
||||
clearLinks(){
|
||||
this.links = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class Node extends TitledUiBase {
|
||||
constructor(id, title, component, left, top){
|
||||
super(id, title);
|
||||
this.component = component;
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
|
||||
this.inPorts = [];
|
||||
this.outPorts = [];
|
||||
}
|
||||
|
||||
move(left, top, deltaX, deltaY){
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
|
||||
this.inPorts.forEach(port => {
|
||||
port.links.forEach(cnn => {
|
||||
cnn.pos.endX = cnn.pos.endX + deltaX;
|
||||
cnn.pos.endY = cnn.pos.endY + deltaY;
|
||||
});
|
||||
});
|
||||
|
||||
this.outPorts.forEach(port => {
|
||||
port.links.forEach(cnn => {
|
||||
cnn.pos.startX = cnn.pos.startX + deltaX;
|
||||
cnn.pos.startY = cnn.pos.startY + deltaY;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
addInPorts(id, maxLinks){
|
||||
const port = new Port(id, this.id, "in", maxLinks);
|
||||
this.inPorts.push(port)
|
||||
return port;
|
||||
}
|
||||
|
||||
addOutPorts(id, maxLinks){
|
||||
const port = new Port(id, this.id, "out", maxLinks);
|
||||
this.outPorts.push(port)
|
||||
return port;
|
||||
}
|
||||
|
||||
canAddInput(portId){
|
||||
const port = this.inPorts.find(o => o.id == portId);
|
||||
return port.canAddLink()
|
||||
}
|
||||
|
||||
addInput(portId, cnn){
|
||||
const port = this.inPorts.find(o => o.id == portId);
|
||||
port.addLink(cnn)
|
||||
}
|
||||
|
||||
removeInput(portId, cnnId){
|
||||
const port = this.inPorts.find(o => o.id == portId);
|
||||
port.removeLink(cnnId);
|
||||
}
|
||||
|
||||
canAddOutput(portId){
|
||||
const port = this.outPorts.find(o => o.id == portId);
|
||||
return port.canAddLink()
|
||||
}
|
||||
|
||||
addOutput(portId, cnn){
|
||||
const port = this.outPorts.find(o => o.id == portId);
|
||||
port.addLink(cnn)
|
||||
}
|
||||
|
||||
removeOutput(portId, cnnId){
|
||||
const port = this.outPorts.find(o => o.id == portId);
|
||||
port.removeLink(cnnId);
|
||||
}
|
||||
|
||||
getConnections(){
|
||||
let res = [];
|
||||
|
||||
this.inPorts.forEach(port => {
|
||||
res = res.concat(port.links)
|
||||
});
|
||||
|
||||
this.outPorts.forEach(port => {
|
||||
res = res.concat(port.links)
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
resetConnections(){
|
||||
this.inPorts.forEach(port => {
|
||||
port.clearLinks();
|
||||
});
|
||||
|
||||
this.outPorts.forEach(port => {
|
||||
port.clearLinks();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Document extends TitledUiBase {
|
||||
constructor(id, sessionId, title){
|
||||
super(id, title);
|
||||
|
||||
this.sessionId = sessionId;
|
||||
|
||||
this.nodes = [];
|
||||
this.connections = [];
|
||||
this.newConnection = undefined;
|
||||
this.selected = [];
|
||||
}
|
||||
|
||||
addNode(id, title, component, left, top) {
|
||||
const node = new Node(id, title, component, left, top);
|
||||
this.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeConnection(id){
|
||||
const cnnIdx = this.connections.findIndex(o => o.id === id);
|
||||
if (cnnIdx > -1){
|
||||
const cnn = this.connections[cnnIdx];
|
||||
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
|
||||
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
|
||||
outNode.removeOutput(cnn.outPortId, id);
|
||||
inNode.removeInput(cnn.inPortId, id);
|
||||
|
||||
removeItem(this.connections, id);
|
||||
}
|
||||
}
|
||||
|
||||
addConnection(cnn){
|
||||
this.connections.push(cnn);
|
||||
return cnn;
|
||||
}
|
||||
|
||||
toggleSelect(type, id){
|
||||
const i = this.selected.findIndex(o => o.id == id);
|
||||
if (i > -1){
|
||||
this.selected.splice(i, 1);
|
||||
}else {
|
||||
this.selected.push({
|
||||
id: id,
|
||||
type: type
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clearSelected(){
|
||||
this.selected = [];
|
||||
}
|
||||
|
||||
removeNode(id){
|
||||
const node = this.nodes.find(o => o.id === id);
|
||||
const cnns = node.getConnections();
|
||||
|
||||
node.resetConnections();
|
||||
|
||||
cnns.forEach(cnn => {
|
||||
this.removeConnection(cnn.id);
|
||||
});
|
||||
|
||||
removeItem(this.nodes, id);
|
||||
}
|
||||
|
||||
deleteSelected(){
|
||||
for(let i = 0; i < this.selected.length; i++){
|
||||
const sel = this.selected[i];
|
||||
if (sel.type === "connection"){
|
||||
this.removeConnection(sel.id);
|
||||
} else if (sel.type === 'node'){
|
||||
this.removeNode(sel.id);
|
||||
}
|
||||
}
|
||||
this.clearSelected();
|
||||
}
|
||||
|
||||
reset() {
|
||||
while (this.nodes.length > 0){
|
||||
const node = this.nodes[0];
|
||||
this.removeNode(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
prepareConnection(id, portId, nodeId, x, y){
|
||||
this.clearSelected();
|
||||
|
||||
const node = this.nodes.find(o => o.id === nodeId);
|
||||
if (node.canAddOutput(portId)){
|
||||
this.newConnection = new Connection(
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
x,
|
||||
y,
|
||||
null,
|
||||
null,
|
||||
portId,
|
||||
nodeId
|
||||
);
|
||||
return this.newConnection;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updateNewConnection(x,y){
|
||||
this.newConnection.updateEndPos(x, y);
|
||||
}
|
||||
|
||||
completeConnection(portId, nodeId, x, y){
|
||||
if(this.newConnection !== undefined){
|
||||
const node = this.nodes.find(o => o.id === nodeId);
|
||||
if (node.canAddInput(portId)){
|
||||
const cnn = this.newConnection;
|
||||
|
||||
cnn.pos.endX = x;
|
||||
cnn.pos.endY = y;
|
||||
cnn.inPortId = portId;
|
||||
cnn.inNodeId = nodeId;
|
||||
|
||||
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
|
||||
outNode.addOutput(cnn.outPortId, cnn);
|
||||
|
||||
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
|
||||
inNode.addInput(cnn.inPortId, cnn);
|
||||
|
||||
this.connections.push(cnn);
|
||||
|
||||
this.clearNewConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearNewConnection(){
|
||||
this.newConnection = undefined;
|
||||
}
|
||||
|
||||
toJson(){
|
||||
return JSON.stringify(this, (key, value) =>{
|
||||
if (key === "component") {
|
||||
if (value.prototype instanceof BaseNode) return value.name
|
||||
else return value;
|
||||
} else if (key === "links") {
|
||||
return undefined;
|
||||
} else{
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fromJson(json){
|
||||
this.reset();
|
||||
|
||||
const jsonObj = JSON.parse(json, (key, value) => {
|
||||
if (key === "component") {
|
||||
return CLASS_MAP[value];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
|
||||
this.id = jsonObj.id;
|
||||
this.title = jsonObj.title;
|
||||
this.loadedFromFile = true;
|
||||
|
||||
jsonObj["nodes"].forEach(o => {
|
||||
const node = this.addNode(o.id, o.title, o.component, o.left, o.top);
|
||||
node.loadedFromFile = true;
|
||||
o.inPorts.forEach( p => {
|
||||
const port = node.addInPorts(p.id, p.maxLinks);
|
||||
port.loadedFromFile = true;
|
||||
});
|
||||
o.outPorts.forEach( p => {
|
||||
const port = node.addOutPorts(p.id, p.maxLinks);
|
||||
port.loadedFromFile = true;
|
||||
});
|
||||
});
|
||||
|
||||
jsonObj["connections"].forEach( c =>{
|
||||
const paths = [];
|
||||
c.paths.forEach(p => {
|
||||
const path = new Path(p.id, p.pos.startX, p.pos.startY, p.pos.endX, p.pos.endY);
|
||||
path.loadedFromFile = true;
|
||||
paths.push(path);
|
||||
})
|
||||
|
||||
const waypoints = []
|
||||
c.waypoints.forEach(wp => {
|
||||
const waypoint = new Waypoint(wp.id, wp.pos.centerX, wp.pos.centerY, wp.pos.radius,
|
||||
wp.startPathId, wp.endPathId);
|
||||
waypoint.loadedFromFile = true;
|
||||
waypoints.push(waypoint);
|
||||
});
|
||||
|
||||
const cnn = new Connection(
|
||||
c.id, c.pos.startX, c.pos.startY, c.pos.endX, c.pos.endY,
|
||||
c.inPortId, c.inNodeId, c.outPortId, c.outNodeId, {
|
||||
paths: paths,
|
||||
waypoints: waypoints
|
||||
}
|
||||
);
|
||||
cnn.loadedFromFile = true;
|
||||
|
||||
const outNode = this.nodes.find(o => o.id === cnn.outNodeId);
|
||||
outNode.addOutput(cnn.outPortId, cnn);
|
||||
|
||||
const inNode = this.nodes.find(o => o.id === cnn.inNodeId);
|
||||
inNode.addInput(cnn.inPortId, cnn);
|
||||
|
||||
this.addConnection(cnn);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Component, useRef, useState } from "@odoo/owl";
|
||||
import { useService, useBus } from "@web/core/utils/hooks";
|
||||
|
||||
import { useDraggable } from "@node_ui/node_ui/utils";
|
||||
|
||||
export class Port extends Component {
|
||||
static template = "node_ui.port";
|
||||
static props = {
|
||||
port: Object
|
||||
};
|
||||
|
||||
setup(){
|
||||
this.rootRef = useRef("root");
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
if ((this.props.port.type === "out") && this.props.port.canAddLink()){
|
||||
const docElement = document.querySelector(".node-ui-doc");
|
||||
const docRect = docElement.getBoundingClientRect();
|
||||
const elRect = event.target.getBoundingClientRect();
|
||||
const x = ((elRect.left - docRect.left) + elRect.width / 2) / this.env.translation.zoom;
|
||||
const y = ((elRect.top - docRect.top) + elRect.height / 2) / this.env.translation.zoom;
|
||||
|
||||
this.env.bus.trigger(this.env.channel + "/out-connect", {
|
||||
id: this.props.port.id,
|
||||
nodeId: this.props.port.nodeId,
|
||||
x: x,
|
||||
y: y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
if ((this.props.port.type === "in") && this.props.port.canAddLink()){
|
||||
const docElement = document.querySelector(".node-ui-doc");
|
||||
const docRect = docElement.getBoundingClientRect();
|
||||
const elRect = event.target.getBoundingClientRect();
|
||||
const x = ((elRect.left - docRect.left) + elRect.width / 2) / this.env.translation.zoom;
|
||||
const y = ((elRect.top - docRect.top) + elRect.height / 2) / this.env.translation.zoom;
|
||||
|
||||
this.env.bus.trigger(this.env.channel + "/in-connect", {
|
||||
id: this.props.port.id,
|
||||
nodeId: this.props.port.nodeId,
|
||||
x: x,
|
||||
y: y
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDebug(ev){
|
||||
console.log("debug")
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseNode extends Component {
|
||||
static template = "node_ui.node";
|
||||
static components = { Port };
|
||||
static props = {
|
||||
node: Object
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.ui = useService("ui");
|
||||
|
||||
this.rootRef = useRef("root")
|
||||
this.position = useState({
|
||||
left: `${this.props.node.left}px`,
|
||||
top: `${this.props.node.top}px`,
|
||||
})
|
||||
|
||||
useDraggable({
|
||||
ref: this.rootRef,
|
||||
handle: ".node-title",
|
||||
elements: ".node-container",
|
||||
// @ts-ignore
|
||||
onWillStartDrag: ({ element: ctx, getRect, x, y}) => {
|
||||
const elRect = getRect(ctx);
|
||||
const docRect = getRect(ctx.closest(".node-ui-doc"));
|
||||
|
||||
const left = elRect.left - docRect.left;
|
||||
const top = elRect.top - docRect.top;
|
||||
|
||||
ctx.startLeft = left / this.env.translation.zoom;
|
||||
ctx.startTop = top / this.env.translation.zoom;
|
||||
|
||||
ctx.startPointerX = x;
|
||||
ctx.startPointerY = y;
|
||||
|
||||
ctx.lastPointerX = x;
|
||||
ctx.lastPointerY = y;
|
||||
},
|
||||
onDrag: ({ element: ctx, x, y}) => {
|
||||
const deltaX = (x - ctx.startPointerX) / this.env.translation.zoom;
|
||||
const deltaY = (y - ctx.startPointerY) / this.env.translation.zoom;
|
||||
|
||||
const left = (ctx.startLeft + deltaX);
|
||||
const top = (ctx.startTop + deltaY);
|
||||
|
||||
const trueDeltaX = (x - ctx.lastPointerX) / this.env.translation.zoom;
|
||||
const trueDeltaY = (y - ctx.lastPointerY) / this.env.translation.zoom;
|
||||
|
||||
ctx.lastPointerX = x;
|
||||
ctx.lastPointerY = y;
|
||||
|
||||
ctx.style.left = `${left}px`;
|
||||
ctx.style.top = `${top}px`;
|
||||
|
||||
this.notifyUpdate(left, top, trueDeltaX, trueDeltaY);
|
||||
},
|
||||
});
|
||||
|
||||
useBus(this.env.bus, this.env.channel + "/debug", this.onDebug.bind(this));
|
||||
}
|
||||
|
||||
notifyUpdate(left, top, deltaX=0, deltaY=0){
|
||||
const node = this.props.node;
|
||||
node.move(left, top, deltaX, deltaY);
|
||||
}
|
||||
|
||||
onClick(event){
|
||||
this.env.bus.trigger(this.env.channel + "/selected", {
|
||||
id: this.props.node.id,
|
||||
type: "node"
|
||||
});
|
||||
}
|
||||
|
||||
get contentStyle() {
|
||||
if (this.ui.isSmall) {
|
||||
return "width: 128px;"
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
onDebug(ev){
|
||||
console.log("debug");
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNode extends BaseNode {
|
||||
static template = "node_ui.dumb-node";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const inId = "in-" + this.props.node.id + "-1";
|
||||
node.addInPorts(inId, 1);
|
||||
|
||||
const outId = "out-" + this.props.node.id + "-1";
|
||||
node.addOutPorts(outId, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNodeNoInput extends BaseNode {
|
||||
static template = "node_ui.dumb-node-no-input";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const outId = "out-" + this.props.node.id + "-1";
|
||||
node.addOutPorts(outId, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNodeMultipleOutputs extends BaseNode {
|
||||
static template = "node_ui.dumb-node-multiple-outputs";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const outId1 = "out-" + this.props.node.id + "-1";
|
||||
node.addOutPorts(outId1, 1);
|
||||
|
||||
const outId2 = "out-" + this.props.node.id + "-2";
|
||||
node.addOutPorts(outId2, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNodeMultipleInputs extends BaseNode {
|
||||
static template = "node_ui.dumb-node-multiple-inputs";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const inId1 = "in-" + this.props.node.id + "-1";
|
||||
node.addInPorts(inId1, 1);
|
||||
|
||||
const inId2 = "in-" + this.props.node.id + "-2";
|
||||
node.addInPorts(inId2, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNodeNoOutput extends BaseNode {
|
||||
static template = "node_ui.dumb-node-no-output";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const inId1 = "in-" + this.props.node.id + "-1";
|
||||
node.addInPorts(inId1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DumbNodeWithTextArea extends BaseNode {
|
||||
static template = "node_ui.dumb-node-with-textarea";
|
||||
|
||||
setup(){
|
||||
super.setup();
|
||||
|
||||
const node = this.props.node;
|
||||
|
||||
if (!node.loadedFromFile){
|
||||
const outId1 = "out-" + this.props.node.id + "-1";
|
||||
node.addOutPorts(outId1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="node_ui.node">
|
||||
<div t-ref="root" tabstop="0" class="node-container" t-attf-style="left: {{ position.left }}; top: {{ position.top }};"
|
||||
t-on-click.stop.prevent="onClick">
|
||||
<div t-att-id="props.node.id" class="node">
|
||||
<div class="input-ports">
|
||||
<t t-foreach="props.node.inPorts" t-as="inPort" t-key="inPort.id" >
|
||||
<Port port="inPort"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="content overflow-hidden">
|
||||
<div class="node-title">
|
||||
<span><t t-out="props.node.title"/></span>
|
||||
</div>
|
||||
<div class="node-content">
|
||||
</div>
|
||||
<div>
|
||||
<div>Left: <t t-out="props.node.left"/></div>
|
||||
<div>Top: <t t-out="props.node.top"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="output-ports">
|
||||
<t t-foreach="props.node.outPorts" t-as="outPort" t-key="outPort.id" >
|
||||
<Port port="outPort"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.port">
|
||||
<div t-ref="root" t-att-id="props.id" t-attf-class="'{{props.port.id}}' {{props.port.type === 'out' ? 'output' : 'input'}} my-1"
|
||||
t-on-mousedown.stop.prevent="onMouseDown" t-on-mouseup.stop.prevent="onMouseUp" >
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node" t-inherit="node_ui.node" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('node-content')]" position="replace">
|
||||
<div class="node-content" t-att-style="contentStyle">
|
||||
<div class="d-block text-truncate">Id: <t t-out="props.node.id"/></div>
|
||||
</div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node-no-input" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node-multiple-outputs" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node-multiple-inputs" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node-no-output" t-inherit="node_ui.dumb-node" t-inherit-mode="primary">
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.dumb-node-with-textarea" t-inherit="node_ui.node" t-inherit-mode="primary">
|
||||
<xpath expr="//div[hasclass('node-content')]" position="inside">
|
||||
<div>Id: <t t-out="props.node.id"/></div>
|
||||
<div class="mb-2"><textarea></textarea></div>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Component, EventBus, useRef, useState, useSubEnv } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
import { Document } from "@node_ui/node_ui/document"
|
||||
import { Document as NuiDoc } from "@node_ui/node_ui/models";
|
||||
|
||||
import { uuidv4, data2blob, loadFile, useMovable, useMouseListener } from "@node_ui/node_ui/utils";
|
||||
|
||||
class NodeMenu extends Component {
|
||||
static template = "node_ui.node-menu";
|
||||
static props = {
|
||||
title: { type: String, optional: true },
|
||||
icon: { type: String, optional: true },
|
||||
action: Function,
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
|
||||
useMovable({
|
||||
ref: this.rootRef,
|
||||
elements: ".node-menu-container",
|
||||
// @ts-ignore
|
||||
onDrop: ({ x, y}) => {
|
||||
const doc = document.querySelector(".node-ui-doc");
|
||||
const docRect = doc.getBoundingClientRect();
|
||||
|
||||
const docParent = doc.parentElement;
|
||||
const docRectParent = docParent.getBoundingClientRect();
|
||||
|
||||
const docX = (x - docRect.left) / this.env.translation.zoom;
|
||||
const docY = (y - docRect.top) / this.env.translation.zoom;
|
||||
|
||||
if (this._contains(docRectParent.left, docRectParent.top, docRectParent.width, docRectParent.height, x, y)) {
|
||||
this.props.action(docX, docY);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_contains(x1, y1, w, h, x, y){
|
||||
return x1 <= x && x <= (x1 + w) && y1 <= y && y <= (y1 + h);
|
||||
}
|
||||
}
|
||||
|
||||
class NodeUi extends Component {
|
||||
static template = "node_ui.node-ui";
|
||||
static components = { Document, Dropdown, DropdownItem, NodeMenu };
|
||||
static props = {
|
||||
...standardActionServiceProps
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.actions = [
|
||||
{
|
||||
title: "Node",
|
||||
icon: "/node_ui/static/images/align-center.svg",
|
||||
action: this.addDumbNode.bind(this)
|
||||
},
|
||||
{
|
||||
title: "Node - No Input",
|
||||
icon: "/node_ui/static/images/align-start.svg",
|
||||
action: this.addDumbNodeNoInput.bind(this)
|
||||
},
|
||||
{
|
||||
title: "Node - No Output",
|
||||
icon: "/node_ui/static/images/align-end.svg",
|
||||
action: this.addDumbNodeNoOutput.bind(this)
|
||||
},
|
||||
{
|
||||
title: "Node - Text Area",
|
||||
icon: "/node_ui/static/images/textbox.svg",
|
||||
action: this.addDumbNodeWithTextArea.bind(this)
|
||||
},
|
||||
{
|
||||
title: "Node - Multi Outputs",
|
||||
icon: "/node_ui/static/images/share.svg",
|
||||
action: this.addDumbNodeMultipleOutputs.bind(this)
|
||||
},
|
||||
{
|
||||
title: "Node - Multi Inputs",
|
||||
icon: "/node_ui/static/images/share-no-fill.svg",
|
||||
action: this.addDumbNodeMultipleInputs.bind(this)
|
||||
}
|
||||
];
|
||||
|
||||
const docId = uuidv4();
|
||||
const sessionId = uuidv4();
|
||||
|
||||
const node_ui_env = {
|
||||
translation: {
|
||||
zoom_max: 2,
|
||||
zoom_min: 0.6,
|
||||
zoom_value: 0.1,
|
||||
last_zoom: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
zoom: 1
|
||||
},
|
||||
channel: "node-ui",
|
||||
bus: new EventBus(),
|
||||
documents: [new NuiDoc(docId, sessionId, docId)]
|
||||
}
|
||||
|
||||
useSubEnv(node_ui_env);
|
||||
|
||||
this.state = useState({
|
||||
currentDoc: "",
|
||||
loading: false,
|
||||
env: node_ui_env
|
||||
})
|
||||
|
||||
this.ui = useService("ui");
|
||||
|
||||
this.nodeUiRef = useRef("node-ui");
|
||||
|
||||
this.onHandleMouseDown = useMouseListener({
|
||||
onMouseMove: this.onMouseMove,
|
||||
onMouseUp: this.onMouseUp,
|
||||
});
|
||||
|
||||
this.lastPointerPos = undefined;
|
||||
this.isMoving = false;
|
||||
|
||||
this.dialog = useService("dialog");
|
||||
}
|
||||
|
||||
get documents() {
|
||||
return this.state.env.documents;
|
||||
}
|
||||
|
||||
get currentDocSessionId() {
|
||||
const currentDocId = this.state.env.documents[this.state.env.documents.length - 1].id;
|
||||
const currentSessionId = this.state.env.documents[this.state.env.documents.length - 1].sessionId;
|
||||
return currentDocId + "-" + currentSessionId;
|
||||
}
|
||||
|
||||
saveDoc(){
|
||||
const jsonDoc = this.state.env.documents[0].toJson();
|
||||
// @ts-ignore
|
||||
saveAs(data2blob(jsonDoc), "doc.txt" );
|
||||
}
|
||||
|
||||
openFile(file){
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const docId = uuidv4();
|
||||
const sessionId = uuidv4();
|
||||
const newDoc = new NuiDoc(docId, sessionId, docId);
|
||||
newDoc.fromJson(reader.result);
|
||||
this.state.env.documents.push(newDoc);
|
||||
setTimeout(() => {
|
||||
this.state.env.documents.shift();
|
||||
}, 20); // give time before destroying component
|
||||
});
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async openDoc(){
|
||||
this.zoom_reset();
|
||||
this.state.loading = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
const file = await loadFile();
|
||||
if (file != undefined && file != false) {
|
||||
this.openFile(file)
|
||||
};
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
addDumbNode(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb", x: x, y: y });
|
||||
}
|
||||
|
||||
addDumbNodeNoInput(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-no-input", x: x, y: y });
|
||||
}
|
||||
|
||||
addDumbNodeNoOutput(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-no-output", x: x, y: y });
|
||||
}
|
||||
|
||||
addDumbNodeWithTextArea(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-textarea", x: x, y: y });
|
||||
}
|
||||
|
||||
addDumbNodeMultipleOutputs(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-multiple-outputs", x: x, y: y });
|
||||
}
|
||||
|
||||
addDumbNodeMultipleInputs(x, y) {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/new", { type: "dumb-multiple-inputs", x: x, y: y });
|
||||
}
|
||||
|
||||
deleteSelected() {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/delete");
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/reset");
|
||||
}
|
||||
|
||||
onDoubleClick(){
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/clear-selected");
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
if (event.ctrlKey){
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (this.lastPointerPos){
|
||||
if (this.isMoving || Math.hypot(event.x - this.lastPointerPos.x, event.y - this.lastPointerPos.y) >= 20){
|
||||
document.documentElement.style.cursor = "move";
|
||||
this.state.env.translation.translateX = this.state.env.translation.translateX + event.movementX;
|
||||
this.state.env.translation.translateY = this.state.env.translation.translateY + event.movementY;
|
||||
|
||||
const doc = document.querySelector(".node-ui-doc");
|
||||
// @ts-ignore
|
||||
doc.style.transform =
|
||||
"translate(" +
|
||||
this.state.env.translation.translateX +
|
||||
"px, " +
|
||||
this.state.env.translation.translateY +
|
||||
"px) scale(" +
|
||||
this.state.env.translation.zoom +
|
||||
")";
|
||||
|
||||
this._notifyZoomChanged();
|
||||
this.isMoving = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.lastPointerPos = {
|
||||
x: event.x,
|
||||
y: event.y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
document.documentElement.style.cursor = "default";
|
||||
this.lastPointerPos = undefined;
|
||||
this.isMoving = false;
|
||||
}
|
||||
|
||||
_notifyZoomChanged(){
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/zoom", {
|
||||
translateX: this.state.env.translation.translateX,
|
||||
translateY: this.state.env.translation.translateY,
|
||||
zoom: this.state.env.translation.zoom,
|
||||
});
|
||||
}
|
||||
|
||||
onWheel(event){
|
||||
if (event.ctrlKey) {
|
||||
if (event.deltaY > 0) {
|
||||
this.zoom_out();
|
||||
} else {
|
||||
this.zoom_in();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zoom_refresh() {
|
||||
this.state.env.translation.translateX = (this.state.env.translation.translateX / this.state.env.translation.last_zoom)
|
||||
* this.state.env.translation.zoom;
|
||||
this.state.env.translation.translateY = (this.state.env.translation.translateY / this.state.env.translation.last_zoom)
|
||||
* this.state.env.translation.zoom;
|
||||
this.state.env.translation.last_zoom = this.state.env.translation.zoom;
|
||||
const doc = document.querySelector(".node-ui-doc");
|
||||
// @ts-ignore
|
||||
doc.style.transform =
|
||||
"translate(" +
|
||||
this.state.env.translation.translateX +
|
||||
"px, " +
|
||||
this.state.env.translation.translateY +
|
||||
"px) scale(" +
|
||||
this.state.env.translation.zoom +
|
||||
")";
|
||||
|
||||
this._notifyZoomChanged();
|
||||
}
|
||||
|
||||
zoom_in() {
|
||||
if (this.state.env.translation.zoom < this.state.env.translation.zoom_max) {
|
||||
this.state.env.translation.zoom += this.state.env.translation.zoom_value;
|
||||
this.zoom_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
zoom_out() {
|
||||
if (this.state.env.translation.zoom > this.state.env.translation.zoom_min) {
|
||||
this.state.env.translation.zoom -= this.state.env.translation.zoom_value;
|
||||
this.zoom_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
zoom_reset() {
|
||||
if (this.state.env.translation.zoom != 1 || this.state.env.translation.translateX != 0 || this.state.env.translation.translateY != 0) {
|
||||
this.state.env.translation.translateX = 0;
|
||||
this.state.env.translation.translateY = 0;
|
||||
this.state.env.translation.zoom = 1;
|
||||
this.zoom_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
get zoom(){
|
||||
// @ts-ignore
|
||||
return Math.round(this.state.env.translation.zoom + "e+2");
|
||||
}
|
||||
|
||||
get nodeMenuStyle() {
|
||||
if (this.ui.isSmall){
|
||||
return "width: 60px;";
|
||||
} else {
|
||||
return "width: 225px;";
|
||||
}
|
||||
}
|
||||
|
||||
debug() {
|
||||
this.state.env.bus.trigger(this.state.env.channel + "/debug");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("NodeUi", NodeUi);
|
||||
@@ -0,0 +1,169 @@
|
||||
$nui-background: $gray-900;
|
||||
$nui-background-color: $nui-background;
|
||||
|
||||
$nui-node-color: $gray-500;
|
||||
$nui-node-border-color: $gray-500;
|
||||
$nui-node-background-color: $gray-800;
|
||||
$nui-node-title-bottom-border-color: $gray-600;
|
||||
$nui-node-selected-border-color: $primary;
|
||||
|
||||
$nui-input-background-color: $nui-node-background-color;
|
||||
$nui-input-border-color: $nui-node-border-color;
|
||||
|
||||
$nui-output-background-color: $nui-node-background-color;
|
||||
$nui-output-border-color: $nui-node-border-color;
|
||||
|
||||
$nui-path-color: $gray-500;
|
||||
$nui-path-selected-color: $primary;
|
||||
|
||||
$nui-point-border-color: $nui-node-border-color;
|
||||
$nui-point-background-color: $gray-500;
|
||||
|
||||
.node-ui {
|
||||
background-color: $nui-background-color;
|
||||
}
|
||||
|
||||
.node-ui-doc-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
|
||||
.node-ui-doc {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
perspective: 0;
|
||||
// border: 1px dashed red;
|
||||
|
||||
.node-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
background: $nui-node-background-color;
|
||||
min-width: 160px;
|
||||
min-height: 40px;
|
||||
border-radius: 4px;
|
||||
border: 3px solid $nui-node-border-color;
|
||||
color: $nui-node-color;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
|
||||
.content {
|
||||
.node-title {
|
||||
padding-bottom: 3px;
|
||||
border-bottom: $nui-node-title-bottom-border-color solid 1px;
|
||||
|
||||
&:hover {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
|
||||
.node-content {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-ports {
|
||||
width: 0px;
|
||||
|
||||
.input {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: $nui-node-background-color;
|
||||
border: 3px solid $nui-node-border-color;
|
||||
border-right: 0px;
|
||||
border-radius: 4px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
cursor: crosshair;
|
||||
z-index: 1;
|
||||
left: -30px;
|
||||
}
|
||||
}
|
||||
|
||||
.output-ports {
|
||||
width: 0px;
|
||||
|
||||
.output {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: $nui-node-background-color;
|
||||
border: 3px solid $nui-node-border-color;
|
||||
border-radius: 4px;
|
||||
border-left: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
cursor: crosshair;
|
||||
z-index: 1;
|
||||
right: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
border-color: $nui-node-selected-border-color;
|
||||
|
||||
.output-ports {
|
||||
.output {
|
||||
border-color: $nui-node-selected-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.input-ports {
|
||||
.input {
|
||||
border-color: $nui-node-selected-border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg.connection {
|
||||
z-index: 0;
|
||||
overflow: visible !important;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
.path {
|
||||
fill: none;
|
||||
stroke-width: 3px;
|
||||
stroke: $nui-path-color;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.path.selected {
|
||||
stroke: $nui-path-selected-color
|
||||
}
|
||||
|
||||
.point {
|
||||
cursor: move;
|
||||
stroke: $nui-point-border-color;
|
||||
stroke-width: 2;
|
||||
fill: $nui-point-background-color;
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="node_ui.node-ui">
|
||||
<div class="d-flex h-100 overflow-hidden" style="min-height:-webkit-fill-available;">
|
||||
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark" t-att-style="nodeMenuStyle">
|
||||
<ul class="nav nav-pills flex-column mb-auto">
|
||||
<t t-foreach="actions" t-as="action" t-key="action_index">
|
||||
<li class="nav-item">
|
||||
<NodeMenu action="action.action" title="action.title" icon="action.icon"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex flex-column w-100 h-100">
|
||||
<div class="o-control-panel container-fluid d-flex align-items-center justify-content-between">
|
||||
<div class="btn-toolbar w-100 w-md-auto my-2 d-flex" role="toolbar">
|
||||
<div class="btn-group me-2">
|
||||
<button id="saveDoc" type="button" class="btn btn-secondary" t-on-click="saveDoc">
|
||||
<i class="fa fa-floppy-o fa-lg align-self-start me-2"></i>
|
||||
<span class="d-none d-md-inline">Save</span>
|
||||
</button>
|
||||
<button id="openDoc" type="button" class="btn btn-secondary" t-on-click="openDoc">
|
||||
<i class="fa fa-folder-open-o fa-lg align-self-start me-2"></i>
|
||||
<span class="d-none d-md-inline">Open</span>
|
||||
</button>
|
||||
</div>
|
||||
<Dropdown>
|
||||
<button class="btn btn-secondary me-2">
|
||||
<i class="fa fa-plus fa-lg align-self-start me-2"></i>
|
||||
<span class="d-none d-md-inline">Add</span>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="actions" t-as="action" t-key="action_index">
|
||||
<DropdownItem onSelected="() => { action.action(50, 50) }">
|
||||
<img class="menu-icon me-2" width="24" height="24" t-att-src="action.icon"></img>
|
||||
<t t-out="action.title"/>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<div class="btn-group me-2">
|
||||
<button id="deleteSelected" type="button" class="btn btn-secondary" t-on-click="deleteSelected">
|
||||
<i class="fa fa-minus fa-lg align-self-start me-2"></i>
|
||||
<span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
<button id="reset" type="button" class="btn btn-secondary" t-on-click="reset">
|
||||
<i class="fa fa-refresh fa-lg align-self-start me-2"></i>
|
||||
<span class="d-none d-md-inline">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="debug" type="button" class="btn me-2" t-on-click="debug">
|
||||
<i class="fa fa-bug me-2 " />
|
||||
<span class="d-none d-md-inline">Debug</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex flex-fill my-auto mb-2">
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<button id="debug" type="button" class="btn me-2" t-on-click="zoom_reset">
|
||||
Zoom
|
||||
</button>
|
||||
<span class="text-end" style="width: 20px"><t t-out="zoom"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div t-ref="node-ui" class="node-ui w-100 h-100">
|
||||
<t t-foreach="documents" t-as="doc" t-key="doc_index">
|
||||
<t t-if="doc.id + '-' + doc.sessionId == currentDocSessionId">
|
||||
<div class="node-ui-doc-container h-100 w-100 p-10" t-on-wheel.prevent="onWheel"
|
||||
t-on-mousedown="onHandleMouseDown" t-on-dblclick="onDoubleClick" >
|
||||
<Document document="doc" />
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="node_ui.node-menu">
|
||||
<div t-ref="root" class="node-menu w-100 my-2">
|
||||
<div class="node-menu-container text-start">
|
||||
<t t-if="props.icon">
|
||||
<img class="menu-icon me-2" width="24" height="24" t-att-src="props.icon"></img>
|
||||
</t>
|
||||
<span class="d-none d-md-inline"><t t-out="props.title"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
useState,
|
||||
onWillDestroy,
|
||||
onWillUnmount,
|
||||
useComponent,
|
||||
useEffect,
|
||||
reactive
|
||||
} from "@odoo/owl";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
/*
|
||||
* comes from o_spreadsheet.js
|
||||
* https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
|
||||
* */
|
||||
export function uuidv4() {
|
||||
// mainly for jest and other browsers that do not have the crypto functionality
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
||||
/[xy]/g,
|
||||
function (c) {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function createImperativeHandle() {
|
||||
return { current: null };
|
||||
}
|
||||
|
||||
export function useImperativeHandle(value) {
|
||||
const component = useComponent();
|
||||
useEffect(() => {
|
||||
if (component.props.handle) {
|
||||
component.props.handle.current = value;
|
||||
return () => {
|
||||
component.props.handle.current = null;
|
||||
};
|
||||
} else {
|
||||
return () => {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useMousePosition() {
|
||||
const position = useState({ x: 0, y: 0 });
|
||||
|
||||
function update(e) {
|
||||
position.x = e.clientX;
|
||||
position.y = e.clientY;
|
||||
}
|
||||
window.addEventListener("mousemove", update);
|
||||
|
||||
onWillDestroy(() => {
|
||||
window.removeEventListener("mousemove", update);
|
||||
});
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
export function useMouseListener(options) {
|
||||
const component = useComponent();
|
||||
|
||||
options.onMouseUp = (options.onMouseUp || (() => {})).bind(component);
|
||||
options.onMouseDown = (options.onMouseDown || (() => {})).bind(component);
|
||||
|
||||
const onMouseMove = useDebounced(
|
||||
options.onMouseMove || (() => {}),
|
||||
"animationFrame"
|
||||
);
|
||||
|
||||
const onMouseUp = (event) => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
onMouseMove.cancel(true);
|
||||
options.onMouseUp(event);
|
||||
};
|
||||
|
||||
onWillUnmount(() => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
return (event) => {
|
||||
options.onMouseDown(event);
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp, { once: true });
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export const useDraggable = makeDraggableHook({
|
||||
name: "useDraggable",
|
||||
onComputeParams({ ctx }) {
|
||||
ctx.followCursor = false;
|
||||
},
|
||||
onWillStartDrag: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDragStart: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDrag: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDrop: ({ ctx }) => pick(ctx.current, "element"),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
export const useMovable = makeDraggableHook({
|
||||
name: "useMovable",
|
||||
onWillStartDrag: ({ ctx, addCleanup, addStyle, addClass }) => {
|
||||
addClass(ctx.current.element, "dragging");
|
||||
|
||||
ctx.current.container = document.createElement("div");
|
||||
addStyle(ctx.current.container, {
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
ctx.current.element.after(ctx.current.container);
|
||||
addCleanup(() => {
|
||||
ctx.current.container.remove();
|
||||
});
|
||||
return { ctx };
|
||||
},
|
||||
onDragStart: ({ ctx, addCleanup, addClass }) => {
|
||||
// @ts-ignore
|
||||
ctx.current.placeholder = document.createElement("div");
|
||||
// @ts-ignore
|
||||
addClass(ctx.current.placeholder, "dragged-placeholder");
|
||||
// @ts-ignore
|
||||
ctx.current.element.before(ctx.current.placeholder);
|
||||
addCleanup(() => {
|
||||
// @ts-ignore
|
||||
ctx.current.placeholder.remove();
|
||||
});
|
||||
},
|
||||
onDrop: ({ ctx, getRect }) => {
|
||||
const { top, left } = getRect(ctx.current.element);
|
||||
return { top, left };
|
||||
},
|
||||
});
|
||||
|
||||
export function createDiv(l, t, w, h, c) {
|
||||
const el = document.createElement("div");
|
||||
|
||||
el.style.position = "fixed";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.background = c;
|
||||
|
||||
return document.body.appendChild(el);
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/29650941
|
||||
export function data2blob(data, isBase64) {
|
||||
var chars = "";
|
||||
|
||||
if (isBase64) chars = atob(data);
|
||||
else chars = data;
|
||||
|
||||
var bytes = new Array(chars.length);
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
bytes[i] = chars.charCodeAt(i);
|
||||
}
|
||||
|
||||
var blob = new Blob([new Uint8Array(bytes)]);
|
||||
return blob;
|
||||
}
|
||||
|
||||
export function loadFile(){
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.setAttribute("type", "file");
|
||||
input.setAttribute("accept", "text/*");
|
||||
input.addEventListener("change", async () => {
|
||||
if (input.files === null || input.files.length != 1) {
|
||||
resolve(false);
|
||||
}
|
||||
else {
|
||||
resolve(input.files[0]);
|
||||
}
|
||||
});
|
||||
input.addEventListener("cancel", async () => {
|
||||
resolve(false);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export function makeReactive(owner, initialState, options = {}) {
|
||||
const handler = (options.onChangedHandler || (() => {}));
|
||||
// @ts-ignore
|
||||
const reactiveState = reactive(initialState, () => handler(owner, reactiveState));
|
||||
handler(owner, reactiveState);
|
||||
return reactive(initialState);
|
||||
}
|
||||
|
||||
export function removeItem(array, id) {
|
||||
const idx = array.findIndex(o => o.id === id);
|
||||
if (idx > -1) {
|
||||
const removed = array.splice(idx, 1);
|
||||
return removed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<record model="ir.actions.client" id="node_ui">
|
||||
<field name="name">Node UI</field>
|
||||
<field name="tag">NodeUi</field>
|
||||
</record>
|
||||
|
||||
<menuitem name="Node UI" id="node_ui.menu_root" />
|
||||
|
||||
<menuitem name="Node UI" id="node_ui.node_ui_menu"
|
||||
parent="node_ui.menu_root" action="node_ui" sequence="1"/>
|
||||
</data>
|
||||
</odoo>
|
||||