This commit is contained in:
2025-08-19 15:05:55 +07:00
parent 70ae66ee4b
commit f78dc34aee
161 changed files with 13703 additions and 0 deletions
+21
View File
@@ -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.
Contains the underlying basic concepts to create Node based UI.
Please watch these videos for more details:
[![EXPLORING_ODOO](https://img.youtube.com/vi/sMDIly3bddo/0.jpg)](https://youtu.be/sMDIly3bddo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/iFPyQjJ2Uyw/0.jpg)](https://youtu.be/iFPyQjJ2Uyw)
[![EXPLORING_ODOO](https://img.youtube.com/vi/knC4BaGbWGo/0.jpg)](https://youtu.be/knC4BaGbWGo)
[![EXPLORING_ODOO](https://img.youtube.com/vi/v9TKiaKA_SE/0.jpg)](https://youtu.be/v9TKiaKA_SE)
View File
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': "Node UI Basics",
'summary': """Node UI Basics""",
'description': """
Tech stacks for developing Node UI
""",
'author': "Yoni Tjio",
'category': 'Productivity',
'version': '18.0.1.0.0',
'depends': ['web'],
'data': [
'views/node_ui_basics_views.xml',
],
'assets': {
"web.assets_backend": [
"node_ui_basics/static/src/**/*",
],
},
"license":"Other proprietary",
"application": True,
"installable": True,
"auto_install": False
}
+242
View File
@@ -0,0 +1,242 @@
import { Component, useRef, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasBasics extends Component {
static template = "canvas-basics";
static components = { };
static props = {
...standardActionServiceProps
};
setup() {
this.canvasRef = useRef("interactive-canvas");
this.state = {
startX: 100,
startY: 50,
endX: 1550,
endY: 150,
controlX: 700,
controlY: 300,
};
onMounted(() => {
this._drawShapes();
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawInteractiveCanvas();
})
this.dragging = undefined;
}
onMouseDown(event){
event.preventDefault();
event.stopPropagation();
const cRect = this.canvasRef.el.getBoundingClientRect();
const ctx = this.canvasRef.el.getContext("2d");
const x = event.clientX - cRect.left;
const y = event.clientY - cRect.top;
if (ctx.isPointInPath(this.startPoint, x, y)){
this.dragging = this.startPoint;
} else if (ctx.isPointInPath(this.endPoint, x, y)){
this.dragging = this.endPoint;
} else if (ctx.isPointInPath(this.controlPoint, x, y)){
this.dragging = this.controlPoint;
} else {
this.dragging = undefined;
}
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (!this.dragging){
const cRect = this.canvasRef.el.getBoundingClientRect();
const ctx = this.canvasRef.el.getContext("2d");
const x = event.clientX - cRect.left;
const y = event.clientY - cRect.top;
if (ctx.isPointInPath(this.startPoint, x, y)){
document.body.style.cursor = "pointer";
} else if (ctx.isPointInPath(this.endPoint, x, y)){
document.body.style.cursor = "pointer";
} else if (ctx.isPointInPath(this.controlPoint, x, y)){
document.body.style.cursor = "pointer";
} else {
document.body.style.cursor = "auto";
}
}else if (this.dragging){
const cRect = this.canvasRef.el.getBoundingClientRect();
const el = this.dragging;
if (event.x > cRect.left + 5 && event.y > cRect.top + 5
&& event.x < cRect.right - 20 && event.y < cRect.bottom - 20){
let dragging;
if(el == this.startPoint){
this.state.startX += event.movementX;
this.state.startY += event.movementY;
dragging = "s";
} else if (el == this.endPoint){
this.state.endX += event.movementX;
this.state.endY += event.movementY;
dragging = "e";
} else if (el == this.controlPoint){
this.state.controlX += event.movementX;
this.state.controlY += event.movementY;
dragging = "c"
} else {
return;
}
this._drawInteractiveCanvas();
if (dragging === "s") {
this.dragging = this.startPoint;
} else if (dragging === "e"){
this.dragging = this.endPoint;
} else if (dragging === "c"){
this.dragging = this.controlPoint;
}
}
}
}
_drawInteractiveCanvas(){
let ctx = this.canvasRef.el.getContext("2d");
this.startPoint = new Path2D();
this.endPoint = new Path2D();
this.controlPoint = new Path2D();
this.mainPath = new Path2D();
this.controlPath = new Path2D();
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.fillStyle = "#b58900";
let path = this.startPoint;
path.arc(this.state.startX, this.state.startY, 6, 0, 2 * Math.PI);
ctx.strokeStyle = "grey";
ctx.fill(path);
ctx.stroke(path);
path = this.mainPath;
path.moveTo(this.state.startX, this.state.startY);
path.quadraticCurveTo(this.state.controlX, this.state.controlY, this.state.endX, this.state.endY);
ctx.strokeStyle = "#b58900";
ctx.stroke(path);
path = this.endPoint;
path.arc(this.state.endX, this.state.endY, 6, 0, 2 * Math.PI);
ctx.strokeStyle = "grey";
ctx.fill(path);
ctx.stroke(path);
path = this.controlPoint;
path.arc(this.state.controlX, this.state.controlY, 6, 0, 2 * Math.PI);
ctx.fill(path);
ctx.stroke(path);
path = this.controlPath;
path.moveTo(this.state.startX, this.state.startY);
path.lineTo(this.state.controlX, this.state.controlY);
path.moveTo(this.state.controlX, this.state.controlY);
path.lineTo(this.state.endX, this.state.endY);
ctx.lineWidth = 1;
ctx.stroke(path);
}
_drawShapes(){
let cvs = document.getElementById("cvsRectangle");
let ctx = cvs.getContext("2d");
ctx.fillStyle = "#b58900";
ctx.fillRect(20, 20, 260, 100);
cvs = document.getElementById("cvsCircle");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.arc(150, 72.5, 50, 0, 2 * Math.PI);
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsEllipse");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.ellipse(150, 72.5, 100, 50, 0, 0, 2 * Math.PI)
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsLine");
ctx = cvs.getContext("2d");
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(30, 62.5);
ctx.lineTo(270, 62.5);
ctx.moveTo(30, 72.5);
ctx.lineTo(270, 72.5);
ctx.moveTo(30, 82.5);
ctx.lineTo(270, 82.5);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsRoundRect");
ctx = cvs.getContext("2d");
ctx.beginPath();
ctx.roundRect(20, 20, 260, 100, [10]);
ctx.fillStyle = "#b58900";
ctx.fill();
ctx.strokeStyle = "grey";
ctx.stroke();
cvs = document.getElementById("cvsCubicCurve");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
let start = { x: 20, y: 20 };
let cp1 = { x: 200, y: 50 };
let cp2 = { x: 50, y: 80 };
let end = { x: 270, y: 120 };
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsQuadraticCurve");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.quadraticCurveTo(20, 120, 270, 120);
ctx.strokeStyle = "#b58900";
ctx.stroke();
cvs = document.getElementById("cvsMiscShape");
ctx = cvs.getContext("2d");
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(20, 20);
ctx.lineTo(270, 20);
ctx.lineTo(135, 120);
ctx.closePath();
ctx.strokeStyle = "#b58900";
ctx.stroke();
}
}
registry.category("actions").add("canvas_basics", CanvasBasics);
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-basics">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Rectangle
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsRectangle">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Circle
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsCircle">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Ellipse
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsEllipse">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Line
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsLine">
</canvas>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Round Rect
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsRoundRect">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Cubic Bézier Curve
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsCubicCurve">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Quadratic Bézier Curved
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsQuadraticCurve">
</canvas>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Misc. Shape
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsMiscShape">
</canvas>
</div>
</div>
</div>
</div>
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Interactive Curve
</div>
<div class="card-body p-1 overflow-hidden">
<canvas id="cvsInteractivePath"
t-ref="interactive-canvas"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove"
>
</canvas>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,134 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasConnection extends Component {
static template = "canvas-connection";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.canvasRef = useRef("canvas");
const startX = 600;
const startY = 400;
const midX = 800;
const midY = 100;
const endX = 1000;
const endY = 400;
const rad = 25;
this.state = useState({
orientation: "auto",
startNode: {
cx: startX,
cy: startY,
r: rad,
},
midNode: {
cx: midX,
cy: midY,
r: rad,
},
endNode: {
cx: endX,
cy: endY,
r: rad,
},
});
onMounted(() => {
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawCanvas();
});
}
onMouseDown(event) {
if (event.target.id === "startNode" || event.target.id === "midNode" || event.target.id === "endNode") {
this.dragging = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.canvasRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
if (this.dragging === "startNode") {
this.state.startNode.cx += event.movementX;
this.state.startNode.cy += event.movementY;
} else if (this.dragging === "midNode") {
this.state.midNode.cx += event.movementX;
this.state.midNode.cy += event.movementY;
} else if (this.dragging === "endNode") {
this.state.endNode.cx += event.movementX;
this.state.endNode.cy += event.movementY;
}
}
this._drawCanvas();
}
}
_drawCanvas() {
const ctx = this.canvasRef.el.getContext("2d");
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.strokeStyle = "#b58900";
ctx.fillStyle = "#b58900";
let path1 = new Path2D();
let path2 = new Path2D();
this._drawPath(path1, this.state.startNode, this.state.midNode, this.state.orientation);
this._drawPath(path2, this.state.midNode, this.state.endNode, this.state.orientation);
ctx.stroke(path1);
ctx.stroke(path2);
}
_drawPath(path, firstNode, secondNode, orient = "auto") {
let hDistance = Math.abs(firstNode.cx - secondNode.cx);
let vDistance = Math.abs(firstNode.cy - secondNode.cy);
let orientation = "v";
if (orient == "auto"){
orientation = hDistance > vDistance ? "v" : "h";
} else {
orientation = orient === "vertical" ? "v" : "h"
}
let firstX = firstNode.cx;
let firstY = firstNode.cy;
let secondX = secondNode.cx;
let secondY = secondNode.cy;
let midX = (secondX + firstX) / 2;
let midY = (secondY + firstY) / 2;
if (orientation === "v") {
path.moveTo(firstX, firstY);
path.lineTo(firstX, midY);
path.lineTo(secondX, midY);
path.lineTo(secondX, secondY);
} else {
path.moveTo(firstX, firstY);
path.lineTo(midX, firstY);
path.lineTo(midX, secondY);
path.lineTo(secondX, secondY);
}
}
}
registry.category("actions").add("canvas_connection", CanvasConnection);
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-connection">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Canvas Connection
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<canvas id="canvas"
t-ref="canvas"
style="left: 0px; top: 0px; position: absolute; z-index: -1000;"/>
<div id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<div id="startNode" class="circle"
t-attf-style="left:{{state.startNode.cx - state.startNode.r}}px;
top:{{state.startNode.cy - state.startNode.r}}px;">
</div>
<div id="midNode" class="circle"
t-attf-style="left:{{state.midNode.cx - state.midNode.r}}px;
top:{{state.midNode.cy - state.midNode.r}}px;">
</div>
<div id="endNode" class="circle"
t-attf-style="left:{{state.endNode.cx - state.endNode.r}}px;
top:{{state.endNode.cy - state.endNode.r}}px;">
</div>
</div>
<div class="btn-group" role="group" style="top:10px; right:10px; position: absolute;">
<input id="optOrientationVertical"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="vertical"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationVertical">Vertical</label>
<input id="optOrientationHorizontal"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="horizontal"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationHorizontal">Horizontal</label>
<input id="optOrientationAuto"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="auto"
t-model="state.orientation"
t-on-change="_drawCanvas">
</input>
<label class="btn btn-primary" for="optOrientationAuto">Auto</label>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+171
View File
@@ -0,0 +1,171 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class CanvasKonva extends Component {
static template = "canvas-konva";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.konvaRef = useRef("konva");
onWillStart(async () => {
await import("/node_ui_basics/static/lib/konva.js");
});
onMounted(() => {
var width = window.innerWidth;
var height = window.innerHeight;
// function to build anchor point
function buildAnchor(x, y) {
var anchor = new Konva.Circle({
x: x,
y: y,
radius: 6,
stroke: "grey",
fill: "#b58900",
strokeWidth: 2,
draggable: true,
});
layer.add(anchor);
// add hover styling
anchor.on("mouseover", function () {
document.body.style.cursor = "pointer";
this.strokeWidth(4);
});
anchor.on("mouseout", function () {
document.body.style.cursor = "default";
this.strokeWidth(2);
});
anchor.on("dragmove", function () {
updateDottedLines();
});
return anchor;
}
var stage = new Konva.Stage({
container: "konva-container",
width: width,
height: height,
});
var layer = new Konva.Layer();
stage.add(layer);
// function to update line points from anchors
function updateDottedLines() {
var q = quad;
var b = bezier;
var quadLinePath = layer.findOne("#quadLinePath");
var bezierLinePath = layer.findOne("#bezierLinePath");
quadLinePath.points([
q.start.x(),
q.start.y(),
q.control.x(),
q.control.y(),
q.end.x(),
q.end.y(),
]);
bezierLinePath.points([
b.start.x(),
b.start.y(),
b.control1.x(),
b.control1.y(),
b.control2.x(),
b.control2.y(),
b.end.x(),
b.end.y(),
]);
}
// we will use custom shape for curve
var quadraticLine = new Konva.Shape({
stroke: "pink",
strokeWidth: 4,
sceneFunc: (ctx, shape) => {
ctx.beginPath();
ctx.moveTo(quad.start.x(), quad.start.y());
ctx.quadraticCurveTo(
quad.control.x(),
quad.control.y(),
quad.end.x(),
quad.end.y()
);
ctx.fillStrokeShape(shape);
},
});
layer.add(quadraticLine);
// we will use custom shape for curve
var bezierLine = new Konva.Shape({
stroke: "#b58900",
strokeWidth: 5,
sceneFunc: (ctx, shape) => {
ctx.beginPath();
ctx.moveTo(bezier.start.x(), bezier.start.y());
ctx.bezierCurveTo(
bezier.control1.x(),
bezier.control1.y(),
bezier.control2.x(),
bezier.control2.y(),
bezier.end.x(),
bezier.end.y()
);
ctx.fillStrokeShape(shape);
},
});
layer.add(bezierLine);
var quadLinePath = new Konva.Line({
dash: [10, 10, 0, 10],
strokeWidth: 3,
stroke: "grey",
lineCap: "round",
id: "quadLinePath",
opacity: 0.3,
points: [0, 0],
});
layer.add(quadLinePath);
var bezierLinePath = new Konva.Line({
dash: [10, 10, 0, 10],
strokeWidth: 3,
stroke: "grey",
lineCap: "round",
id: "bezierLinePath",
opacity: 0.3,
points: [0, 0],
});
layer.add(bezierLinePath);
// special objects to save references to anchors
var quad = {
start: buildAnchor(60, 30),
control: buildAnchor(240, 500),
end: buildAnchor(100, 600),
};
var bezier = {
start: buildAnchor(280, 20),
control1: buildAnchor(530, 500),
control2: buildAnchor(1250, 150),
end: buildAnchor(1580, 600),
};
updateDottedLines();
});
}
}
registry.category("actions").add("canvas_konva", CanvasKonva);
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-konva">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Konva
</div>
<div id="konva-container" class="card-body p-1 overflow-hidden" t-ref="konva">
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+148
View File
@@ -0,0 +1,148 @@
import { Component, useRef, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { uuidv4 } from "@node_ui_basics/utils/utils";
class CanvasNodes extends Component {
static template = "canvas-nodes";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.canvasRef = useRef("canvas");
this.state = useState({
nodes: [],
selected: undefined,
});
onMounted(() => {
this.canvasRef.el.width = this.canvasRef.el.parentElement.offsetWidth;
this.canvasRef.el.height = this.canvasRef.el.parentElement.offsetHeight;
this._drawCanvas();
});
}
onAddButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 50,
cy: 50,
r: 25,
});
this._drawCanvas();
}
onAdd7NodesButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 1000,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 100,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 600,
r: 25,
},{
id: uuidv4(),
cx: 1000,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 325,
r: 25,
}
);
this._drawCanvas();
}
onRemoveButtonClick(event) {
if (this.state.selected){
let nodeIdx = this.state.nodes.findIndex(o => o.id === this.state.selected);
if (nodeIdx > -1){
this.state.nodes.splice(nodeIdx, 1);
this._drawCanvas();
}
this.state.selected = undefined;
}
}
onNodeSelected(event){
this.state.selected = event.target.id;
}
onMouseDown(event) {
if (event.target.classList.contains("node")) {
this.dragging = event.target.id;
this.selected = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.canvasRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
const node = this.state.nodes.find((o) => o.id == this.dragging);
if (node){
node.cx += event.movementX;
node.cy += event.movementY;
}
}
this._drawCanvas();
}
}
_drawCanvas() {
if (this.state.nodes.length > 0) {
const nodes = this.state.nodes;
const ctx = this.canvasRef.el.getContext("2d");
ctx.clearRect(0, 0, this.canvasRef.el.width, this.canvasRef.el.height);
ctx.beginPath();
ctx.lineWidth = 3;
ctx.strokeStyle = "#b58900";
ctx.fillStyle = "#b58900";
let path = new Path2D();
path.moveTo(nodes[0].cx, nodes[0].cy);
for (let i = 1; i < this.state.nodes.length; i++) {
path.lineTo(nodes[i].cx, nodes[i].cy);
}
ctx.stroke(path);
}
}
}
registry.category("actions").add("canvas_nodes", CanvasNodes);
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="canvas-nodes">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Canvas With DIV Nodes
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<canvas id="canvas" t-ref="canvas"
style="left: 0px; top: 0px; position: absolute; z-index: -1000;"/>
<div id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="node circle"
t-att-id="node.id"
t-attf-style="left:{{node.cx - node.r}}px;
top:{{node.cy - node.r}}px;
border-color: {{ node.id === state.selected ? '#5f5' : '#999'}}
"
t-on-mousedown.prevent="onNodeSelected">
</div>
</t>
</div>
<div class="btn-group" style="top:10px; right:10px; position: absolute;">
<button class="btn btn-primary my-0" t-on-click="onAddButtonClick">
Add
</button>
<button class="btn btn-primary my-0" t-on-click="onAdd7NodesButtonClick">
Add Seven Nodes
</button>
<button class="btn btn-primary my-0" t-on-click="onRemoveButtonClick">
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+49
View File
@@ -0,0 +1,49 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class MovableDiv extends Component {
static template = "movable-div";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
this.state = useState({
cdLeft: 800,
cdTop: 100,
cLeft: 700,
cTop: 100
});
this.dragging = undefined;
}
onMouseDown(event){
if (event.target.classList.contains("diamond")){
this.dragging = "cd";
} else if (event.target.classList.contains("circle")){
this.dragging = "c";
}
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (this.dragging === "cd"){
this.state.cdLeft += event.movementX;
this.state.cdTop += event.movementY;
} else if (this.dragging === "c"){
this.state.cLeft += event.movementX;
this.state.cTop += event.movementY;
}
}
}
registry.category("actions").add("movable_div", MovableDiv);
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="movable-div">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Movable DIV
</div>
<div class="card-body p-1 overflow-hidden" t-ref="container"
t-on-mousedown="onMouseDown" t-on-mousemove="onMouseMove" t-on-mouseup="onMouseUp">
<div class="diamond" t-attf-style="left:{{state.cdLeft}}px; top:{{state.cdTop}}px;">
</div>
<div class="circle" t-attf-style="left:{{state.cLeft}}px; top:{{state.cTop}}px;">
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,68 @@
.diamond {
position: relative;
height: 100px;
width: 100px;
line-height: 200px;
text-align: center;
margin: 10px 40px;
cursor: pointer;
}
.diamond:after {
position: absolute;
top: 10px;
left: 10px;
content: '';
height: calc(100% - 22px);
width: calc(100% - 22px);
background: #b58900;
border: 1px solid #b58900;
transform: rotateX(45deg) rotateZ(45deg);
}
.circle {
position: absolute;
width: 80px;
height: 80px;
background: #b58900;
border-radius: 50%;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.small-circle {
position: absolute;
width: 30px;
height: 30px;
background: #b58900;
border-radius: 50%;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.square {
position: absolute;
width: 80px;
height: 80px;
background: #b58900;
border-radius: 5px;
cursor: pointer;
border-style: solid;
border-width: 4px;
border-color: #999;
}
.node .icon {
color: #f6f6f6;
}
.path {
cursor: pointer;
}
+348
View File
@@ -0,0 +1,348 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { uuidv4, createDiv } from "@node_ui_basics/utils/utils";
const icons = [
"fa-envelope-open",
"fa-clone",
"fa-cog",
"fa-database",
"fa-folder-o",
"fa-link",
"fa-lock",
"fa-pencil",
"fa-plus",
"fa-square-o",
]
function getRandomIcon() {
return icons[Math.floor(Math.random() * 10)];
}
const SQUARE_HALF = 40;
const CIRCLE_RAD = 40
class NodeUiSvg extends Component {
static template = "node-ui-svg";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
this.dragging = undefined;
this.state = useState({
nodes: [],
connections: [],
selected: undefined,
connecting: undefined
});
}
onAddCircleButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 50,
cY: 50,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
});
}
onAddSquareButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 50,
cY: 50,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
});
}
onAdd7NodesButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cX: 1000,
cY: 200,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 750,
cY: 100,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 500,
cY: 200,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 500,
cY: 500,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 750,
cY: 600,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
},{
id: uuidv4(),
cX: 1000,
cY: 500,
width: SQUARE_HALF * 2,
height: SQUARE_HALF * 2,
icon: getRandomIcon(),
type: "square"
},{
id: uuidv4(),
cX: 750,
cY: 325,
r: CIRCLE_RAD,
icon: getRandomIcon(),
type: "circle"
}
);
}
onRemoveButtonClick(event) {
if (this.state.selected){
const nodeIdx = this.state.nodes.findIndex(o => o.id === this.state.selected);
if (nodeIdx > -1){
const node = this.state.nodes.at(nodeIdx);
const cnns = this.state.connections.filter(c => c.sourceId === node.id || c.targetId === node.id)
for (const cnn of cnns) {
const cnnIdx = this.state.connections.findIndex(c => c.id === cnn.id);
this.state.connections.splice(cnnIdx, 1);
}
this.state.nodes.splice(nodeIdx, 1);
} else {
const cnnIdx = this.state.connections.findIndex(c => c.id === this.state.selected);
if (cnnIdx > -1) {
this.state.connections.splice(cnnIdx, 1);
}
}
this.state.selected = undefined;
}
}
calcIntersectionPosForCircle(x1, y1, x2, y2, r){
const xDist = x2 - x1;
const yDist = y2 - y1;
const diagDist = Math.sqrt(Math.pow(xDist, 2) + Math.pow(yDist, 2));
const ratio = r/diagDist
const mposX = ratio * xDist;
const mposY = ratio * yDist;
return { x: x1 + mposX, y: y1 + mposY }
}
calcIntersectionPosForSquare(x1, y1, w, h, x2, y2){
const xDist = Math.abs(x2 - x1);
const yDist = Math.abs(y2 - y1);
const signY = Math.sign(y2 - y1)
const signX = Math.sign(x2 - x1)
let res;
if (yDist <= xDist) {
const ratio = Math.abs((w/2)/xDist);
const mposX = (w/2) * signX;
const mposY = ratio * yDist * signY;
res = { x: x1 + mposX, y: y1 + mposY }
} else {
const ratio = Math.abs((h/2)/yDist);
const mposX = ratio * xDist * signX;
const mposY = (h/2) * signY;
res = { x: x1 + mposX, y: y1 + mposY }
}
return res;
}
calcMarkerPosByTargetPos(sourceEl, x, y) {
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceRect = sourceEl.getBoundingClientRect();
const funcForCircle = this.calcIntersectionPosForCircle;
const funcForSquare = this.calcIntersectionPosForSquare;
const pos = sourceEl.classList.contains("circle") ? funcForCircle(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
x,
y,
CIRCLE_RAD
) : funcForSquare(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
sourceRect.width,
sourceRect.height,
x,
y
);
return pos;
}
calcMarkerPosByTargetEl(sourceEl, targetEl){
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceRect = sourceEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const funcForCircle = this.calcIntersectionPosForCircle;
const funcForSquare = this.calcIntersectionPosForSquare;
const pos = sourceEl.classList.contains("circle") ? funcForCircle(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
(targetRect.left + targetRect.width / 2) - cRect.left,
(targetRect.top + targetRect.height / 2) - cRect.top,
CIRCLE_RAD
) : funcForSquare(
(sourceRect.left + sourceRect.width / 2) - cRect.left,
(sourceRect.top + sourceRect.height / 2) - cRect.top,
sourceRect.width,
sourceRect.height,
(targetRect.left + targetRect.width / 2) - cRect.left,
(targetRect.top + targetRect.height / 2) - cRect.top,
);
return pos;
}
onNodeMouseDown(event){
if (event.ctrlKey) {
event.stopPropagation();
event.preventDefault();
const el = event.target;
const cRect = this.containerRef.el.getBoundingClientRect();
const pos = this.calcMarkerPosByTargetPos(el, event.x - cRect.left, event.y - cRect.top);
this.state.connecting = {
id: uuidv4(),
startX: pos.x,
startY: pos.y,
endX: event.x - cRect.left,
endY: event.y - cRect.top,
sourceId: el.id,
};
} else {
this.state.selected = event.target.id;
}
}
onNodeMouseUp(event){
if (this.state.connecting !== undefined){
event.stopPropagation();
if (event.target.classList.contains("node") && event.target.id !== this.state.connecting.sourceId) {
const targetEl = event.target;
const sourceEl = document.getElementById(this.state.connecting.sourceId);
const targetPos = this.calcMarkerPosByTargetEl(targetEl, sourceEl);
this.state.connecting.endX = targetPos.x;
this.state.connecting.endY = targetPos.y;
this.state.connecting.targetId = targetEl.id;
const cnns = this.state.connections.filter(c =>
c.sourceId === this.state.connecting.id || c.targetId === this.state.connecting.id)
if (cnns.length == 0){
this.state.connections.push(this.state.connecting);
}
}
}
this.state.connecting = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5
&& event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
const node = this.state.nodes.find((o) => o.id == this.dragging);
if (node){
node.cX += event.movementX;
node.cY += event.movementY;
const cnns = this.state.connections.filter(c => c.sourceId === node.id || c.targetId === node.id)
for (const cnn of cnns) {
this.updateConnectionPos(cnn);
}
}
}
} else if (this.state.connecting){
const cRect = this.containerRef.el.getBoundingClientRect();
const sourceEl = document.getElementById(this.state.connecting.sourceId);
const pos = this.calcMarkerPosByTargetPos(sourceEl, event.x - cRect.left, event.y - cRect.top);
const cnn = this.state.connecting;
cnn.startX = pos.x;
cnn.startY = pos.y;
cnn.endX = event.x - cRect.left;
cnn.endY = event.y - cRect.top;
}
}
onMouseDown(event){
if (!event.ctrlKey) {
if (event.target.classList.contains("node")) {
this.dragging = event.target.id;
this.selected = event.target.id;
} else {
this.dragging = undefined;
}
}
}
onMouseUp(event){
this.dragging = undefined;
this.state.connecting = undefined;
}
updateConnectionPos(cnn){
const startNode = this.state.nodes.find(n => n.id === cnn.sourceId);
const endNode = this.state.nodes.find(n => n.id === cnn.targetId);
if (startNode !== undefined && endNode !== undefined) {
const startEl = document.getElementById(startNode.id);
const endEl = document.getElementById(endNode.id);
let pos = this.calcMarkerPosByTargetEl(startEl, endEl);
cnn.startX = pos.x;
cnn.startY = pos.y;
pos = this.calcMarkerPosByTargetEl(endEl, startEl);
cnn.endX = pos.x;
cnn.endY = pos.y;
}
}
onLineSelected(event){
this.state.selected = event.target.id;
}
onDeselectButtonClick(event) {
this.state.selected = undefined;
}
}
registry.category("actions").add("node_ui_svg", NodeUiSvg);
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="node-ui-svg">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Node UI Basic with SVG
</div>
<div id="card-body"
class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<div t-ref="container"
id="container"
class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<svg xmlns="http://www.w3.org/2000/svg"
style="width:100%; height:100%">
<defs>
<marker id="arrow"
viewBox="0 0 15 15"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse">
<path d="M 0 0 L 5 5 L 0 10 z"
stroke="context-stroke"
fill="context-stroke"/>
</marker>
<marker id="dot"
viewBox="0 0 30 30"
refX="5"
refY="5"
markerWidth="15"
markerHeight="10"
orient="auto-start-reverse">
<circle cx="0"
cy="5"
r="5"
fill="context-stroke"/>
</marker>
</defs>
<t t-foreach="state.connections"
t-as="cnn"
t-key="cnn.id">
<line class="path" t-attf-style="stroke-width:3;
stroke:{{ cnn.id === state.selected ? '#5f5' : '#b58900'}};"
marker-end="url(#arrow)"
marker-start="url(#dot)"
t-att-id="cnn.id"
t-att-x1="cnn.startX"
t-att-y1="cnn.startY"
t-att-x2="cnn.endX"
t-att-y2="cnn.endY"
t-on-mousedown.prevent="onLineSelected"/>
</t>
<line t-if="state.connecting !== undefined"
style="stroke-width:3;
stroke:#b58900;"
t-att-id="state.connecting.id"
t-att-x1="state.connecting.startX"
t-att-y1="state.connecting.startY"
t-att-x2="state.connecting.endX"
t-att-y2="state.connecting.endY"/>
</svg>
<t t-foreach="state.nodes"
t-as="node"
t-key="node.id">
<div t-attf-class="node {{ node.type }}"
t-att-id="node.id"
t-attf-style="
left:{{node.type === 'circle' ? node.cX - node.r : node.cX - node.width / 2}}px;
top:{{node.type === 'circle' ? node.cY - node.r : node.cY - node.height / 2}}px;
border-color: {{ node.id === state.selected ? '#5f5' : '#999'}}
"
t-on-mousedown="onNodeMouseDown"
t-on-mouseup.prevent="onNodeMouseUp">
<div class="pe-none h-100 w-100 d-flex justify-content-center align-items-center">
<i t-attf-class="icon fa {{node.icon}} fa-3x align-self-center"></i>
</div>
</div>
</t>
</div>
<div class="btn-group"
style="top:10px; right:10px; position: absolute;">
<button class="btn btn-primary my-0"
t-on-click="onAddCircleButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-circle fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onAddSquareButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-square fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onAdd7NodesButtonClick">
<i t-attf-class="icon fa fa-plus fa-lg align-self-center"></i>
<i t-attf-class="icon fa fa-stop-circle-o fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onDeselectButtonClick">
<i t-attf-class="icon fa fa-times fa-lg align-self-center"></i>
</button>
<button class="btn btn-primary my-0"
t-on-click="onRemoveButtonClick">
<i t-attf-class="icon fa fa-minus fa-lg align-self-center"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+55
View File
@@ -0,0 +1,55 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class SvgBasics extends Component {
static template = "svg-basics";
static components = { };
static props = {
...standardActionServiceProps
};
setup() {
this.svgRef = useRef("svg");
this.state = useState({
startX: 100,
startY: 50,
endX: 1550,
endY: 300,
controlX: 200,
controlY: 200,
})
this.dragging = undefined;
}
onMouseDown(event){
this.dragging = event.target;
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event){
if (this.dragging){
const svgRect = this.svgRef.el.getBoundingClientRect();
const el = this.dragging;
if (event.x > svgRect.left + 5 && event.y > svgRect.top + 5
&& event.x < svgRect.right - 5 && event.y < svgRect.bottom - 5){
if(el.id === "startPoint"){
this.state.startX += event.movementX;
this.state.startY += event.movementY;
} else if (el.id === "endPoint"){
this.state.endX += event.movementX;
this.state.endY += event.movementY;
} else if (el.id === "controlPoint"){
this.state.controlX += event.movementX;
this.state.controlY += event.movementY;
}
}
}
}
}
registry.category("actions").add("svg_basics", SvgBasics);
+178
View File
@@ -0,0 +1,178 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="svg-basics">
<div class="d-flex flex-column h-100 w-100 p-1"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove"
>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Rectangle
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<rect width="100" height="100" x="10" y="10" rx="10" ry="10" fill="grey"/>
<rect width="100" height="100" x="120" y="10"
style="fill:#b58900;stroke-width:3;stroke:grey"/>
<rect width="100" height="100" x="230" y="10"
style="fill:grey;stroke:#b58900;stroke-width:3;fill-opacity:0.5;stroke-opacity:0.5"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Circle
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<circle r="47.5" cx="55" cy="60" fill="#b58900"/>
<circle r="47.5" cx="167.5" cy="60" fill="#123456" stroke="grey" stroke-width="3"/>
<circle r="47.5" cx="280" cy="60" fill="#b58900" stroke="grey" stroke-width="3" opacity="0.5"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Ellipse
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<ellipse rx="50" ry="20" cx="55" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
<ellipse rx="20" ry="50" cx="167.5" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
<ellipse rx="50" ry="20" cx="280" cy="60" style="fill:#b58900;stroke:grey;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Line
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<line x1="55" y1="10" x2="55" y2="115" style="stroke:#b58900;stroke-width:2"/>
<line x1="125" y1="10" x2="225" y2="115" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="55" x2="330" y2="55" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="60" x2="330" y2="60" style="stroke:#b58900;stroke-width:2"/>
<line x1="280" y1="65" x2="330" y2="65" style="stroke:#b58900;stroke-width:2"/>
</svg>
</div>
</div>
</div>
</div>
<div class="d-flex">
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Polygon
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<polygon points="225,10 320,120 10,120" style="fill:#b58900;stroke:grey;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Polyline
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<polyline points="10,10 50,50 55,55 100,60, 200,100 250,80 320,100"
style="fill:none;stroke:#b58900;stroke-width:3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Simple Path
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<path d="M 10 10 l 330 110" stroke="#b58900" stroke-width="3"/>
</svg>
</div>
</div>
</div>
<div class="m-0 p-1 col-3">
<div class="card text-center">
<div class="card-header">
Curved Path
</div>
<div class="card-body p-1 overflow-hidden">
<svg xmlns="http://www.w3.org/2000/svg" style="width: 340px; height: 125px">
<path d="M 10 100 Q 0 0 330 110" stroke="#b58900" stroke-width="3" fill="none"/>
</svg>
</div>
</div>
</div>
</div>
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
Interactive Path
</div>
<div class="card-body p-1 overflow-hidden" t-ref="svg">
<svg xmlns="http://www.w3.org/2000/svg" style="width:100%; height:100%">
<style>
circle { cursor: pointer; }
</style>
<t t-set="startX" t-value="state.startX"/>
<t t-set="startY" t-value="state.startY"/>
<t t-set="endX" t-value="state.endX"/>
<t t-set="endY" t-value="state.endY"/>
<t t-set="controlX" t-value="state.controlX"/>
<t t-set="controlY" t-value="state.controlY"/>
<t t-set="midX" t-value="(startX + endX)/2"/>
<t t-set="midY" t-value="(startY + endY)/2"/>
<t t-call="qbc"/>
</svg>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-name="qbc">
<g xmlns="http://www.w3.org/2000/svg" >
<path id="qBC" t-attf-d="
M {{startX}},{{startY}}
Q {{controlX}},{{controlY}} {{midX}},{{midY}}
T {{endX}},{{endY}}"
stroke="#b58900" stroke-width="3" fill="none"/>
<circle id="startPoint" class="point" style="fill:grey"
t-att-cx="startX"
t-att-cy="startY"
r="6"/>
<circle id="endPoint" class="point" style="fill:grey"
t-att-cx="endX"
t-att-cy="endY"
r="6"/>
<circle id="controlPoint" class="point" style="fill:grey"
t-att-cx="controlX"
t-att-cy="controlY"
r="6"/>
<line t-att-x1="startX" t-att-y1="startY"
t-att-x2="controlX" t-att-y2="controlY"
style="stroke:#b58900;stroke-width:2"
stroke-dasharray="10,10"/>
<line t-att-x1="controlX" t-att-y1="controlY"
t-att-x2="midX" t-att-y2="midY"
style="stroke:#b58900;stroke-width:2"
stroke-dasharray="10,10"/>
</g>
</t>
</templates>
+125
View File
@@ -0,0 +1,125 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class SvgBezier extends Component {
static template = "svg-bezier";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
const startX = 600;
const startY = 400;
const midX = 800;
const midY = 100;
const endX = 1000;
const endY = 400;
const rad = 15;
this.state = useState({
orientation: "auto",
startNode: {
cx: startX,
cy: startY,
r: rad,
},
midNode: {
cx: midX,
cy: midY,
r: rad,
},
endNode: {
cx: endX,
cy: endY,
r: rad,
},
path: "",
});
onMounted(() => {
this.updatePath();
});
}
onMouseDown(event) {
if (event.target.id === "startNode" || event.target.id === "midNode" || event.target.id === "endNode") {
this.dragging = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
if (this.dragging === "startNode") {
this.state.startNode.cx += event.movementX;
this.state.startNode.cy += event.movementY;
} else if (this.dragging === "midNode") {
this.state.midNode.cx += event.movementX;
this.state.midNode.cy += event.movementY;
} else if (this.dragging === "endNode") {
this.state.endNode.cx += event.movementX;
this.state.endNode.cy += event.movementY;
}
this.updatePath();
}
}
}
updatePath(){
this.state.path = this._createPaths();
}
_createPaths(){
let res = "";
const startNode = this.state.startNode;
const midNode = this.state.midNode;
const endNode = this.state.endNode;
res = res + this._createPath(startNode.cx, startNode.cy, midNode.cx, midNode.cy);
res = res + " " + this._createPath(midNode.cx, midNode.cy, endNode.cx, endNode.cy);
return res;
}
// https://stackoverflow.com/a/45245042
_createPath(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;
}
}
registry.category("actions").add("svg_bezier", SvgBezier);
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="svg-bezier">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
SVG Connection
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<div t-ref="container" id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:100%; height:100%">
<path stroke="#b58900" stroke-width="3" fill="none"
t-att-d="state.path"
/>
</svg>
<div id="startNode" class="small-circle"
t-attf-style="left:{{state.startNode.cx - state.startNode.r}}px;
top:{{state.startNode.cy - state.startNode.r}}px;">
</div>
<div id="midNode" class="small-circle"
t-attf-style="left:{{state.midNode.cx - state.midNode.r}}px;
top:{{state.midNode.cy - state.midNode.r}}px;">
</div>
<div id="endNode" class="small-circle"
t-attf-style="left:{{state.endNode.cx - state.endNode.r}}px;
top:{{state.endNode.cy - state.endNode.r}}px;">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+179
View File
@@ -0,0 +1,179 @@
import { Component, useRef, useState, onWillStart, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
class SvgConnection extends Component {
static template = "svg-connection";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
const startX = 600;
const startY = 400;
const midX = 800;
const midY = 100;
const endX = 1000;
const endY = 400;
const rad = 25;
this.state = useState({
orientation: "auto",
startNode: {
cx: startX,
cy: startY,
r: rad,
},
midNode: {
cx: midX,
cy: midY,
r: rad,
},
endNode: {
cx: endX,
cy: endY,
r: rad,
},
path: "",
});
onMounted(() => {
this.updatePath();
});
}
onMouseDown(event) {
if (event.target.id === "startNode" || event.target.id === "midNode" || event.target.id === "endNode") {
this.dragging = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event) {
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
if (this.dragging === "startNode") {
this.state.startNode.cx += event.movementX;
this.state.startNode.cy += event.movementY;
} else if (this.dragging === "midNode") {
this.state.midNode.cx += event.movementX;
this.state.midNode.cy += event.movementY;
} else if (this.dragging === "endNode") {
this.state.endNode.cx += event.movementX;
this.state.endNode.cy += event.movementY;
}
this.updatePath();
}
}
}
updatePath(){
this.state.path = this._createPaths(this.state.orientation);
}
_createPaths(orient = "auto"){
let res = "";
res = res + this._createPath(this.state.startNode, this.state.midNode, orient);
res = res + " " + this._createPath(this.state.midNode, this.state.endNode, orient);
return res;
}
_createPath(firstNode, secondNode, orient) {
let res = "";
let hDistance = Math.abs(firstNode.cx - secondNode.cx);
let vDistance = Math.abs(firstNode.cy - secondNode.cy);
let orientation = "v";
if (orient == "auto"){
orientation = hDistance > vDistance ? "v" : "h";
} else {
orientation = orient === "vertical" ? "v" : "h"
}
let firstX = firstNode.cx;
let firstY = firstNode.cy;
let secondX = secondNode.cx;
let secondY = secondNode.cy;
let midX = (secondX + firstX) / 2;
let midY = (secondY + firstY) / 2;
const dirX = Math.sign(secondX - firstX);
const dirY = Math.sign(secondY - firstY);
let dirA = dirX > 0 ? 0 : 1;
let dirAFlip = dirA == 0 ? 1 : 0;
if (dirY < 0){
const temp = dirA;
dirA = dirAFlip;
dirAFlip = temp;
}
const minDistance = 5;
const baseMargin = 10;
let margin = baseMargin;
if (hDistance <= margin * 2 || vDistance <= margin * 2){
margin = hDistance > vDistance ? vDistance : hDistance;
}
const marginX = margin * dirX;
const marginY = margin * dirY;
if (orientation === "v") {
res += "M" + (firstX) + "," + (firstY);
res += " L" + (firstX) + "," + (midY - marginY);
if (hDistance > baseMargin * 2){
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirA) + " " + (firstX + marginX) + "," + (midY);
res += " L" + (secondX - marginX) + "," + (midY);
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirAFlip) + " " + (secondX) + "," + (midY + marginY);
} else if (hDistance > minDistance){
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirA) + " " + (firstX + marginX / 2) + "," + (midY);
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirAFlip) + " " + (secondX) + "," + (midY + marginY);
} else {
res += " L" + (secondX) + "," + (midY + marginY);
}
res += " L" + (secondX) + "," + (secondY);
} else {
res += "M" + (firstX) + "," + (firstY);
res += " L" + (midX - marginX) + "," + (firstY);
if (vDistance > baseMargin * 2){
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirAFlip) + " " + (midX) + "," + (firstY + marginY);
res += " L" + (midX) + "," + (secondY - marginY);
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirA) + " " + (midX + marginX) + "," + (secondY);
} else if (vDistance > minDistance){
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirAFlip) + " " + (midX) + "," + (firstY + marginY / 2);
res += " A" + (margin) + " " + (margin) + " 90 0 " + " "
+ (dirA) + " " + (midX + marginX) + "," + (secondY);
} else {
res += " L" + (midX + marginX) + "," + (secondY);
}
res += " L" + (secondX) + "," + (secondY);
}
return res;
}
}
registry.category("actions").add("svg_connection", SvgConnection);
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="svg-connection">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
SVG Connection
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<div t-ref="container" id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:100%; height:100%">
<path stroke="#b58900" stroke-width="3" fill="none"
t-att-d="state.path"
/>
</svg>
<div id="startNode" class="circle"
t-attf-style="left:{{state.startNode.cx - state.startNode.r}}px;
top:{{state.startNode.cy - state.startNode.r}}px;">
</div>
<div id="midNode" class="circle"
t-attf-style="left:{{state.midNode.cx - state.midNode.r}}px;
top:{{state.midNode.cy - state.midNode.r}}px;">
</div>
<div id="endNode" class="circle"
t-attf-style="left:{{state.endNode.cx - state.endNode.r}}px;
top:{{state.endNode.cy - state.endNode.r}}px;">
</div>
</div>
<div class="btn-group" role="group" style="top:10px; right:10px; position: absolute;">
<input id="optOrientationVertical"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="vertical"
t-model="state.orientation"
t-on-change="updatePath">
</input>
<label class="btn btn-primary" for="optOrientationVertical">Vertical</label>
<input id="optOrientationHorizontal"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="horizontal"
t-model="state.orientation"
t-on-change="updatePath">
</input>
<label class="btn btn-primary" for="optOrientationHorizontal">Horizontal</label>
<input id="optOrientationAuto"
autocomplete="off"
name="optOrientation"
class="btn-check"
type="radio"
value="auto"
t-model="state.orientation"
t-on-change="updatePath">
</input>
<label class="btn btn-primary" for="optOrientationAuto">Auto</label>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+113
View File
@@ -0,0 +1,113 @@
import { Component, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { uuidv4 } from "@node_ui_basics/utils/utils";
class SvgNodes extends Component {
static template = "svg-nodes";
static components = {};
static props = {
...standardActionServiceProps,
};
setup() {
this.containerRef = useRef("container");
this.state = useState({
nodes: [],
selected: undefined,
});
}
onAddButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 50,
cy: 50,
r: 25,
});
}
onAdd7NodesButtonClick() {
this.state.nodes.push({
id: uuidv4(),
cx: 1000,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 100,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 200,
r: 25,
},{
id: uuidv4(),
cx: 500,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 600,
r: 25,
},{
id: uuidv4(),
cx: 1000,
cy: 500,
r: 25,
},{
id: uuidv4(),
cx: 750,
cy: 325,
r: 25,
}
);
}
onRemoveButtonClick(event) {
if (this.state.selected){
let nodeIdx = this.state.nodes.findIndex(o => o.id === this.state.selected);
if (nodeIdx > -1){
this.state.nodes.splice(nodeIdx, 1);
}
this.state.selected = undefined;
}
}
onNodeSelected(event){
this.state.selected = event.target.id;
}
onMouseDown(event){
if (event.target.classList.contains("node")) {
this.dragging = event.target.id;
this.selected = event.target.id;
} else {
this.dragging = undefined;
}
}
onMouseUp(event){
this.dragging = undefined;
}
onMouseMove(event) {
if (this.dragging) {
const cRect = this.containerRef.el.getBoundingClientRect();
if (event.x > cRect.left + 5 && event.y > cRect.top + 5 && event.x < cRect.right - 20 && event.y < cRect.bottom - 20) {
const node = this.state.nodes.find((o) => o.id == this.dragging);
if (node){
node.cx += event.movementX;
node.cy += event.movementY;
}
}
}
}
}
registry.category("actions").add("svg_nodes", SvgNodes);
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="svg-nodes">
<div class="d-flex flex-column h-100 w-100 p-1">
<div class="d-flex flex-fill">
<div class="m-0 p-1 col-12 h-100">
<div class="card text-center h-100">
<div class="card-header">
SVG With DIV Nodes
</div>
<div id="card-body" class="card-body p-1 overflow-hidden position-relative"
t-on-mousedown.stop.prevent="onMouseDown"
t-on-mouseup.stop.prevent="onMouseUp"
t-on-mousemove.stop.prevent="onMouseMove">
<div t-ref="container" id="container" class="h-100 w-100"
style="left: 0px; top: 0px; position: absolute;">
<svg xmlns="http://www.w3.org/2000/svg" style="width:100%; height:100%">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<t t-if="(state.nodes.length gt 1) and (node_index + 1 lt state.nodes.length)">
<line style="stroke:#b58900;stroke-width:3"
t-att-x1="node.cx"
t-att-y1="node.cy"
t-att-x2="state.nodes[node_index+1].cx"
t-att-y2="state.nodes[node_index+1].cy"/>
</t>
</t>
</svg>
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="node circle"
t-att-id="node.id"
t-attf-style="left:{{node.cx - node.r}}px;
top:{{node.cy - node.r}}px;
border-color: {{ node.id === state.selected ? '#5f5' : '#999'}}
"
t-on-mousedown.prevent="onNodeSelected">
</div>
</t>
</div>
<div class="btn-group" style="top:10px; right:10px; position: absolute;">
<button class="btn btn-primary my-0" t-on-click="onAddButtonClick">
Add
</button>
<button class="btn btn-primary my-0" t-on-click="onAdd7NodesButtonClick">
Add Seven Nodes
</button>
<button class="btn btn-primary my-0" t-on-click="onRemoveButtonClick">
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
+30
View File
@@ -0,0 +1,30 @@
/*
* 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 createDiv(l, t, w, h, c) {
const el = document.createElement("div");
el.className = "debug-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);
}
@@ -0,0 +1,97 @@
<odoo>
<data>
<record model="ir.actions.client" id="node_ui_basics_svg">
<field name="name">SVG</field>
<field name="tag">svg_basics</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_svg_nodes">
<field name="name">SVG with Nodes</field>
<field name="tag">svg_nodes</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_svg_connection">
<field name="name">SVG Connection</field>
<field name="tag">svg_connection</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_svg_bezier">
<field name="name">SVG Bezier</field>
<field name="tag">svg_bezier</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_node_ui_svg">
<field name="name">Node UI with SVG</field>
<field name="tag">node_ui_svg</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_canvas">
<field name="name">Canvas</field>
<field name="tag">canvas_basics</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_canvas_nodes">
<field name="name">Canvas with Nodes</field>
<field name="tag">canvas_nodes</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_canvas_connection">
<field name="name">Canvas Connection</field>
<field name="tag">canvas_connection</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_canvas_connection">
<field name="name">Canvas Connection</field>
<field name="tag">canvas_connection</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_konva">
<field name="name">Konva</field>
<field name="tag">canvas_konva</field>
</record>
<record model="ir.actions.client" id="node_ui_basics_movable_div">
<field name="name">movable DIV</field>
<field name="tag">movable_div</field>
</record>
<menuitem name="Node UI Basics" id="node_ui_basics.menu_root" />
<menuitem name="Canvas" id="node_ui_basics.canvas_menu"
parent="node_ui_basics.menu_root" sequence="1"/>
<menuitem name="Basics" id="node_ui_basics.canvas_basics_menu"
parent="node_ui_basics.canvas_menu" action="node_ui_basics_canvas" sequence="1"/>
<menuitem name="Canvas with Nodes" id="node_ui_basics.canvas_nodes_menu"
parent="node_ui_basics.canvas_menu" action="node_ui_basics_canvas_nodes" sequence="2"/>
<menuitem name="Canvas Connection" id="node_ui_basics.canvas_connection_menu"
parent="node_ui_basics.canvas_menu" action="node_ui_basics_canvas_connection" sequence="2"/>
<menuitem name="Konva" id="node_ui_basics.canvas_konva_menu"
parent="node_ui_basics.menu_root" action="node_ui_basics_konva" sequence="2"/>
<menuitem name="SVG" id="node_ui_basics.svg_menu"
parent="node_ui_basics.menu_root" sequence="3"/>
<menuitem name="Basics" id="node_ui_basics.svg_basics_menu"
parent="node_ui_basics.svg_menu" action="node_ui_basics_svg" sequence="1"/>
<menuitem name="SVG with Nodes" id="node_ui_basics.svg_nodes_menu"
parent="node_ui_basics.svg_menu" action="node_ui_basics_svg_nodes" sequence="2"/>
<menuitem name="SVG Connection" id="node_ui_basics.svg_connection_menu"
parent="node_ui_basics.svg_menu" action="node_ui_basics_svg_connection" sequence="2"/>
<menuitem name="SVG Bezier" id="node_ui_basics.svg_bezier_menu"
parent="node_ui_basics.svg_menu" action="node_ui_basics_svg_bezier" sequence="3"/>
<menuitem name="Node UI with SVG" id="node_ui_basics.node_ui_svg_menu"
parent="node_ui_basics.svg_menu" action="node_ui_basics_node_ui_svg" sequence="4"/>
<menuitem name="DIV" id="node_ui_basics.movable_div_menu"
parent="node_ui_basics.menu_root" action="node_ui_basics_movable_div" sequence="4"/>
</data>
</odoo>