feat: widgets can move inside the canvas

This commit is contained in:
paul
2024-09-09 19:06:03 +05:30
parent b7f5ba05f4
commit 5a9346af0b
4 changed files with 299 additions and 100 deletions

View File

@@ -19,8 +19,9 @@ class Canvas extends React.Component {
this.widgetRefs = {}
this.modes = {
DEFAULT: '',
PAN: 'pan',
DEFAULT: 0,
PAN: 1,
MOVE_WIDGET: 2 // when the mode is move widget
}
this.currentMode = this.modes.DEFAULT
@@ -31,14 +32,27 @@ class Canvas extends React.Component {
}
this.state = {
widgets: [],
widgets: [], // don't store the widget directly here, instead store it in widgetRef, else the changes in the widget will re-render the whole canvas
zoom: 1,
isPanning: false,
currentTranslate: { x: 0, y: 0 },
}
this.selectedWidgets = []
this.resetTransforms = this.resetTransforms.bind(this)
this.renderWidget = this.renderWidget.bind(this)
this.mouseDownEvent = this.mouseDownEvent.bind(this)
this.mouseMoveEvent = this.mouseMoveEvent.bind(this)
this.mouseUpEvent = this.mouseUpEvent.bind(this)
this.getWidgets = this.getWidgets.bind(this)
this.getActiveObjects = this.getActiveObjects.bind(this)
this.getWidgetFromTarget = this.getWidgetFromTarget.bind(this)
this.clearSelections = this.clearSelections.bind(this)
this.clearCanvas = this.clearCanvas.bind(this)
// this.updateCanvasDimensions = this.updateCanvasDimensions.bind(this)
}
@@ -46,40 +60,14 @@ class Canvas extends React.Component {
componentDidMount() {
this.initEvents()
// this.widgets.push(new Widget())
// let widgetRef = React.createRef()
// this.widgetRefs[widgetRef.current.__id] = widgetRef
// // Update the state to include the new widget's ID
// this.setState((prevState) => ({
// widgetIds: [...prevState.widgetIds, widgetRef.current.__id]
// }))
this.addWidget(Widget)
}
componentWillUnmount() {
}
mouseDownEvent(event){
this.mousePressed = true
this.mousePos.x = event.e.clientX
this.mousePos.y = event.e.clientY
}
mouseMoveEvent(event){
}
mouseUpEvent(event){
this.mousePressed = false
// NOTE: this will clear the canvas
this.clearCanvas()
}
/**
@@ -92,61 +80,23 @@ class Canvas extends React.Component {
}
/**
* returns list of active objects on the canvas
* returns list of active objects / selected objects on the canvas
* @returns Widget[]
*/
getActiveObjects(){
return this.getWidgets().filter((widget) => {
return widget.isSelected
return Object.values(this.widgetRefs).filter((widgetRef) => {
return widgetRef.current?.isSelected()
})
}
initEvents(){
this.canvasContainerRef.current.addEventListener("mousedown", (event) => {
this.mousePressed = true
this.mousePos = { x: event.clientX, y: event.clientY }
if (this.state.widgets.length > 0){
this.currentMode = this.modes.PAN
this.setCursor(Cursor.GRAB)
}
})
this.canvasContainerRef.current.addEventListener("mouseup", () => {
this.mousePressed = false
this.currentMode = this.modes.DEFAULT
this.setCursor(Cursor.DEFAULT)
})
this.canvasContainerRef.current.addEventListener("mousemove", (event) => {
// console.log("event: ", event)
if (this.mousePressed && this.currentMode === this.modes.PAN) {
const deltaX = event.clientX - this.mousePos.x
const deltaY = event.clientY - this.mousePos.y
this.setState(prevState => ({
currentTranslate: {
x: prevState.currentTranslate.x + deltaX,
y: prevState.currentTranslate.y + deltaY,
}
}), this.applyTransform)
this.mousePos = { x: event.clientX, y: event.clientY }
this.setCursor(Cursor.GRAB)
}
this.canvasContainerRef.current.addEventListener("mousedown", this.mouseDownEvent)
this.canvasContainerRef.current.addEventListener("mouseup", this.mouseUpEvent)
this.canvasContainerRef.current.addEventListener("mousemove", this.mouseMoveEvent)
})
this.canvasContainerRef.current.addEventListener("selection:created", () => {
this.canvasRef.current.addEventListener("selection:created", () => {
console.log("selected")
this.currentMode = this.modes.DEFAULT
})
@@ -162,6 +112,89 @@ class Canvas extends React.Component {
this.canvasRef.current.style.transform = `translate(${currentTranslate.x}px, ${currentTranslate.y}px) scale(${zoom})`
}
/**
* returns the widget that contains the target
* @param {HTMLElement} target
* @returns {Widget}
*/
getWidgetFromTarget(target){
for (let [key, ref] of Object.entries(this.widgetRefs)){
if (ref.current.getElement().contains(target)){
return ref.current
}
}
}
mouseDownEvent(event){
this.mousePressed = true
this.mousePos = { x: event.clientX, y: event.clientY }
let selectedWidget = this.getWidgetFromTarget(event.target)
if (selectedWidget){
// if the widget is selected don't pan, instead move the widget
if (!selectedWidget._disableSelection){
selectedWidget.select()
this.selectedWidgets.push(selectedWidget)
this.currentMode = this.modes.MOVE_WIDGET
}
this.currentMode = this.modes.PAN
}else if (this.state?.widgets?.length > 0){
this.clearSelections()
this.currentMode = this.modes.PAN
this.setCursor(Cursor.GRAB)
}
}
mouseMoveEvent(event){
// console.log("mode: ", this.currentMode, this.getActiveObjects())
if (this.mousePressed && [this.modes.PAN, this.modes.MOVE_WIDGET].includes(this.currentMode)) {
const deltaX = event.clientX - this.mousePos.x
const deltaY = event.clientY - this.mousePos.y
if (this.selectedWidgets.length === 0){
this.setState(prevState => ({
currentTranslate: {
x: prevState.currentTranslate.x + deltaX,
y: prevState.currentTranslate.y + deltaY,
}
}), this.applyTransform)
}else{
// update the widgets position
this.selectedWidgets.forEach(widget => {
const {x, y} = widget.getPos()
const newPosX = x + (deltaX/this.state.zoom) // account for the zoom, since the widget is relative to canvas
const newPosY = y + (deltaY/this.state.zoom) // account for the zoom, since the widget is relative to canvas
widget.setPos(newPosX, newPosY)
})
}
this.mousePos = { x: event.clientX, y: event.clientY }
this.setCursor(Cursor.GRAB)
}
}
mouseUpEvent(event){
this.mousePressed = false
this.currentMode = this.modes.DEFAULT
this.setCursor(Cursor.DEFAULT)
}
wheelZoom(event){
let delta = event.deltaY
let zoom = this.state.zoom * 0.999 ** delta
@@ -242,9 +275,16 @@ class Canvas extends React.Component {
}, this.applyTransform)
}
clearSelections(){
this.getActiveObjects().forEach(widget => {
widget.current?.deSelect()
})
this.selectedWidgets = []
}
/**
*
* @param {Widget} widgetComponentType - don't pass <Widget /> instead pass Widget
* @param {Widget} widgetComponentType - don't pass <Widget /> instead pass Widget object
*/
addWidget(widgetComponentType){
const widgetRef = React.createRef()
@@ -253,18 +293,45 @@ class Canvas extends React.Component {
// Store the ref in the instance variable
this.widgetRefs[id] = widgetRef
console.log("widget ref: ", this.widgetRefs)
// console.log("widget ref: ", this.widgetRefs)
// Update the state to include the new widget's type and ID
this.setState((prevState) => ({
widgets: [...prevState.widgets, { id, type: widgetComponentType }]
widgets: [...prevState.widgets, { id, type: widgetComponentType }]
}))
}
/**
* removes all the widgets from the canvas
*/
clearCanvas(){
for (let [key, value] of Object.entries(this.widgetRefs)){
console.log("removed: ", key, value)
value.current?.remove()
}
this.widgetRefs = {}
this.setState(() => ({
widgets: []
}))
}
removeWidget(widgetId){
this.widgetRefs[widgetId]?.current.remove()
delete this.widgetRefs[widgetId]
// TODO: remove from widgets
// this.setState(() => ({
// widgets: []
// }))
}
renderWidget(widget){
const { id, type: ComponentType } = widget
console.log("widet: ", this.widgetRefs, id)
// console.log("widet: ", this.widgetRefs, id)
return <ComponentType key={id} id={id} ref={this.widgetRefs[id]} />
return <ComponentType key={id} id={id} ref={this.widgetRefs[id]} canvasRef={this.canvasRef} />
}
render() {

View File

@@ -0,0 +1,5 @@
// given a string, converts to a python naming variable (snake case)
export function toSnakeCase(str) {
return str.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_').toLowerCase()
}

View File

@@ -4,6 +4,8 @@ import { NotImplementedError } from "../../utils/errors"
import Tools from "../constants/tools"
import Layouts from "../constants/layouts"
import Cursor from "../constants/cursor"
import { toSnakeCase } from "../utils/utils"
import EditableDiv from "../../components/editableDiv"
@@ -17,13 +19,15 @@ class Widget extends React.Component{
constructor(props){
super(props)
const {id} = props
const {id, widgetName, canvasRef} = props
console.log("Id: ", id)
// this id has to be unique inside the canvas, it will be set automatically and should never be changed
this.__id = id
this._zIndex = 0
this._selected = false
this.canvas = canvasRef?.current || null
// this._selected = false
this._disableResize = false
this._disableSelection = false
@@ -33,7 +37,7 @@ class Widget extends React.Component{
this.elementRef = React.createRef()
this.props = {
this.attrs = {
styling: {
backgroundColor: {
tool: Tools.COLOR_PICKER, // the tool to display, can be either HTML ELement or a constant string
@@ -70,10 +74,21 @@ class Widget extends React.Component{
attrs: { // attributes
// replace this with this.props
},
zIndex: 0
zIndex: 0,
pos: {x: 0, y: 0},
selected: false,
widgetName: widgetName || 'unnamed widget' // this will later be converted to variable name
}
}
this.mousePress = this.mousePress.bind(this)
this.getElement = this.getElement.bind(this)
this.isSelected = this.isSelected.bind(this)
this.getPos = this.getPos.bind(this)
this.setPos = this.setPos.bind(this)
}
setComponentAdded(added=true){
@@ -92,10 +107,22 @@ class Widget extends React.Component{
this.elementRef.current?.removeEventListener("click", this.mousePress)
}
mousePress(event){
// TODO: add context menu items such as delete, add etc
contextMenu(){
}
getVariableName(){
return toSnakeCase(this.state.widgetName)
}
mousePress(event){
// event.preventDefault()
if (!this._disableSelection){
this._selected = true
this.setState((prev) => ({
...prev,
selected: false
}))
const widgetSelected = new CustomEvent("selection:created", {
detail: {
@@ -103,22 +130,44 @@ class Widget extends React.Component{
id: this.__id,
element: this
},
// bubbles: true // Allow the event to bubble up the DOM tree
})
console.log("dispatched")
document.dispatchEvent(widgetSelected)
// document.dispatchEvent(widgetSelected)
// console.log("dispatched", this.canvas)
this.canvas.dispatchEvent(widgetSelected)
}
}
select(){
this._selected = true
this.setState((prev) => ({
...prev,
selected: true
}))
}
deSelect(){
this._selected = false
this.setState((prev) => ({
...prev,
selected: false
}))
}
isSelected(){
return this.state.selected
}
setPos(x, y){
this.setState({
pos: {x: x, y: y}
})
}
getPos(){
return this.state.pos
}
getProps(){
return this.props
return this.attrs
}
getWidgetFunctions(){
@@ -129,6 +178,10 @@ class Widget extends React.Component{
return this.__id
}
getElement(){
return this.elementRef.current
}
renderContent(){
// throw new NotImplementedError("render method has to be implemented")
return (
@@ -146,8 +199,8 @@ class Widget extends React.Component{
let style = {
cursor: this.cursor,
top: "40px",
left: "40px",
top: `${this.state.pos.y}px`,
left: `${this.state.pos.x}px`,
width: this.boundingRect.width,
height: this.boundingRect.height
}
@@ -159,19 +212,41 @@ class Widget extends React.Component{
height: this.boundingRect.height + 5
}
const onWidgetNameChange = (value) => {
this.setState((prev) => ({
...prev,
widgetName: value.length > 0 ? value : prev.widgetName
}))
}
return (
<div data-id={this.__id} ref={this.elementRef} className="tw-relative tw-w-fit tw-h-fit" style={style}
<div data-id={this.__id} ref={this.elementRef} className="tw-relative tw-w-fit tw-h-fit"
style={style}
>
{this.renderContent()}
<div className="tw-absolute tw-bg-transparent tw-scale-[1.1] tw-opacity-35
tw-w-full tw-h-full tw-top-0 tw-border-2 tw-border-solid tw-border-black">
<div className={`tw-absolute tw-bg-transparent tw-scale-[1.1] tw-opacity-100
tw-w-full tw-h-full tw-top-0
${this.state.selected ? 'tw-border-2 tw-border-solid tw-border-blue-500' : 'tw-border-none'}`}>
<div className="">
<div className="tw-relative tw-w-full tw-h-full">
{/* <div contentEditable="true" onClick={(e) => e.preventDefault()} className="tw-text-sm tw-w-fit tw-min-w-[100px] tw-absolute tw--top-2">
{this._widgetName}
</div> */}
{ this.state.selected &&
<EditableDiv value={this.state.widgetName} onChange={onWidgetNameChange}
maxLength={40}
className="tw-text-sm tw-w-fit tw-max-w-[160px] tw-text-clip tw-min-w-[150px]
tw-overflow-hidden tw-absolute tw--top-4"
/>
}
</div>
</div>
</div>
)

View File

@@ -0,0 +1,52 @@
import React, { useState, useRef, useEffect } from 'react'
function EditableDiv({value, onChange, maxLength=Infinity, className='', inputClassName}) {
const [isEditable, setIsEditable] = useState(false)
const [content, setContent] = useState(value)
const inputRef = useRef(null)
useEffect(() => {
setContent(value)
}, [value])
const handleInput = (event) => {
console.log("value: ", event.target.value)
onChange(event.target.value)
}
const handleDoubleClick = () => {
setIsEditable(true)
setTimeout(() => inputRef.current.focus(), 1)
}
const handleBlur = () => {
setIsEditable(false)
}
return (
<div
className={`tw-w-fit ${className}`}
onDoubleClick={handleDoubleClick}
onBlur={handleBlur} // To exit edit mode when clicking outside
>
{!isEditable && <span className="tw-select-none">{content}</span>}
<input type="text" value={content}
onInput={handleInput}
maxLength={maxLength}
ref={inputRef}
className={`${!isEditable && "tw-hidden"}
tw-outline-none tw-bg-transparent
tw-border-none tw-p-1
focus-within:tw-border-[1px]
focus-within:tw-border-blue-500
${inputClassName}`} />
</div>
)
}
export default EditableDiv