658 lines
18 KiB
JavaScript
658 lines
18 KiB
JavaScript
const { invoke } = window.__TAURI__.core;
|
|
import * as fitCurve from '/fit-curve.js';
|
|
|
|
let simplifyPolyline = simplify
|
|
|
|
let greetInputEl;
|
|
let greetMsgEl;
|
|
let rootPane;
|
|
|
|
let canvases = [];
|
|
|
|
let mode = "draw"
|
|
|
|
let minSegmentSize = 5;
|
|
let maxSmoothAngle = 0.6;
|
|
|
|
let tools = {
|
|
select: {
|
|
icon: "/assets/select.svg",
|
|
properties: {}
|
|
|
|
},
|
|
transform: {
|
|
icon: "/assets/transform.svg",
|
|
properties: {}
|
|
|
|
},
|
|
draw: {
|
|
icon: "/assets/draw.svg",
|
|
properties: {
|
|
"lineWidth": {
|
|
type: "number",
|
|
label: "Line Width"
|
|
},
|
|
"simplifyMode": {
|
|
type: "enum",
|
|
options: ["corners", "smooth"], // "auto"],
|
|
label: "Line Mode"
|
|
},
|
|
"fillShape": {
|
|
type: "boolean",
|
|
label: "Fill Shape"
|
|
}
|
|
}
|
|
},
|
|
rectangle: {
|
|
icon: "/assets/rectangle.svg",
|
|
properties: {}
|
|
},
|
|
ellipse: {
|
|
icon: "assets/ellipse.svg",
|
|
properties: {}
|
|
},
|
|
paint_bucket: {
|
|
icon: "/assets/paint_bucket.svg",
|
|
properties: {}
|
|
}
|
|
}
|
|
|
|
let mouseEvent;
|
|
|
|
let context = {
|
|
mouseDown: false,
|
|
swatches: [
|
|
"#000000",
|
|
"#FFFFFF",
|
|
"#FF0000",
|
|
"#FFFF00",
|
|
"#00FF00",
|
|
"#00FFFF",
|
|
"#0000FF",
|
|
"#FF00FF",
|
|
],
|
|
lineWidth: 5,
|
|
simplifyMode: "smooth",
|
|
fillShape: true,
|
|
}
|
|
|
|
let config = {
|
|
shortcuts: {
|
|
playAnimation: " ",
|
|
}
|
|
}
|
|
|
|
function uuidv4() {
|
|
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
|
|
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
|
|
);
|
|
}
|
|
|
|
function vectorDist(a, b) {
|
|
return Math.sqrt((a.x-b.x)*(a.x-b.x) + (a.y-b.y)*(a.y-b.y))
|
|
}
|
|
|
|
function getMousePos(canvas, evt) {
|
|
var rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: evt.clientX - rect.left,
|
|
y: evt.clientY - rect.top
|
|
};
|
|
}
|
|
|
|
function getProperty(context, path) {
|
|
let pointer = context;
|
|
let pathComponents = path.split('.')
|
|
for (let component of pathComponents) {
|
|
pointer = pointer[component]
|
|
}
|
|
return pointer
|
|
}
|
|
|
|
function setProperty(context, path, value) {
|
|
let pointer = context;
|
|
let pathComponents = path.split('.')
|
|
let finalComponent = pathComponents.pop()
|
|
for (let component of pathComponents) {
|
|
pointer = pointer[component]
|
|
}
|
|
pointer[finalComponent] = value
|
|
}
|
|
|
|
class Curve {
|
|
constructor(cp1x, cp1y, cp2x, cp2y, x, y) {
|
|
this.cp1x = cp1x;
|
|
this.cp1y = cp1y;
|
|
this.cp2x = cp2x;
|
|
this.cp2y = cp2y;
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
}
|
|
|
|
class Frame {
|
|
constructor() {
|
|
this.keys = {}
|
|
this.shapes = []
|
|
}
|
|
}
|
|
|
|
class Shape {
|
|
constructor(startx, starty, context, stroked=true) {
|
|
this.startx = startx;
|
|
this.starty = starty;
|
|
this.curves = [];
|
|
this.fillStyle = context.fillStyle;
|
|
this.fillImage = context.fillImage;
|
|
this.strokeStyle = context.strokeStyle;
|
|
this.lineWidth = context.lineWidth
|
|
this.filled = context.fillShape;
|
|
this.stroked = stroked;
|
|
}
|
|
addCurve(curve) {
|
|
this.curves.push(curve)
|
|
}
|
|
addLine(x, y) {
|
|
let lastpoint;
|
|
if (this.curves.length) {
|
|
lastpoint = this.curves[this.curves.length - 1]
|
|
} else {
|
|
lastpoint = {x: this.startx, y: this.starty}
|
|
}
|
|
let midpoint = {x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2}
|
|
let curve = new Curve(midpoint.x, midpoint.y, midpoint.x, midpoint.y, x, y)
|
|
this.curves.push(curve)
|
|
}
|
|
simplify(mode="corners") {
|
|
// Mode can be corners, smooth or auto
|
|
if (mode=="corners") {
|
|
let points = [{x: this.startx, y: this.starty}]
|
|
points = points.concat(this.curves)
|
|
let newpoints = simplifyPolyline(points, 10, false)
|
|
this.curves = []
|
|
let lastpoint = newpoints.shift()
|
|
let midpoint
|
|
for (let point of newpoints) {
|
|
midpoint = {x: (lastpoint.x+point.x)/2, y: (lastpoint.y+point.y)/2}
|
|
this.curves.push(new Curve(midpoint.x, midpoint.y,midpoint.x,midpoint.y,point.x,point.y))
|
|
lastpoint = point
|
|
}
|
|
} else if (mode=="smooth") {
|
|
let error = 30;
|
|
let points = [[this.startx, this.starty]]
|
|
for (let curve of this.curves) {
|
|
points.push([curve.x, curve.y])
|
|
}
|
|
this.curves = []
|
|
let curves = fitCurve.fitCurve(points, error)
|
|
for (let curve of curves) {
|
|
this.curves.push(new Curve(curve[1][0],curve[1][1],curve[2][0], curve[2][1], curve[3][0], curve[3][1]))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class GraphicsObject {
|
|
constructor() {
|
|
this.x = 0;
|
|
this.y = 0;
|
|
this.rotation = 0; // in radians
|
|
this.scale = 1;
|
|
this.idx = uuidv4()
|
|
|
|
this.frames = [new Frame()]
|
|
this.currentFrame = 0;
|
|
this.children = []
|
|
|
|
this.shapes = []
|
|
}
|
|
draw(context) {
|
|
let ctx = context.ctx;
|
|
ctx.translate(this.x, this.y)
|
|
ctx.rotate(this.rotation)
|
|
if (this.currentFrame>=this.frames.length) {
|
|
this.currentFrame = 0;
|
|
}
|
|
for (let child of this.children) {
|
|
let idx = child.idx
|
|
if (idx in this.frames[this.currentFrame].keys) {
|
|
child.x = this.frames[this.currentFrame].keys[idx].x;
|
|
child.y = this.frames[this.currentFrame].keys[idx].y;
|
|
child.rotation = this.frames[this.currentFrame].keys[idx].rotation;
|
|
child.scale = this.frames[this.currentFrame].keys[idx].scale;
|
|
ctx.save()
|
|
child.draw(context)
|
|
ctx.restore()
|
|
}
|
|
}
|
|
for (let shape of this.frames[this.currentFrame].shapes) {
|
|
ctx.beginPath()
|
|
ctx.lineWidth = shape.lineWidth
|
|
ctx.moveTo(shape.startx, shape.starty)
|
|
for (let curve of shape.curves) {
|
|
ctx.bezierCurveTo(curve.cp1x, curve.cp1y, curve.cp2x, curve.cp2y, curve.x, curve.y)
|
|
|
|
// Debug, show curve endpoints
|
|
// ctx.beginPath()
|
|
// ctx.arc(curve.x,curve.y, 3, 0, 2*Math.PI)
|
|
// ctx.fill()
|
|
}
|
|
if (shape.filled) {
|
|
if (shape.fillImage) {
|
|
let pat = ctx.createPattern(shape.fillImage, "no-repeat")
|
|
ctx.fillStyle = pat
|
|
} else {
|
|
ctx.fillStyle = shape.fillStyle
|
|
}
|
|
ctx.fill()
|
|
}
|
|
if (shape.stroked) {
|
|
ctx.strokeStyle = shape.strokeStyle
|
|
ctx.stroke()
|
|
}
|
|
}
|
|
}
|
|
addShape(shape) {
|
|
this.frames[this.currentFrame].shapes.push(shape)
|
|
}
|
|
addObject(object, x=0, y=0) {
|
|
this.children.push(object)
|
|
let idx = object.idx
|
|
this.frames[this.currentFrame].keys[idx] = {
|
|
x: x,
|
|
y: y,
|
|
rotation: 0,
|
|
scale: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
let root = new GraphicsObject();
|
|
context.activeObject = root
|
|
|
|
async function greet() {
|
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
|
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });
|
|
|
|
}
|
|
|
|
window.addEventListener("DOMContentLoaded", () => {
|
|
rootPane = document.querySelector("#root")
|
|
rootPane.appendChild(createPane(toolbar()))
|
|
rootPane.addEventListener("mousemove", (e) => {
|
|
mouseEvent = e;
|
|
})
|
|
let [_toolbar, panel] = splitPane(rootPane, 10, true)
|
|
let [_stage, _infopanel] = splitPane(panel, 70, false, createPane(infopanel()))
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
updateLayout(rootPane)
|
|
})
|
|
|
|
window.addEventListener("keypress", (e) => {
|
|
if (e.key == config.shortcuts.playAnimation) {
|
|
console.log("Spacebar pressed")
|
|
}
|
|
})
|
|
|
|
function stage() {
|
|
let stage = document.createElement("canvas")
|
|
let scroller = document.createElement("div")
|
|
stage.className = "stage"
|
|
stage.width = 1500
|
|
stage.height = 1000
|
|
scroller.className = "scroll"
|
|
stage.addEventListener("drop", (e) => {
|
|
e.preventDefault()
|
|
let mouse = getMousePos(stage, e)
|
|
const imageTypes = ['image/png', 'image/gif', 'image/avif', 'image/jpeg',
|
|
'image/svg+xml', 'image/webp'
|
|
];
|
|
if (e.dataTransfer.items) {
|
|
let i = 0
|
|
for (let item of e.dataTransfer.items) {
|
|
if (item.kind == "file") {
|
|
let file = item.getAsFile()
|
|
if (imageTypes.includes(file.type)) {
|
|
let img = new Image()
|
|
img.src = window.URL.createObjectURL(file)
|
|
img.ix = i
|
|
img.onload = function() {
|
|
let width = img.width
|
|
let height = img.height
|
|
let imageObject = new GraphicsObject()
|
|
let ct = {
|
|
...context,
|
|
fillImage: img,
|
|
}
|
|
let imageShape = new Shape(0, 0, ct, false)
|
|
imageShape.addLine(width, 0)
|
|
imageShape.addLine(width, height)
|
|
imageShape.addLine(0, height)
|
|
imageShape.addLine(0, 0)
|
|
imageObject.addShape(imageShape)
|
|
context.activeObject.addObject(
|
|
imageObject,
|
|
mouse.x-width/2 + (20*img.ix),
|
|
mouse.y-height/2 + (20*img.ix))
|
|
updateUI()
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
}
|
|
} else {
|
|
}
|
|
})
|
|
stage.addEventListener("dragover", (e) => {
|
|
e.preventDefault()
|
|
})
|
|
canvases.push(stage)
|
|
scroller.appendChild(stage)
|
|
stage.addEventListener("mousedown", (e) => {
|
|
let mouse = getMousePos(stage, e)
|
|
switch (mode) {
|
|
case "draw":
|
|
context.mouseDown = true
|
|
context.activeShape = new Shape(mouse.x, mouse.y, context, true, true)
|
|
context.activeObject.addShape(context.activeShape)
|
|
context.lastMouse = mouse
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
context.lastMouse = mouse
|
|
updateUI()
|
|
})
|
|
stage.addEventListener("mouseup", (e) => {
|
|
context.mouseDown = false
|
|
let mouse = getMousePos(stage, e)
|
|
switch (mode) {
|
|
case "draw":
|
|
if (context.activeShape) {
|
|
let midpoint = {x: (mouse.x+context.lastMouse.x)/2, y: (mouse.y+context.lastMouse.y)/2}
|
|
context.activeShape.addCurve(new Curve(midpoint.x, midpoint.y, midpoint.x, midpoint.y, mouse.x, mouse.y))
|
|
context.activeShape.simplify(context.simplifyMode)
|
|
context.activeShape = undefined
|
|
}
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
context.lastMouse = mouse
|
|
updateUI()
|
|
})
|
|
stage.addEventListener("mousemove", (e) => {
|
|
let mouse = getMousePos(stage, e)
|
|
switch (mode) {
|
|
case "draw":
|
|
if (context.activeShape) {
|
|
if (vectorDist(mouse, context.lastMouse) > minSegmentSize) {
|
|
let midpoint = {x: (mouse.x+context.lastMouse.x)/2, y: (mouse.y+context.lastMouse.y)/2}
|
|
context.activeShape.addCurve(new Curve(midpoint.x, midpoint.y, midpoint.x, midpoint.y, mouse.x, mouse.y))
|
|
context.lastMouse = mouse
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
updateUI()
|
|
})
|
|
return scroller
|
|
}
|
|
|
|
function toolbar() {
|
|
let tools_scroller = document.createElement("div")
|
|
tools_scroller.className = "toolbar"
|
|
for (let tool in tools) {
|
|
let toolbtn = document.createElement("button")
|
|
toolbtn.className = "toolbtn"
|
|
let icon = document.createElement("img")
|
|
icon.className = "icon"
|
|
icon.src = tools[tool].icon
|
|
toolbtn.appendChild(icon)
|
|
tools_scroller.appendChild(toolbtn)
|
|
toolbtn.addEventListener("click", () => {
|
|
console.log(tool)
|
|
})
|
|
}
|
|
let tools_break = document.createElement("div")
|
|
tools_break.className = "horiz_break"
|
|
tools_scroller.appendChild(tools_break)
|
|
let fillColor = document.createElement("input")
|
|
let strokeColor = document.createElement("input")
|
|
fillColor.className = "color-field"
|
|
strokeColor.className = "color-field"
|
|
fillColor.value = "#ffffff"
|
|
strokeColor.value = "#000000"
|
|
context.fillStyle = fillColor.value
|
|
context.strokeStyle = strokeColor.value
|
|
fillColor.addEventListener('click', e => {
|
|
Coloris({
|
|
el: ".color-field",
|
|
selectInput: true,
|
|
focusInput: true,
|
|
theme: 'default',
|
|
swatches: context.swatches,
|
|
defaultColor: '#ffffff',
|
|
onChange: (color) => {
|
|
context.fillStyle = color;
|
|
}
|
|
})
|
|
})
|
|
strokeColor.addEventListener('click', e => {
|
|
Coloris({
|
|
el: ".color-field",
|
|
selectInput: true,
|
|
focusInput: true,
|
|
theme: 'default',
|
|
swatches: context.swatches,
|
|
defaultColor: '#000000',
|
|
onChange: (color) => {
|
|
context.strokeStyle = color;
|
|
}
|
|
})
|
|
})
|
|
// Fill and stroke colors use the same set of swatches
|
|
fillColor.addEventListener("change", e => {
|
|
context.swatches.unshift(fillColor.value)
|
|
if (context.swatches.length>12) context.swatches.pop();
|
|
})
|
|
strokeColor.addEventListener("change", e => {
|
|
context.swatches.unshift(strokeColor.value)
|
|
if (context.swatches.length>12) context.swatches.pop();
|
|
})
|
|
tools_scroller.appendChild(fillColor)
|
|
tools_scroller.appendChild(strokeColor)
|
|
return tools_scroller
|
|
}
|
|
|
|
function infopanel() {
|
|
let panel = document.createElement("div")
|
|
panel.className = "infopanel"
|
|
let input;
|
|
let label;
|
|
let span;
|
|
// for (let i=0; i<10; i++) {
|
|
for (let property in tools[mode].properties) {
|
|
let prop = tools[mode].properties[property]
|
|
label = document.createElement("label")
|
|
label.className = "infopanel-field"
|
|
span = document.createElement("span")
|
|
span.className = "infopanel-label"
|
|
span.innerText = prop.label
|
|
switch (prop.type) {
|
|
case "number":
|
|
input = document.createElement("input")
|
|
input.className = "infopanel-input"
|
|
input.type = "number"
|
|
input.value = getProperty(context, property)
|
|
break;
|
|
case "enum":
|
|
input = document.createElement("select")
|
|
input.className = "infopanel-input"
|
|
let optionEl;
|
|
for (let option of prop.options) {
|
|
optionEl = document.createElement("option")
|
|
optionEl.value = option
|
|
optionEl.innerText = option
|
|
input.appendChild(optionEl)
|
|
}
|
|
input.value = getProperty(context, property)
|
|
break;
|
|
case "boolean":
|
|
input = document.createElement("input")
|
|
input.className = "infopanel-input"
|
|
input.type = "checkbox"
|
|
input.checked = getProperty(context, property)
|
|
break;
|
|
}
|
|
input.addEventListener("input", () => {
|
|
console.log(input.value)
|
|
switch (prop.type) {
|
|
case "number":
|
|
if (!isNaN(input.value) && input.value > 0) {
|
|
setProperty(context, property, input.value)
|
|
}
|
|
break;
|
|
case "enum":
|
|
if (prop.options.indexOf(input.value) >= 0) {
|
|
setProperty(context, property, input.value)
|
|
}
|
|
break;
|
|
case "boolean":
|
|
setProperty(context, property, input.checked)
|
|
}
|
|
|
|
})
|
|
label.appendChild(span)
|
|
label.appendChild(input)
|
|
panel.appendChild(label)
|
|
}
|
|
return panel
|
|
}
|
|
|
|
function createPane(content=undefined) {
|
|
let div = document.createElement("div")
|
|
let header = document.createElement("div")
|
|
if (!content) {
|
|
content = stage() // TODO: change based on type
|
|
}
|
|
header.className = "header"
|
|
|
|
let button = document.createElement("button")
|
|
header.appendChild(button)
|
|
let icon = document.createElement("img")
|
|
icon.className="icon"
|
|
icon.src = "/assets/stage.svg"
|
|
button.appendChild(icon)
|
|
|
|
|
|
// div.style.display = "grid";
|
|
// div.style.gridTemplateColumns = `var(--lineheight) 1fr`
|
|
// div.style.gridTemplateRows = "1fr"
|
|
// header.style.gridArea = "1 / 1 / 2 / 2"
|
|
// content.style.gridArea = "1 / 2 / 2 / 3"
|
|
|
|
div.className = "vertical-grid"
|
|
header.style.height = "calc( 2 * var(--lineheight))"
|
|
content.style.height = "calc( 100% - 2 * var(--lineheight) )"
|
|
div.appendChild(header)
|
|
div.appendChild(content)
|
|
return div
|
|
}
|
|
|
|
function splitPane(div, percent, horiz, newPane=undefined) {
|
|
let content = div.firstElementChild
|
|
let div1 = document.createElement("div")
|
|
let div2 = document.createElement("div")
|
|
|
|
div1.className = "panecontainer"
|
|
div2.className = "panecontainer"
|
|
|
|
div1.appendChild(content)
|
|
if (newPane) {
|
|
div2.appendChild(newPane)
|
|
} else {
|
|
div2.appendChild(createPane())
|
|
}
|
|
div.appendChild(div1)
|
|
div.appendChild(div2)
|
|
|
|
// div.style.display = "grid";
|
|
// if (horiz) {
|
|
// div.classList.add("horizontal-grid")
|
|
// div.style.gridTemplateColumns = `${percent}% 1fr`
|
|
// div1.style.gridArea = "1 / 1 / 2 / 2"
|
|
// div2.style.gridArea = "1 / 2 / 2 / 3"
|
|
// } else {
|
|
// div.classList.add("vertical-grid")
|
|
// div.style.gridTemplateRows = `${percent}% 1fr`
|
|
// div1.style.gridArea = "1 / 1 / 2 / 2"
|
|
// div2.style.gridArea = "2 / 1 / 3 / 2"
|
|
// }
|
|
if (horiz) {
|
|
div.className = "horizontal-grid"
|
|
} else {
|
|
div.className = "vertical-grid"
|
|
}
|
|
div.setAttribute("lb-percent", percent) // TODO: better attribute name
|
|
// div1.style.flex = `0 0 ${percent}%`
|
|
// div2.style.flex = `1 1 auto`
|
|
Coloris({el: ".color-field"})
|
|
updateUI()
|
|
updateLayout(rootPane)
|
|
return [div1, div2]
|
|
}
|
|
|
|
function updateLayout(element) {
|
|
let rect = element.getBoundingClientRect()
|
|
let percent = element.getAttribute("lb-percent")
|
|
percent ||= 50
|
|
let children = element.children
|
|
if (children.length != 2) return;
|
|
if (element.className == "horizontal-grid") {
|
|
children[0].style.width = `${rect.width * percent / 100}px`
|
|
children[1].style.width = `${rect.width * (100 - percent) / 100}px`
|
|
children[0].style.height = `${rect.height}px`
|
|
children[1].style.height = `${rect.height}px`
|
|
} else if (element.className == "vertical-grid") {
|
|
children[0].style.height = `${rect.height * percent / 100}px`
|
|
children[1].style.height = `${rect.height * (100 - percent) / 100}px`
|
|
children[0].style.width = `${rect.width}px`
|
|
children[1].style.width = `${rect.width}px`
|
|
}
|
|
if (children[0].getAttribute("lb-percent")) {
|
|
updateLayout(children[0])
|
|
}
|
|
if (children[1].getAttribute("lb-percent")) {
|
|
updateLayout(children[1])
|
|
}
|
|
}
|
|
|
|
function updateUI() {
|
|
for (let canvas of canvases) {
|
|
let ctx = canvas.getContext("2d")
|
|
ctx.reset();
|
|
ctx.fillStyle = "white"
|
|
ctx.fillRect(0,0,canvas.width,canvas.height)
|
|
ctx.fillStyle = "green"
|
|
// ctx.fillRect(0,0,200,200)
|
|
|
|
context.ctx = ctx;
|
|
root.draw(context)
|
|
|
|
// let mouse;
|
|
// if (mouseEvent) {
|
|
// mouse = getMousePos(canvas, mouseEvent);
|
|
// } else {
|
|
// mouse = {x: 0, y: 0}
|
|
// }
|
|
// ctx.fillRect(mouse.x, mouse.y, 50,50)
|
|
}
|
|
// requestAnimationFrame(updateUI)
|
|
} |