feat: widgets can move inside the canvas
This commit is contained in:
@@ -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() {
|
||||
|
||||
5
src/canvas/utils/utils.js
Normal file
5
src/canvas/utils/utils.js
Normal 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()
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
52
src/components/editableDiv.js
Normal file
52
src/components/editableDiv.js
Normal 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
|
||||
Reference in New Issue
Block a user