diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..330fd67 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug report +description: Create a report to help us improve +title: "[BUG]: " +labels: ["bug"] +assignees: + - PaulleDemon +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + + - type: input + id: template-name + attributes: + label: Template name + description: Please tell us which template is the issue related to + placeholder: Template name + value: "Template name" + validations: + required: true + + - type: input + id: browser + attributes: + label: Browser & version + description: Please tell us which browser and version of the browser you are using + placeholder: Browser name & version + value: "Browser name" + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + value: "A bug happened!" + validations: + required: true + + - type: checkboxes + id: related-issues + attributes: + label: This is a new issue + description: I have checked for the same issue in the issues panel + options: + - label: "Yes" + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7397ae0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: New Feature request +description: Have an idea for new feature? well tell us +title: "[FEATURE REQUEST]: " +labels: ["feature request"] +assignees: + - PaulleDemon +body: + - type: markdown + attributes: + value: | + > [!NOTE] + > Please make sure to check the issues tab before requesting a new feature. + + - type: textarea + id: feature-description + attributes: + label: Feature Description + description: "Describe about the feature in brief" + placeholder: "Tell us about the feature request in brief" + value: "Feature description" + validations: + required: true + + - type: textarea + id: functionalities + attributes: + label: Functionalities of the Feature + description: "What should the feature do? eg: a bold button to make the text bold" + placeholder: Functions of the feature + value: "Functions of the feature" + validations: + required: true + + - type: checkboxes + id: related-issues + attributes: + label: This is a new feature request + description: I acknowledge that I have checked the issues section for similar request and found none. + options: + - label: "Yes" + required: true + + - type: markdown + attributes: + value: | + The feature request priority are based on number of reaction, a good number of thumbs up will make it to priority queue \ No newline at end of file diff --git a/.github/funding.yaml b/.github/funding.yaml new file mode 100644 index 0000000..9e2ff5b --- /dev/null +++ b/.github/funding.yaml @@ -0,0 +1,3 @@ +ko_fi: artpaul +github: [PaulleDemon] +buy_me_a_coffee: artpaul \ No newline at end of file diff --git a/License.txt b/License.txt new file mode 100644 index 0000000..a3527e1 --- /dev/null +++ b/License.txt @@ -0,0 +1,23 @@ +License held by paul +Github username: PaulleDemon + +1. License Grant +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, publish, and distribute the source code, subject to the following conditions: + +2. Derived Work License +Any modifications, derivative works, or copies of the Software must retain this license. All derivative works must be open-sourced under the same license terms. You are not permitted to relicense or sub-license any portion of the Software under a different license without explicit prior written permission from the original author. + +3. Distribution Restriction on Executables +You are not permitted to distribute the Software in compiled, executable, or binary form without prior written consent from the original author. This applies to both original and derivative works. + +4. Commercial Use Restriction +The Software, in its original or modified form, cannot be used for any commercial purpose without prior written permission from the original author. This includes selling, licensing, or offering the Software as part of a service. + +5. Non-Commercial Use +You may freely use, copy, modify, and distribute the source code of the Software for non-commercial purposes, provided that the same terms of this license are maintained in all copies or derivative works. + +6. Changes to the License +The original author reserves the right to modify, amend, or update this license at any time. Any changes will apply only to future versions of the Software. Any prior versions of the Software, including derivative works, will continue to be governed by the version of the license in effect at the time the work was created. + +7. Disclaimer +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 49f4b19..340fe9b 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# TkBuilder +# PyUIBuilder - The only Python GUI builder you'll ever need + + + + + + + +## Features +* Framework agnostic - Can outputs code in multiple frameworks. +* Easy to use. +* Plugins to extend 3rd party UI libraries +* Generate Code. + +## Roadmap + + +### All code generated by the builder tools are under MIT license and can be used commercially \ No newline at end of file diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..e0c1c09 --- /dev/null +++ b/notes.md @@ -0,0 +1 @@ +### State management in react is a f*king mess \ No newline at end of file diff --git a/public/index.html b/public/index.html index aa069f2..376e91b 100644 --- a/public/index.html +++ b/public/index.html @@ -15,6 +15,7 @@ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> + - React App + PyUI builder diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..786ef74 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,32 @@ +## Road map for PyUIBuilder + +Any feature that has 👑 beside it, is meant only for [premium users](./readme.md#license--support) + +### 1.0.0 +- [x] Create the initial version for UI builder + +### 1.2.0 +- [ ] UI fixes and enhancement +- [ ] Tree view for elements on the canvas +- [ ] Add text editor to support event handlers +- [ ] Rewrite DND for better feedback - (swappy/react-dnd-kit/ GSAP draggable) +- [ ] Duplicate widgets + +### 1.5.0 +- [ ] Add canvas support tools (lines, rect etc) (try fabricjs) +- [ ] Initial version for Electron App 👑 +- [ ] Save files locally 👑 +- [ ] Load UI files 👑 +- [ ] Light/Dark theme 👑 +- [ ] Run the preview 👑 + +### 2.0.0 +- [ ] Support for more third party plugins +- [ ] Support more templates +- [ ] Support for Kivy +- [ ] Sharable Templates +- [ ] Dark theme 👑 + + +### 3.0.0 +- [ ] Support for PySide / PyQt 👑 (commercial license only) \ No newline at end of file diff --git a/src/App.js b/src/App.js index 6f56097..bd8d389 100644 --- a/src/App.js +++ b/src/App.js @@ -12,6 +12,9 @@ import WidgetsContainer from './sidebar/widgetsContainer' import Widget from './canvas/widgets/base' import { DraggableWidgetCard } from './components/cards' +import { DragProvider } from './components/draggable/draggableContext' +import { ActiveWidgetProvider } from './canvas/activeWidgetContext' +import TkinterSidebar from './frameworks/tkinter/sidebarWidgets' function App() { @@ -28,7 +31,7 @@ function App() { const [dropAnimation, setDropAnimation] = useState(null) - const [sidebarWidgets, setSidebarWidgets] = useState([]) + const [sidebarWidgets, setSidebarWidgets] = useState(TkinterSidebar || []) const [canvasWidgets, setCanvasWidgets] = useState([]) // contains the reference to the widgets inside the canvas const [activeSidebarWidget, setActiveSidebarWidget] = useState(null) // helps with the dnd overlay @@ -41,7 +44,7 @@ function App() { { name: "Widgets", icon: , - content: setSidebarWidgets(widgets)}/> + content: setSidebarWidgets(widgets)}/> }, { name: "Plugins", @@ -95,15 +98,6 @@ function App() { return } setDropAnimation(null) - - // FIXME: drop offset is not correct - // Calculate the dragged item's bounding rectangle - const itemRect = activeItemElement.getBoundingClientRect(); - const itemCenterX = itemRect.left + itemRect.width / 2 - const itemCenterY = itemRect.top + itemRect.height / 2 - - console.log("widget overlay: ", delta, itemRect) - // Get widget dimensions (assuming you have a way to get these) const widgetWidth = activeItemElement.offsetWidth; // Adjust this based on how you get widget size @@ -114,11 +108,6 @@ function App() { const canvasTranslate = canvasRef.current.getCanvasTranslation() const zoom = canvasRef.current.getZoom() - // let finalPosition = { - // x: (delta.x - canvasContainerRect.x - canvasTranslate.x) / zoom, - // y: (delta.y - canvasContainerRect.y - canvasTranslate.y) / zoom, - // } - let finalPosition = { x: (initialPosition.x + delta.x - canvasContainerRect.x - canvasTranslate.x) / zoom - (widgetWidth / 2), y: (initialPosition.y + delta.y - canvasContainerRect.y - canvasTranslate.y) / zoom - (widgetHeight / 2), @@ -151,32 +140,18 @@ function App() {
- +
+ + {/* */} + {/* */}
{/* dragOverlay (dnd-kit) helps move items from one container to another */} - - {activeSidebarWidget ? ( - - ): - null} - -
+
) } diff --git a/src/assets/background/dots.svg b/src/assets/background/dots.svg index e8b0530..836be93 100644 --- a/src/assets/background/dots.svg +++ b/src/assets/background/dots.svg @@ -1,7 +1,7 @@ - - + + diff --git a/src/assets/logo/bmc.svg b/src/assets/logo/bmc.svg new file mode 100644 index 0000000..edddb83 --- /dev/null +++ b/src/assets/logo/bmc.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/assets/logo/ko-fi.png b/src/assets/logo/ko-fi.png new file mode 100644 index 0000000..fdcb58c Binary files /dev/null and b/src/assets/logo/ko-fi.png differ diff --git a/src/canvas/activeWidgetContext.js b/src/canvas/activeWidgetContext.js new file mode 100644 index 0000000..f82ed90 --- /dev/null +++ b/src/canvas/activeWidgetContext.js @@ -0,0 +1,61 @@ +import React, { createContext, Component, useContext, useState, createRef, useMemo } from 'react' + + + +// NOTE: using this context provider causes many re-rendering when the canvas is panned the toolbar updates unnecessarily + + +// Create the Context +export const ActiveWidgetContext = createContext() + +// Create the Provider component +export class ActiveWidgetProvider extends Component { + state = { + activeWidgetId: null, + activeWidgetAttrs: {} + } + + updateActiveWidget = (widget) => { + this.setState({ activeWidgetId: widget }) + } + + updateToolAttrs = (widgetAttrs) => { + this.setState({activeWidgetAttrs: widgetAttrs}) + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +// Custom hook for function components +export const useActiveWidget = () => { + const context = useContext(ActiveWidgetContext) + if (context === undefined) { + throw new Error('useActiveWidget must be used within an ActiveWidgetProvider') + } + return useMemo(() => context, [context.activeWidgetId, context.activeWidgetAttrs, context.updateToolAttrs, context.updateActiveWidget]) +} + +// Higher-Order Component for class-based components +export const withActiveWidget = (WrappedComponent) => { + return class extends Component { + + render() { + return ( + + {context => } + + ) + } + } +} + + + diff --git a/src/canvas/canvas.js b/src/canvas/canvas.js index 7ab41e7..9accaab 100644 --- a/src/canvas/canvas.js +++ b/src/canvas/canvas.js @@ -1,11 +1,11 @@ import React from "react" -import {DndContext} from '@dnd-kit/core' +import { DndContext } from '@dnd-kit/core' import { CloseOutlined, DeleteOutlined, EditOutlined, FullscreenOutlined, ReloadOutlined } from "@ant-design/icons" import { Button, Tooltip, Dropdown } from "antd" -import Droppable from "../components/utils/droppable" +import Droppable from "../components/utils/droppableDnd" import Widget from "./widgets/base" import Cursor from "./constants/cursor" @@ -17,10 +17,19 @@ import { removeDuplicateObjects } from "../utils/common" import { WidgetContext } from './context/widgetContext' // import {ReactComponent as DotsBackground} from "../assets/background/dots.svg" -import DotsBackground from "../assets/background/dots.svg" +// import DotsBackground from "../assets/background/dots.svg" +import { ReactComponent as DotsBackground } from "../assets/background/dots.svg" + +import DroppableWrapper from "../components/draggable/droppable" +import { ActiveWidgetContext, ActiveWidgetProvider, withActiveWidget } from "./activeWidgetContext" +import { DragWidgetProvider } from "./widgets/draggableWidgetContext" +import { PosType } from "./constants/layouts" +import WidgetContainer from "./constants/containers" +import { isSubClassOfWidget } from "../utils/widget" // const DotsBackground = require("../assets/background/dots.svg") + const CanvasModes = { DEFAULT: 0, PAN: 1, @@ -30,20 +39,20 @@ const CanvasModes = { class Canvas extends React.Component { + // static contextType = ActiveWidgetContext + constructor(props) { super(props) const { canvasWidgets, onWidgetListUpdated } = props - - this.canvasRef = React.createRef() + + this.canvasRef = React.createRef() this.canvasContainerRef = React.createRef() - this.widgetRefs = {} // stores the actual refs to the widgets inside the canvas - this.currentMode = CanvasModes.DEFAULT - this.minCanvasSize = {width: 500, height: 500} + this.minCanvasSize = { width: 500, height: 500 } this.mousePressed = false this.mousePos = { @@ -52,18 +61,20 @@ class Canvas extends React.Component { } // this._contextMenuItems = [] + this.widgetRefs = {} // stores the actual refs to the widgets inside the canvas {id: ref, id2, ref2...} this.state = { - widgets: [], // don't store the refs directly here, instead store it in widgetRef, store the widget type here + widgetResizing: "", // set this to "nw", "sw" etc based on the side when widgets resizing handles are selected + widgets: [], // stores the mapping to widgetRefs, stores id and WidgetType, later used for rendering [{id: , widgetType: WidgetClass, children: [], parent: "", initialData: {}}] zoom: 1, isPanning: false, currentTranslate: { x: 0, y: 0 }, - canvasSize: { width: 500, height: 500 }, - + canvasSize: { width: 500, height: 500 }, + contextMenuItems: [], - selectedWidgets: [], - - toolbarOpen: true, + selectedWidget: null, + + toolbarOpen: true, toolbarAttrs: null } @@ -71,10 +82,12 @@ class Canvas extends React.Component { 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.keyDownEvent = this.keyDownEvent.bind(this) + this.wheelZoom = this.wheelZoom.bind(this) this.onActiveWidgetUpdate = this.onActiveWidgetUpdate.bind(this) @@ -84,7 +97,9 @@ class Canvas extends React.Component { this.getCanvasObjectsBoundingBox = this.getCanvasObjectsBoundingBox.bind(this) this.fitCanvasToBoundingBox = this.fitCanvasToBoundingBox.bind(this) - this.getCanvasBoundingRect = this.getCanvasContainerBoundingRect.bind(this) + + this.getCanvasContainerBoundingRect = this.getCanvasContainerBoundingRect.bind(this) + this.getCanvasBoundingRect = this.getCanvasBoundingRect.bind(this) this.setSelectedWidget = this.setSelectedWidget.bind(this) this.deleteSelectedWidgets = this.deleteSelectedWidgets.bind(this) @@ -92,28 +107,57 @@ class Canvas extends React.Component { this.clearSelections = this.clearSelections.bind(this) this.clearCanvas = this.clearCanvas.bind(this) + this.createWidget = this.createWidget.bind(this) + // this.updateCanvasDimensions = this.updateCanvasDimensions.bind(this) } componentDidMount() { this.initEvents() - this.addWidget(Widget) + this.createWidget(Widget) } componentWillUnmount() { - + + this.canvasContainerRef.current.removeEventListener("mousedown", this.mouseDownEvent) + this.canvasContainerRef.current.removeEventListener("mouseup", this.mouseUpEvent) + this.canvasContainerRef.current.removeEventListener("mousemove", this.mouseMoveEvent) + this.canvasContainerRef.current.removeEventListener("wheel", this.wheelZoom) + + this.canvasContainerRef.current.removeEventListener("keydown", this.keyDownEvent) + + // NOTE: this will clear the canvas this.clearCanvas() } + initEvents() { + + 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("wheel", this.wheelZoom) + + + this.canvasContainerRef.current.addEventListener("keydown", this.keyDownEvent, true) + // window.addEventListener("keydown", this.keyDownEvent, true) + + + } + + applyTransform() { + const { currentTranslate, zoom } = this.state + this.canvasRef.current.style.transform = `translate(${currentTranslate.x}px, ${currentTranslate.y}px) scale(${zoom})` + } + /** - * - * @returns {import("./widgets/base").Widget[]} - */ - getWidgets(){ - + * + * @returns {import("./widgets/base").Widget[]} + */ + getWidgets() { + return this.state.widgets } @@ -121,84 +165,105 @@ class Canvas extends React.Component { * returns list of active objects / selected objects on the canvas * @returns Widget[] */ - getActiveObjects(){ + getActiveObjects() { return Object.values(this.widgetRefs).filter((widgetRef) => { return widgetRef.current?.isSelected() }) } - initEvents(){ - - 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('wheel', (event) => { - this.wheelZoom(event) - }) - - } - - applyTransform(){ - const { currentTranslate, zoom } = this.state - 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){ + getWidgetFromTarget(target) { + // TODO: improve search, currently O(n), but can be improved via this.state.widgets or something - for (let [key, ref] of Object.entries(this.widgetRefs)){ - if (ref.current.getElement().contains(target)){ - return ref.current + let innerWidget = null + for (let [key, ref] of Object.entries(this.widgetRefs)) { + + if (ref.current === target){ + innerWidget = ref.current + break } + + if (ref.current.getElement().contains(target)) { + + if (!innerWidget) { + innerWidget = ref.current + } else if (innerWidget.getElement().contains(ref.current.getElement())) { + // If the current widget is deeper than the existing innermost widget, update innerWidget + innerWidget = ref.current + } + } + } + + return innerWidget + } + + keyDownEvent(event) { + + if (event.key === "Delete") { + this.deleteSelectedWidgets() + } + + if (event.key === "+") { + this.setZoom(this.state.zoom + 0.1) + } + + if (event.key === "-") { + this.setZoom(this.state.zoom - 0.1) } } - - mouseDownEvent(event){ + mouseDownEvent(event) { this.mousePos = { x: event.clientX, y: event.clientY } - + let selectedWidget = this.getWidgetFromTarget(event.target) - if (event.button === 0){ + // console.log("selected widget: ", selectedWidget) + if (event.button === 0) { this.mousePressed = true - - if (selectedWidget){ + + if (selectedWidget) { // if the widget is selected don't pan, instead move the widget - if (!selectedWidget._disableSelection){ + if (!selectedWidget._disableSelection) { + // console.log("selected widget2: ", selectedWidget.getId(), this.state.selectedWidget?.getId()) - const selectedLength = this.state.selectedWidgets.length + this.state.selectedWidget?.deSelect() // deselect the previous widget before adding the new one + this.state.selectedWidget?.setZIndex(0) + selectedWidget.setZIndex(1000) + selectedWidget.select() + this.setState({ + selectedWidget: selectedWidget, + toolbarAttrs: selectedWidget.getToolbarAttrs() + }) - // console.log("selected widget: ", selectedWidget) - - if (selectedLength === 0 || (selectedLength === 1 && selectedWidget.__id !== this.state.selectedWidgets[0].__id)){ - this.state.selectedWidgets[0]?.deSelect() // deselect the previous widget before adding the new one - this.state.selectedWidgets[0]?.setZIndex(0) - - selectedWidget.setZIndex(1000) - selectedWidget.select() - - this.setState({ - selectedWidgets: [selectedWidget], - toolbarAttrs: selectedWidget.getToolbarAttrs() - }) - } + // if (!this.state.selectedWidget || (selectedWidget.getId() !== this.state.selectedWidget?.getId())) { + // this.state.selectedWidget?.deSelect() // deselect the previous widget before adding the new one + // this.state.selectedWidget?.setZIndex(0) + // console.log("working: ", this.state.selectedWidget) + // selectedWidget.setZIndex(1000) + // selectedWidget.select() + // console.log("widget selected") + // this.setState({ + // selectedWidget: selectedWidget, + // toolbarAttrs: selectedWidget.getToolbarAttrs() + // }) + // } this.currentMode = CanvasModes.MOVE_WIDGET } this.currentMode = CanvasModes.PAN - }else if (!selectedWidget){ + } else if (!selectedWidget) { // get the canvas ready to pan, if there are widgets on the canvas this.clearSelections() this.currentMode = CanvasModes.PAN this.setCursor(Cursor.GRAB) - + // console.log("clear selection") } this.setState({ @@ -208,17 +273,17 @@ class Canvas extends React.Component { // this.setState({ // showContextMenu: false // }) - }else if (event.button === 2){ + } else if (event.button === 2) { //right click - - if (this.state.selectedWidgets.length > 0 && this.state.selectedWidgets[0].__id !== selectedWidget.__id){ + + if (this.state.selectedWidget && this.state.selectedWidget.__id !== selectedWidget.__id) { this.clearSelections() } - if (selectedWidget){ + if (selectedWidget) { this.setState({ - selectedWidget: [selectedWidget], + selectedWidget: selectedWidget, contextMenuItems: [ { key: "rename", @@ -233,21 +298,27 @@ class Canvas extends React.Component { } ] }) - + } } } - mouseMoveEvent(event){ + mouseMoveEvent(event) { + + if (this.state.widgetResizing !== "") { + // if resizing is taking place don't do anything else + this.handleResize(event) + return + } // console.log("mode: ", this.currentMode, this.getActiveObjects()) if (this.mousePressed && [CanvasModes.PAN, CanvasModes.MOVE_WIDGET].includes(this.currentMode)) { const deltaX = event.clientX - this.mousePos.x const deltaY = event.clientY - this.mousePos.y - - if (this.state.selectedWidgets.length === 0){ + + if (!this.state.selectedWidget) { // if there aren't any selected widgets, then pan the canvas this.setState(prevState => ({ currentTranslate: { @@ -256,65 +327,116 @@ class Canvas extends React.Component { } }), this.applyTransform) - }else{ + } else { // update the widgets position - this.state.selectedWidgets.forEach(widget => { - const {x, y} = widget.getPos() + // this.state.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) - }) + // 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.mousePos = { x: event.clientX, y: event.clientY } - this.setCursor(Cursor.GRAB) - } + this.setCursor(Cursor.GRAB) + } } - mouseUpEvent(event){ + mouseUpEvent(event) { this.mousePressed = false this.currentMode = CanvasModes.DEFAULT this.setCursor(Cursor.DEFAULT) + + if (this.state.widgetResizing) { + this.setState({ widgetResizing: "" }) + } + + for (let [key, widget] of Object.entries(this.widgetRefs)) { + // since the mouseUp event is not triggered inside the widget once its outside, + // we'll need a global mouse up event to re-enable drag + widget.current.enableDrag() + } } - wheelZoom(event){ + wheelZoom(event) { let delta = event.deltaY let zoom = this.state.zoom * 0.999 ** delta - - this.setZoom(zoom, {x: event.offsetX, y: event.offsetY}) - } - getCanvasContainerBoundingRect(){ - return this.canvasContainerRef.current.getBoundingClientRect() - } - - getCanvasBoundingRect(){ - return this.canvasRef.current.getBoundingClientRect() - } - - getCanvasTranslation(){ - return this.state.currentTranslate + this.setZoom(zoom, { x: event.offsetX, y: event.offsetY }) } /** - * Given a position relative to canvas container, - * returns the position relative to the canvas + * handles widgets resizing + * @param {MouseEvent} event - mouse move event + * @returns */ - getRelativePositionToCanvas(x, y){ + handleResize = (event) => { + if (this.state.resizing === "") return - const canvasRect = this.canvasRef.current.getBoundingClientRect() - let zoom = this.state.zoom + const widget = this.state.selectedWidget - return {x: (canvasRect.left - x ), y: (canvasRect.top - y)} + if (!widget) return + const resizeCorner = this.state.widgetResizing + const size = widget.getSize() + const pos = widget.getPos() + + const deltaX = event.movementX + const deltaY = event.movementY + + let newSize = { ...size } + let newPos = { ...pos } + + const { width: minWidth, height: minHeight } = widget.minSize + const { width: maxWidth, height: maxHeight } = widget.maxSize + // console.log("resizing: ", deltaX, deltaY, event) + + switch (resizeCorner) { + case "nw": + newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width - deltaX)) + newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height - deltaY)) + newPos.x += (newSize.width !== size.width) ? deltaX : 0 + newPos.y += (newSize.height !== size.height) ? deltaY : 0 + break + case "ne": + newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width + deltaX)) + newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height - deltaY)) + newPos.y += (newSize.height !== size.height) ? deltaY : 0 + break + case "sw": + newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width - deltaX)) + newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height + deltaY)) + newPos.x += (newSize.width !== size.width) ? deltaX : 0 + break + case "se": + newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width + deltaX)) + newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height + deltaY)) + break + default: + break + } + + widget.setResize(newPos, newSize) + } + + getCanvasContainerBoundingRect() { + return this.canvasContainerRef.current.getBoundingClientRect() + } + + getCanvasBoundingRect() { + return this.canvasRef.current.getBoundingClientRect() + } + + getCanvasTranslation() { + return this.state.currentTranslate } /** * fits the canvas size to fit the widgets bounding box */ - fitCanvasToBoundingBox(padding=0){ + fitCanvasToBoundingBox(padding = 0) { const { top, left, right, bottom } = this.getCanvasObjectsBoundingBox() const width = right - left @@ -334,43 +456,41 @@ class Canvas extends React.Component { canvasStyle.top = `${top - padding}px` } - setCursor(cursor){ + setCursor(cursor) { this.canvasContainerRef.current.style.cursor = cursor } - setZoom(zoom, pos={x:0, y:0}){ - - // if (zoom < 0.5 || zoom > 2){ - // return - // } + setZoom(zoom, pos) { const { currentTranslate } = this.state - - // Calculate the new translation to zoom into the mouse position - const offsetX = pos.x - (this.canvasContainerRef.current.clientWidth / 2 + currentTranslate.x) - const offsetY = pos.y - (this.canvasContainerRef.current.clientHeight / 2 + currentTranslate.y) - - const newTranslateX = currentTranslate.x - offsetX * (zoom - this.state.zoom) - const newTranslateY = currentTranslate.y - offsetY * (zoom - this.state.zoom) - - this.setState({ - zoom: Math.max(0.5, Math.min(zoom, 1.5)), // clamp between 0.5 and 1.5 - currentTranslate: { + let newTranslate = currentTranslate + + if (pos) { + // Calculate the new translation to zoom into the mouse position + const offsetX = pos.x - (this.canvasContainerRef.current.clientWidth / 2 + currentTranslate.x) + const offsetY = pos.y - (this.canvasContainerRef.current.clientHeight / 2 + currentTranslate.y) + + const newTranslateX = currentTranslate.x - offsetX * (zoom - this.state.zoom) + const newTranslateY = currentTranslate.y - offsetY * (zoom - this.state.zoom) + newTranslate = { x: newTranslateX, y: newTranslateY } + } + + this.setState({ + zoom: Math.max(0.5, Math.min(zoom, 1.5)), // clamp between 0.5 and 1.5 + currentTranslate: newTranslate }, this.applyTransform) - // this.canvasRef.current.style.width = `${100/zoom}%` - // this.canvasRef.current.style.height = `${100/zoom}%` } - getZoom(){ + getZoom() { return this.state.zoom } - + resetTransforms() { this.setState({ zoom: 1, @@ -378,17 +498,24 @@ class Canvas extends React.Component { }, this.applyTransform) } - setSelectedWidget(selectedWidget){ + setSelectedWidget(selectedWidget) { this.setState({ selectedWidget: [selectedWidget] }) } - clearSelections(){ + clearSelections() { + + if (!this.state.selectedWidget) + return + this.getActiveObjects().forEach(widget => { widget.current?.deSelect() }) + // this.context?.updateActiveWidget("") + // this.context.updateToolAttrs({}) + this.setState({ - selectedWidgets: [], + selectedWidget: null, toolbarAttrs: null, // toolbarOpen: }) @@ -399,7 +526,7 @@ class Canvas extends React.Component { * returns tha combined bounding rect of all the widgets on the canvas * */ - getCanvasObjectsBoundingBox(){ + getCanvasObjectsBoundingBox() { // Initialize coordinates to opposite extremes let top = Number.POSITIVE_INFINITY @@ -415,77 +542,235 @@ class Canvas extends React.Component { if (rect.right > right) right = rect.right if (rect.bottom > bottom) bottom = rect.bottom } - + return { top, left, right, bottom } } + + /** + * finds widgets from the list of this.state.widgets, also checks the children to find the widgets + * @param {string} widgetId + * @returns + */ + findWidgetFromListById = (widgetId) => { + + const searchWidgetById = (widgets, widgetId) => { + for (let widget of widgets) { + if (widget.id === widgetId) { + return widget + } + + // Recursively search in children + if (widget.children.length > 0) { + const foundInChildren = searchWidgetById(widget.children, widgetId) + if (foundInChildren) { + return foundInChildren // Found in children + } + } + } + return null // Widget not found + } + + return searchWidgetById(this.state.widgets, widgetId) + } + + /** + * Finds the widget from the list and removes it from its current position, even if the widget is in the child position + * @param {Array} widgets - The current list of widgets + * @param {string} widgetId - The ID of the widget to remove + * @returns {Array} - The updated widgets list + */ + removeWidgetFromCurrentList = (widgetId) => { + + function recursiveRemove(objects) { + return objects + .map(obj => { + if (obj.id === widgetId) { + return null // Remove the object + } + // Recursively process children + if (obj.children && obj.children.length > 0) { + obj.children = recursiveRemove(obj.children).filter(Boolean) + } + return obj + }) + .filter(Boolean) // Remove any nulls from the array + } + + // Start the recursive removal from the top level + return recursiveRemove(this.state.widgets) + + } + + // Helper function for recursive update + updateWidgetRecursively = (widgets, updatedParentWidget, updatedChildWidget) => { + return widgets.map(widget => { + if (widget.id === updatedParentWidget.id) { + return updatedParentWidget // Update the parent widget + } else if (widget.id === updatedChildWidget.id) { + return updatedChildWidget // Update the child widget + } else if (widget.children && widget.children.length > 0) { + // Recursively update the children if they exist + return { + ...widget, + children: this.updateWidgetRecursively(widget.children, updatedParentWidget, updatedChildWidget) + } + } else { + return widget // Leave other widgets unchanged + } + }) + } + + /** + * Adds the child into the children attribute inside the this.widgets list of objects + * // widgets data structure { id, widgetType: widgetComponentType, children: [], parent: "" } + * @param {string} parentWidgetId + * @param {object} dragElement + * @param {boolean} create - if create is set to true the widget will be created before adding to the child tree + */ + handleAddWidgetChild = ({ parentWidgetId, dragElementID, swap = false }) => { + + // TODO: creation of the child widget if its not created + // widgets data structure { id, widgetType: widgetComponentType, children: [], parent: "" } + const dropWidgetObj = this.findWidgetFromListById(parentWidgetId) + // Find the dragged widget object + let dragWidgetObj = this.findWidgetFromListById(dragElementID) + + console.log("Drag widget obj: ", dragWidgetObj) + + if (dropWidgetObj && dragWidgetObj) { + const dragWidget = this.widgetRefs[dragWidgetObj.id] + const dragData = dragWidget.current.serialize() + + if (swap) { + // If swapping, we need to find the common parent + const grandParentWidgetObj = this.findWidgetFromListById(dropWidgetObj.parent) + // console.log("parent widget: ", grandParentWidgetObj, dropWidgetObj, this.state.widgets) + if (grandParentWidgetObj) { + // Find the indices of the dragged and drop widgets in the grandparent's children array + const dragIndex = grandParentWidgetObj.children.findIndex(child => child.id === dragElementID) + const dropIndex = grandParentWidgetObj.children.findIndex(child => child.id === parentWidgetId) + + if (dragIndex !== -1 && dropIndex !== -1) { + // Swap their positions + let childrenCopy = [...grandParentWidgetObj.children] + const temp = childrenCopy[dragIndex] + childrenCopy[dragIndex] = childrenCopy[dropIndex] + childrenCopy[dropIndex] = temp + + // Update the grandparent with the swapped children + const updatedGrandParentWidget = { + ...grandParentWidgetObj, + children: childrenCopy + } + + // Update the state with the new widget hierarchy + this.setState((prevState) => ({ + widgets: this.updateWidgetRecursively(prevState.widgets, updatedGrandParentWidget) + })) + } + } + } else { + // Non-swap mode: Add the dragged widget as a child of the drop widget + let updatedWidgets = this.removeWidgetFromCurrentList(dragElementID) + + const updatedDragWidget = { + ...dragWidgetObj, + parent: dropWidgetObj.id, // Keep the parent reference + initialData: { + ...dragData, + positionType: PosType.NONE, + zIndex: 0, + widgetContainer: WidgetContainer.WIDGET + } + } + + const updatedDropWidget = { + ...dropWidgetObj, + children: [...dropWidgetObj.children, updatedDragWidget] + } + + + // Recursively update the widget structure + updatedWidgets = this.updateWidgetRecursively(updatedWidgets, updatedDropWidget, updatedDragWidget) + + // Update the state with the new widget hierarchy + this.setState({ + widgets: updatedWidgets + }) + } + } + + } + /** * - * @param {Widget} widgetComponentType - don't pass instead pass Widget object + * @param {Widget} widgetComponentType - don't pass instead pass Widget object/class */ - addWidget(widgetComponentType, callback){ + createWidget(widgetComponentType, callback) { + + if (!isSubClassOfWidget(widgetComponentType)){ + throw new Error("widgetComponentType must be a subclass of Widget class") + } + const widgetRef = React.createRef() const id = `${widgetComponentType.widgetType}_${UID()}` // Store the ref in the instance variable this.widgetRefs[id] = widgetRef - // console.log("widget ref: ", this.widgetRefs) - const widgets = [...this.state.widgets, { id, widgetType: widgetComponentType }] // don't add the widget refs in the state + const newWidget = { + id, + widgetType: widgetComponentType, + children: [], + parent: "", + initialData: {} // useful for serializing and deserializing (aka, saving and loading) + } + + const widgets = [...this.state.widgets, newWidget] // don't add the widget refs in the state + // Update the state to include the new widget's type and ID this.setState({ widgets: widgets }, () => { if (callback) - callback({id, widgetRef}) + callback({ id, widgetRef }) if (this._onWidgetListUpdated) - this._onWidgetListUpdated(widgets) + this._onWidgetListUpdated(widgets) // inform the parent container }) - - - return {id, widgetRef} + return { id, widgetRef } } - deleteSelectedWidgets(widgets=[]){ + getWidgetById(id) { + + return this.widgetRefs[id] + } + + /** + * delete's the selected widgets from the canvas + * @param {null|Widget} widgets - optional widgets that can be deleted along the selected widgets + */ + deleteSelectedWidgets(widgets = []) { + + let activeWidgets = removeDuplicateObjects([...widgets, this.state.selectedWidget], "__id") - - let activeWidgets = removeDuplicateObjects([...widgets, ...this.state.selectedWidgets], "__id") - const widgetIds = activeWidgets.map(widget => widget.__id) - for (let widgetId of widgetIds){ - - // this.widgetRefs[widgetId]?.current.remove() - delete this.widgetRefs[widgetId] - - this.setState((prevState) => ({ - widgets: prevState.widgets.filter(widget => widget.id !== widgetId) - }), () => { - - if (this._onWidgetListUpdated) - this._onWidgetListUpdated(this.state.widgets) - }) - // value.current?.remove() + for (let widgetId of widgetIds) { + this.removeWidget(widgetId) } - - } /** * removes all the widgets from the canvas */ - clearCanvas(){ + clearCanvas() { // NOTE: Don't remove from it using remove() function since, it already removed from the DOM tree when its removed from widgets - // for (let [key, value] of Object.entries(this.widgetRefs)){ - // console.log("removed: ", value, value.current?.getElement()) - - // value.current?.remove() - // } this.widgetRefs = {} this.setState({ @@ -496,13 +781,12 @@ class Canvas extends React.Component { this._onWidgetListUpdated([]) } - removeWidget(widgetId){ + removeWidget(widgetId) { + - // this.widgetRefs[widgetId]?.current.remove() delete this.widgetRefs[widgetId] - const widgets = this.state.widgets.filter(widget => widget.id !== widgetId) - + const widgets = this.removeWidgetFromCurrentList(widgetId) this.setState({ widgets: widgets }) @@ -511,73 +795,220 @@ class Canvas extends React.Component { this._onWidgetListUpdated(widgets) } - onActiveWidgetUpdate(widgetId){ + onActiveWidgetUpdate(widgetId) { - if (this.state.selectedWidgets.length === 0 || widgetId !== this.state.selectedWidgets[0].__id) + if (!this.state.selectedWidget || widgetId !== this.state.selectedWidget.__id) return - // console.log("updating...") + // console.log("updating...", this.state.toolbarAttrs, this.state.selectedWidget.getToolbarAttrs()) + + // console.log("attrs: ", this.state.selectedWidgets.at(0).getToolbarAttrs()) this.setState({ - toolbarAttrs: this.state.selectedWidgets.at(0).getToolbarAttrs() + toolbarAttrs: this.state.selectedWidget.getToolbarAttrs() }) } - renderWidget(widget){ - const { id, widgetType: ComponentType } = widget - // console.log("widet: ", this.widgetRefs, id) - - return + /** + * Handles drop event to canvas from the sidebar and on canvas widget movement + * @param {DragEvent} e + */ + handleDropEvent = (e, draggedElement, widgetClass=null) => { + + e.preventDefault() + + const container = draggedElement.getAttribute("data-container") + const canvasRect = this.canvasRef.current.getBoundingClientRect() + + const draggedElementRect = draggedElement.getBoundingClientRect() + const elementWidth = draggedElementRect.width + const elementHeight = draggedElementRect.height + + const { clientX, clientY } = e + + let finalPosition = { + x: (clientX - canvasRect.left) / this.state.zoom, + y: (clientY - canvasRect.top) / this.state.zoom, + } + + + + if (container === WidgetContainer.SIDEBAR) { + + if (!widgetClass){ + throw new Error("WidgetClass has to be passed for widgets dropped from sidebar") + } + + // TODO: handle drop from sidebar + // if the widget is being dropped from the sidebar, use the info to create the widget first + this.createWidget(Widget, ({ id, widgetRef }) => { + widgetRef.current.setPos(finalPosition.x, finalPosition.y) + }) + + } else if ([WidgetContainer.CANVAS, WidgetContainer.WIDGET].includes(container)) { + + // snaps to center + finalPosition = { + x: (clientX - canvasRect.left) / this.state.zoom - (elementWidth / 2) / this.state.zoom, + y: (clientY - canvasRect.top) / this.state.zoom - (elementHeight / 2) / this.state.zoom, + } + + let widgetId = draggedElement.getAttribute("data-widget-id") + + const widgetObj = this.getWidgetById(widgetId) + // console.log("WidgetObj: ", widgetObj) + if (container === WidgetContainer.CANVAS) { + + widgetObj.current.setPos(finalPosition.x, finalPosition.y) + + } else if (container === WidgetContainer.WIDGET) { + + // if the widget was inside another widget move it outside + let childWidgetObj = this.findWidgetFromListById(widgetObj.current.getId()) + let parentWidgetObj = this.findWidgetFromListById(childWidgetObj.parent) + + const childData = widgetObj.current.serialize() // save the data and pass it the updated child object + + // remove child from current position + + // console.log("pre updated widgets: ", updatedWidgets) + + const updatedChildWidget = { + ...childWidgetObj, + parent: "", + initialData: { + ...childData, + pos: { x: finalPosition.x, y: finalPosition.y }, + positionType: PosType.ABSOLUTE, // makes sure that after dropping the position is set to absolute value + zIndex: 0, + widgetContainer: WidgetContainer.CANVAS + } + } + + let updatedWidgets = this.removeWidgetFromCurrentList(widgetObj.current.getId()) + + + // Create a new copy of the parent widget with the child added + const updatedParentWidget = { + ...parentWidgetObj, + // children: parentWidgetObj.children.filter(child => child.id !== childWidgetObj.id) + } + + + updatedWidgets = updatedWidgets.map(widget => { + if (widget.id === parentWidgetObj.id) { + return updatedParentWidget // Update the parent widget with the child removed + } else { + return widget // Leave other widgets unchanged + } + }) + + + this.setState({ + widgets: [...updatedWidgets, updatedChildWidget] + }) + + } + } + + } + + + + renderWidget = (widget) => { + + const { id, widgetType: ComponentType, children = [], parent, initialData = {} } = widget + + + const renderChildren = (childrenData) => { + // recursively render the child elements + return childrenData.map((child) => { + const childWidget = this.findWidgetFromListById(child.id) + if (childWidget) { + return this.renderWidget(childWidget) // Recursively render child widgets + } + return null + }) + } + + + return ( + this.setState({ widgetResizing: resizeSide })} + > + {/* Render children inside the parent with layout applied */} + {renderChildren(children)} + + ) } render() { return (
- +
- + -
- - -
- {/* Canvas */} -
-
- { - this.state.widgets.map(this.renderWidget) - } -
+ {/* */} + + {/* */} + +
+ + {/* Canvas */} +
+
+ { + this.state.widgets.map(this.renderWidget) + }
+
- + {/*
*/} +
- + + {/*
*/}
) } diff --git a/src/canvas/constants/containers.js b/src/canvas/constants/containers.js new file mode 100644 index 0000000..0f89230 --- /dev/null +++ b/src/canvas/constants/containers.js @@ -0,0 +1,12 @@ + + +const WidgetContainer = { + + CANVAS: "canvas", // widget is on the canvas + SIDEBAR: "sidebar", // widget is contained inside sidebar + WIDGET: "widget", // widget is contained inside another widget + +} + + +export default WidgetContainer \ No newline at end of file diff --git a/src/canvas/constants/layouts.js b/src/canvas/constants/layouts.js index 82db7bd..a93a1da 100644 --- a/src/canvas/constants/layouts.js +++ b/src/canvas/constants/layouts.js @@ -1,7 +1,12 @@ -const Layouts = { - PACK: "flex", +export const Layouts = { + FLEX: "flex", GRID: "grid", PLACE: "absolute" } -export default Layouts \ No newline at end of file + +export const PosType = { + ABSOLUTE: "absolute", + RELATIVE: "relative", + NONE: "unset" +} diff --git a/src/canvas/constants/tools.js b/src/canvas/constants/tools.js index 13f1b63..dba5207 100644 --- a/src/canvas/constants/tools.js +++ b/src/canvas/constants/tools.js @@ -5,6 +5,8 @@ const Tools = { SELECT_DROPDOWN: "select_dropdown", COLOR_PICKER: "color_picker", EVENT_HANDLER: "event_handler", // shows a event handler with all the possible function in the dropdown + + LAYOUT_MANAGER: "layout_manager" } diff --git a/src/canvas/toolbar.js b/src/canvas/toolbar.js index d51bb57..593b305 100644 --- a/src/canvas/toolbar.js +++ b/src/canvas/toolbar.js @@ -4,6 +4,8 @@ import { ColorPicker, Input, InputNumber, Select } from "antd" import { capitalize } from "../utils/common" import Tools from "./constants/tools.js" +import { useActiveWidget } from "./activeWidgetContext.js" +import { Layouts } from "./constants/layouts.js" // FIXME: Maximum recursion error @@ -14,11 +16,15 @@ import Tools from "./constants/tools.js" * @param {string} widgetType * @param {object} attrs - widget attributes */ -const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { +const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { + // const { activeWidgetAttrs } = useActiveWidget() + + // console.log("active widget context: ", activeWidgetAttrs) const [toolbarOpen, setToolbarOpen] = useState(isOpen) const [toolbarAttrs, setToolbarAttrs] = useState(attrs) + useEffect(() => { setToolbarOpen(isOpen) }, [isOpen]) @@ -34,6 +40,93 @@ const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { } } + + const renderLayoutManager = (val) => { + + return ( +
+ handleChange({ ...val.value, direction: value }, val.onChange)} + /> +
+
+ Gap + { + handleChange({ ...val.value, gap: value }, val.onChange) + }} + /> +
+
+ Grids +
+
+ Rows + { + let newGrid = { + rows: value, + cols: val.value?.grid.cols + } + handleChange({ ...val.value, grid: newGrid }, val.onChange) + }} + /> +
+
+ Columns + { + let newGrid = { + rows: val.value?.grid.cols, + cols: value + } + handleChange({ ...val.value, grid: newGrid }, val.onChange) + }} + /> +
+
+
+ +
+ ) + + } + + const renderWidgets = (obj, parentKey = "") => { return Object.entries(obj).map(([key, val], i) => { const keyName = parentKey ? `${parentKey}.${key}` : key @@ -42,14 +135,14 @@ const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { const isFirstLevel = parentKey === "" const outerLabelClass = isFirstLevel - ? "tw-text-lg tw-text-blue-700 tw-font-medium" - : "tw-text-lg" + ? "tw-text-base tw-text-blue-700 tw-font-medium" + : "tw-text-base" // Render tool widgets if (typeof val === "object" && val.tool) { return (
-
{val.label}
+
{val.label}
{val.tool === Tools.INPUT && ( { {val.tool === Tools.COLOR_PICKER && ( { onChange={(value) => handleChange(value, val.onChange)} /> )} + + { + val.tool === Tools.LAYOUT_MANAGER && ( + renderLayoutManager(val) + ) + } +
); } @@ -116,8 +217,8 @@ const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { return (
@@ -125,7 +226,6 @@ const CanvasToolBar = memo(({ isOpen, widgetType, attrs = {} }) => { {capitalize(`${widgetType || ""}`)} -
{renderWidgets(toolbarAttrs || {})}
) diff --git a/src/canvas/widgets/base.js b/src/canvas/widgets/base.js index a846531..51761fa 100644 --- a/src/canvas/widgets/base.js +++ b/src/canvas/widgets/base.js @@ -2,11 +2,21 @@ import React from "react" import { NotImplementedError } from "../../utils/errors" import Tools from "../constants/tools" -import Layouts from "../constants/layouts" +import { Layouts, PosType} from "../constants/layouts" import Cursor from "../constants/cursor" import { toSnakeCase } from "../utils/utils" import EditableDiv from "../../components/editableDiv" +import { ActiveWidgetContext } from "../activeWidgetContext" +import { DragWidgetProvider } from "./draggableWidgetContext" +import WidgetDraggable from "./widgetDragDrop" +import WidgetContainer from "../constants/containers" +import { DragContext } from "../../components/draggable/draggableContext" + + + +const ATTRS_KEYS = ['value', 'label', 'tool', 'onChange', 'toolProps'] // these are attrs keywords, don't use these keywords as keys while defining the attrs property + /** * Base class to be extended @@ -15,6 +25,8 @@ class Widget extends React.Component { static widgetType = "widget" + // static contextType = ActiveWidgetContext + constructor(props) { super(props) @@ -28,9 +40,6 @@ class Widget extends React.Component { this._disableResize = false this._disableSelection = false - this._parent = "" // id of the parent widget, default empty string - this._children = [] // id's of all the child widgets - this.minSize = { width: 50, height: 50 } // disable resizing below this number this.maxSize = { width: 500, height: 500 } // disable resizing above this number @@ -38,14 +47,15 @@ class Widget extends React.Component { this.icon = "" // antd icon name representing this widget - this.elementRef = React.createRef() + this.elementRef = React.createRef() // this is the outer ref for draggable area + this.swappableAreaRef = React.createRef() // helps identify if the users intent is to swap or drop inside the widget + this.innerAreaRef = React.createRef() // this is the inner area where swap is prevented and only drop is accepted this.functions = { "load": { "args1": "number", "args2": "string" } } - - this.layout = Layouts.PACK + this.droppableTags = ["widget"] // This indicates if the draggable can be dropped on this widget this.boundingRect = { x: 0, y: 0, @@ -58,15 +68,28 @@ class Widget extends React.Component { selected: false, widgetName: widgetName || 'widget', // this will later be converted to variable name enableRename: false, // will open the widgets editable div for renaming - resizing: false, - resizeCorner: "", + + isDragging: false, // tells if the widget is currently being dragged + dragEnabled: true, + + widgetContainer: WidgetContainer.CANVAS, // what is the parent of the widget + + showDroppableStyle: { // shows the droppable indicator + allow: false, + show: false, + }, pos: { x: 0, y: 0 }, size: { width: 100, height: 100 }, - position: "absolute", + positionType: PosType.ABSOLUTE, widgetStyling: { // use for widget's inner styling + backgroundColor: "#fff", + display: "flex", + flexDirection: "row", + gap: 10, + flexWrap: "wrap" }, attrs: { @@ -74,26 +97,42 @@ class Widget extends React.Component { backgroundColor: { label: "Background Color", tool: Tools.COLOR_PICKER, // the tool to display, can be either HTML ELement or a constant string - value: "", - onChange: (value) => this.setWidgetStyling("backgroundColor", value) + value: "#fff", + onChange: (value) => { + this.setWidgetStyling("backgroundColor", value) + this.setAttrValue("styling.backgroundColor", value) + } }, foregroundColor: { label: "Foreground Color", tool: Tools.COLOR_PICKER, - value: "", + value: "#000", }, label: "Styling" }, layout: { label: "Layout", - tool: Tools.SELECT_DROPDOWN, // the tool to display, can be either HTML ELement or a constant string - value: "flex", - options: [ - { value: "flex", label: "Flex" }, - { value: "grid", label: "Grid" }, - { value: "place", label: "Place" }, - ], - onChange: (value) => this.setWidgetStyling("backgroundColor", value) + tool: Tools.LAYOUT_MANAGER, // the tool to display, can be either HTML ELement or a constant string + value: { + layout: "flex", + direction: "row", + grid: { + rows: 1, + cols: 1 + }, + gap: 10, + }, + toolProps: { + options: [ + { value: "flex", label: "Flex" }, + { value: "grid", label: "Grid" }, + { value: "place", label: "Place" }, + ], + }, + onChange: (value) => { + // this.setAttrValue("layout", value) + this.setLayout(value) + } }, events: { event1: { @@ -107,6 +146,8 @@ class Widget extends React.Component { this.mousePress = this.mousePress.bind(this) this.getElement = this.getElement.bind(this) + this.getId = this.getId.bind(this) + this.getPos = this.getPos.bind(this) this.getSize = this.getSize.bind(this) this.getWidgetName = this.getWidgetName.bind(this) @@ -123,35 +164,44 @@ class Widget extends React.Component { this.setAttrValue = this.setAttrValue.bind(this) this.setWidgetName = this.setWidgetName.bind(this) this.setWidgetStyling = this.setWidgetStyling.bind(this) - - - this.startResizing = this.startResizing.bind(this) - this.handleResize = this.handleResize.bind(this) - this.stopResizing = this.stopResizing.bind(this) + this.setPosType = this.setPosType.bind(this) } componentDidMount() { this.elementRef.current?.addEventListener("click", this.mousePress) - this.canvas.addEventListener("mousemove", this.handleResize) - this.canvas.addEventListener("mouseup", this.stopResizing) + // FIXME: initial layout is not set properly + console.log("prior layout: ", this.state.attrs.layout.value) + this.setLayout(this.state.attrs.layout.value) + this.setWidgetStyling('backgroundColor', this.state.attrs.styling?.backgroundColor.value || "#fff") + + this.load(this.props.initialData || {}) // load the initial data + + } componentWillUnmount() { this.elementRef.current?.removeEventListener("click", this.mousePress) - - this.canvas.addEventListener("mousemove", this.handleResize) - this.canvas.addEventListener("mouseup", this.stopResizing) } updateState = (newState, callback) => { + + // FIXME: maximum recursion error when updating size, color etc this.setState(newState, () => { + const { onWidgetUpdate } = this.props if (onWidgetUpdate) { onWidgetUpdate(this.__id) } + + // const { activeWidgetId, updateToolAttrs } = this.context + + // if (activeWidgetId === this.__id) + // updateToolAttrs(this.getToolbarAttrs()) + if (callback) callback() + }) } @@ -166,6 +216,7 @@ class Widget extends React.Component { getToolbarAttrs(){ return ({ + id: this.__id, widgetName: { label: "Widget Name", tool: Tools.INPUT, // the tool to display, can be either HTML ELement or a constant string @@ -174,7 +225,7 @@ class Widget extends React.Component { onChange: (value) => this.setWidgetName(value) }, size: { - label: "Sizing", + label: "Size", display: "horizontal", width: { label: "Width", @@ -218,26 +269,13 @@ class Widget extends React.Component { return this.state.attrs } - /** - * removes the element/widget - */ - remove() { - this.canvas.removeWidget(this.__id) + getId(){ + return this.__id } mousePress(event) { // event.preventDefault() if (!this._disableSelection) { - - // const widgetSelected = new CustomEvent("selection:created", { - // detail: { - // event, - // id: this.__id, - // element: this - // }, - // // bubbles: true // Allow the event to bubble up the DOM tree - // }) - // this.canvas.dispatchEvent(widgetSelected) } } @@ -258,34 +296,29 @@ class Widget extends React.Component { return this.state.selected } + setPosType(positionType){ + + if (!Object.values(PosType).includes(positionType)){ + throw Error(`The Position type can only be among: ${Object.values(PosType).join(", ")}`) + } + + this.setState({ + positionType: positionType + }) + + } + setPos(x, y) { - if (this.state.resizing) { - // don't change position when resizing the widget - return - } - // this.setState({ - // pos: { x, y } - // }) - - this.updateState({ + this.setState({ pos: { x, y } }) + + // this.updateState({ + // pos: { x, y } + // }) } - setParent(parentId) { - this._parent = parentId - } - - addChild(childId) { - this._children.push(childId) - } - - removeChild(childId) { - this._children = this._children.filter(function (item) { - return item !== childId - }) - } getPos() { return this.state.pos @@ -303,38 +336,6 @@ class Widget extends React.Component { return this.state.size } - getScaleAwareDimensions() { - // Get the bounding rectangle - const rect = this.elementRef.current.getBoundingClientRect() - - // Get the computed style of the element - const style = window.getComputedStyle(this.elementRef.current) - - // Get the transform matrix - const transform = style.transform - - // Extract scale factors from the matrix - let scaleX = 1 - let scaleY = 1 - - if (transform && transform !== 'none') { - // For 2D transforms (a, c, b, d) - const matrix = transform.match(/^matrix\(([^,]+),[^,]+,([^,]+),[^,]+,[^,]+,[^,]+\)$/); - - if (matrix) { - scaleX = parseFloat(matrix[1]) - scaleY = parseFloat(matrix[2]) - } - } - - // Return scaled width and height - return { - width: rect.width / scaleX, - height: rect.height / scaleY - } - } - - getWidgetFunctions() { return this.functions } @@ -347,34 +348,79 @@ class Widget extends React.Component { return this.elementRef.current } + getLayoutStyleForWidget = () => { + + switch (this.state.attrs.layout) { + case 'grid': + return { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '10px' } + case 'flex': + return { display: 'flex', flexDirection: 'row', justifyContent: 'space-around' } + case 'absolute': + return { position: 'absolute', left: "0", top: "0" } // Custom positioning + default: + return {} + } + } + /** * Given the key as a path, sets the value for the widget attribute * @param {string} path - path to the key, eg: styling.backgroundColor * @param {any} value */ setAttrValue(path, value) { - this.setState((prevState) => { - // Split the path to access the nested property (e.g., "styling.backgroundColor") - const keys = path.split('.') - const lastKey = keys.pop() - // Traverse the state and update the nested value immutably - let newAttrs = { ...prevState.attrs } - let nestedObject = newAttrs + const keys = path.split('.') + const lastKey = keys.pop() - keys.forEach(key => { - nestedObject[key] = { ...nestedObject[key] } // Ensure immutability - nestedObject = nestedObject[key] - }) - nestedObject[lastKey].value = value - - return { attrs: newAttrs } + // Traverse the state and update the nested value immutably + let newAttrs = { ...this.state.attrs } + let nestedObject = newAttrs + + keys.forEach(key => { + nestedObject[key] = { ...nestedObject[key] } // Ensure immutability + nestedObject = nestedObject[key] }) + + nestedObject[lastKey].value = value + + this.updateState({attrs: newAttrs}) } - startResizing(corner, event) { - event.stopPropagation() - this.setState({ resizing: true, resizeCorner: corner }) + /** + * returns the path from the serialized attrs values, + * this is a helper function to remove any non-serializable data associated with attrs + * eg: {"styling.backgroundColor": "#ffff", "layout": {layout: "flex", direction: "", grid: }} + */ + serializeAttrsValues = () => { + + const serializeValues = (obj, currentPath = "") => { + const result = {} + + for (let key in obj) { + + if (ATTRS_KEYS.includes(key)) continue // don't serialize these as separate keys + + if (typeof obj[key] === 'object' && obj[key] !== null) { + // If the key contains a value property + if (obj[key].hasOwnProperty('value')) { + const path = currentPath ? `${currentPath}.${key}` : key; + + // If the value is an object, retain the entire value object + if (typeof obj[key].value === 'object' && obj[key].value !== null) { + result[path] = obj[key].value + } else { + result[`${path}`] = obj[key].value + } + } + // Continue recursion for nested objects + Object.assign(result, serializeValues(obj[key], currentPath ? `${currentPath}.${key}` : key)) + } + } + + return result + } + + return serializeValues(this.state.attrs) } setZIndex(zIndex) { @@ -385,22 +431,37 @@ class Widget extends React.Component { setWidgetName(name) { - // this.setState((prev) => ({ - // widgetName: name.length > 0 ? name : prev.widgetName - // })) - this.updateState({ widgetName: name.length > 0 ? name : this.state.widgetName }) } + setLayout(value){ + // FIXME: when the parent layout is place, the child widgets should have position absolute + const {layout, direction, grid={rows: 1, cols: 1}, gap=10} = value + + const widgetStyle = { + ...this.state.widgetStyling, + display: layout, + flexDirection: direction, + gap: `${gap}px`, + flexWrap: "wrap" + // TODO: add grid rows and cols + } + + this.setAttrValue("layout", value) + this.updateState({ + widgetStyling: widgetStyle + }) + + } + /** * * @param {string} key - The string in react Style format * @param {string} value - Value of the style - * @param {function():void} [callback] - optional callback, thats called after setting the internal state */ - setWidgetStyling(key, value, callback) { + setWidgetStyling(key, value) { const widgetStyle = { ...this.state.widgetStyling, @@ -409,9 +470,6 @@ class Widget extends React.Component { this.setState({ widgetStyling: widgetStyle - }, () => { - if (callback) - callback(widgetStyle) }) } @@ -420,83 +478,26 @@ class Widget extends React.Component { * * @param {number|null} width * @param {number|null} height - * @param {function():void} [callback] - optional callback, thats called after setting the internal state */ - setWidgetSize(width, height, callback) { + setWidgetSize(width, height) { const newSize = { width: Math.max(this.minSize.width, Math.min(width || this.state.size.width, this.maxSize.width)), height: Math.max(this.minSize.height, Math.min(height || this.state.size.height, this.maxSize.height)), } - - this.setState({ - size: newSize - }, () => { - if (callback) { - callback(newSize) - } - }) - this.updateState({ size: newSize - }, () => { - if (callback) - callback(newSize) }) } - handleResize(event) { - if (!this.state.resizing) return - - const { resizeCorner, size, pos } = this.state - const deltaX = event.movementX - const deltaY = event.movementY - - let newSize = { ...size } - let newPos = { ...pos } - - const { width: minWidth, height: minHeight } = this.minSize - const { width: maxWidth, height: maxHeight } = this.maxSize - // console.log("resizing: ", deltaX, deltaY, event) - - switch (resizeCorner) { - case "nw": - newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width - deltaX)) - newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height - deltaY)) - newPos.x += (newSize.width !== size.width) ? deltaX : 0 - newPos.y += (newSize.height !== size.height) ? deltaY : 0 - break - case "ne": - newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width + deltaX)) - newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height - deltaY)) - newPos.y += (newSize.height !== size.height) ? deltaY : 0 - break - case "sw": - newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width - deltaX)) - newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height + deltaY)) - newPos.x += (newSize.width !== size.width) ? deltaX : 0 - break - case "se": - newSize.width = Math.max(minWidth, Math.min(maxWidth, newSize.width + deltaX)) - newSize.height = Math.max(minHeight, Math.min(maxHeight, newSize.height + deltaY)) - break - default: - break - } - - // this.setState({ size: newSize, pos: newPos }) + setResize(pos, size){ + // useful when resizing the widget relative to the canvas, sets all pos, and size this.updateState({ - size: newSize, - pos: newPos + size: size, + pos: pos }) } - stopResizing() { - if (this.state.resizing) { - this.setState({ resizing: false }) - } - } - openRenaming() { this.setState({ selected: true, @@ -510,15 +511,272 @@ class Widget extends React.Component { }) } - handleDragStart = (event) => { - console.log("dragging event: ", event) + enableDrag = () => { + this.setState({ + dragEnabled: true + }) } + disableDrag = () => { + this.setState({ + dragEnabled: false + }) + } + + + /** + * + * serialize data for saving + */ + serialize = () => { + // NOTE: when serializing make sure, you are only passing serializable objects not functions or other + return ({ + zIndex: this.state.zIndex, + widgetName: this.state.widgetName, + pos: this.state.pos, + size: this.state.size, + widgetContainer: this.state.widgetContainer, + widgetStyling: this.state.widgetStyling, + positionType: this.state.positionType, + attrs: this.serializeAttrsValues() // makes sure that functions are not serialized + }) + + } + + /** + * loads the data + * @param {object} data + */ + load = (data) => { + + if (Object.keys(data).length === 0) return // no data to load + + for (let [key, value] of Object.entries(data.attrs|{})) + this.setAttrValue(key, value) + + delete data.attrs + + /** + * const obj = { a: 1, b: 2, c: 3 } + * const { b, ...newObj } = obj + * console.log(newObj) // { a: 1, c: 3 } + */ + + this.setState(data) + + } + + /** + * + * @depreciated - This function is depreciated in favour of handleDropEvent() + */ + handleDrop = (event, dragElement) => { + // THIS function is depreciated in favour of handleDropEvent() + // console.log("dragging event: ", event, dragElement) + const container = dragElement.getAttribute("data-container") + // TODO: check if the drop is allowed + if (container === "canvas"){ + + this.props.onAddChildWidget(this.__id, dragElement.getAttribute("data-widget-id")) + + }else if (container === "sidebar"){ + + this.props.onAddChildWidget(this.__id, null, true) // if dragged from the sidebar create the widget first + + } + + } + + handleDragStart = (e, callback) => { + e.stopPropagation() + + callback(this.elementRef?.current || null) + + // Create custom drag image with full opacity, this will ensure the image isn't taken from part of the canvas + const dragImage = this.elementRef?.current.cloneNode(true) + dragImage.style.opacity = '1' // Ensure full opacity + dragImage.style.position = 'absolute' + dragImage.style.top = '-9999px' // Move it out of view + + document.body.appendChild(dragImage) + const rect = this.elementRef?.current.getBoundingClientRect() + // snap to mouse pos + // const offsetX = e.clientX - rect.left + // const offsetY = e.clientY - rect.top + + // snap to middle + const offsetX = rect.width / 2 + const offsetY = rect.height / 2 + + // Set the custom drag image with correct offset to avoid snapping to the top-left corner + e.dataTransfer.setDragImage(dragImage, offsetX, offsetY) + + + // Remove the custom drag image after some time to avoid leaving it in the DOM + setTimeout(() => { + document.body.removeChild(dragImage) + }, 0) + + // NOTE: this line will prevent problem's such as self-drop or dropping inside its own children + setTimeout(this.disablePointerEvents, 1) + + this.setState({isDragging: true}) + + } + + handleDragEnter = (e, draggedElement, setOverElement) => { + + const dragEleType = draggedElement.getAttribute("data-draggable-type") + + // console.log("Drag entering...", dragEleType, draggedElement, this.droppableTags) + // FIXME: the outer widget shouldn't be swallowed by inner widget + if (draggedElement === this.elementRef.current){ + // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + return + } + + setOverElement(e.currentTarget) // provide context to the provider + + let showDrop = { + allow: true, + show: true + } + + if (this.droppableTags.length === 0 || this.droppableTags.includes(dragEleType)) { + showDrop = { + allow: true, + show: true + } + + } else { + showDrop = { + allow: false, + show: true + } + } + + this.setState({ + showDroppableStyle: showDrop + }) + + } + + handleDragOver = (e, draggedElement) => { + if (draggedElement === this.elementRef.current){ + // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + return + } + + // console.log("Drag over: ", e.dataTransfer.getData("text/plain"), e.dataTransfer) + const dragEleType = draggedElement.getAttribute("data-draggable-type") + + if (this.droppableTags.length === 0 || this.droppableTags.includes(dragEleType)) { + e.preventDefault() // NOTE: this is necessary to allow drop to take place + } + + } + + handleDropEvent = (e, draggedElement, widgetClass=null) => { + e.preventDefault() + e.stopPropagation() + // FIXME: sometimes the elements showDroppableStyle is not gone, when dropping on the same widget + this.setState({ + showDroppableStyle: { + allow: false, + show: false + } + }, () => { + console.log("droppable cleared: ", this.elementRef.current, this.state.showDroppableStyle) + }) + + + const dragEleType = draggedElement.getAttribute("data-draggable-type") + + if (this.droppableTags.length > 0 && !this.droppableTags.includes(dragEleType)) { + return // prevent drop if the draggable element doesn't match + } + + // if (draggedElement === this.elementRef.current){ + // // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + // return + // } + + // let currentElement = e.currentTarget + // while (currentElement) { + // if (currentElement === draggedElement) { + // // if the parent is dropped accidentally into the child don't allow drop + // console.log("Dropped into a descendant element, ignoring drop") + // return // Exit early to prevent the drop + // } + // currentElement = currentElement.parentElement // Traverse up to check ancestors + // } + + const container = draggedElement.getAttribute("data-container") + + const thisContainer = this.elementRef.current.getAttribute("data-container") + // console.log("Dropped as swappable: ", e.target, this.swappableAreaRef.current.contains(e.target)) + // If swaparea is true, then it swaps instead of adding it as a child, also make sure that the parent widget(this widget) is on the widget and not on the canvas + const swapArea = (this.swappableAreaRef.current.contains(e.target) && !this.innerAreaRef.current.contains(e.target) && thisContainer === WidgetContainer.WIDGET) + + // TODO: check if the drop is allowed + if ([WidgetContainer.CANVAS, WidgetContainer.WIDGET].includes(container)){ + // console.log("Dropped on meee: ", swapArea, this.swappableAreaRef.current.contains(e.target), thisContainer) + + this.props.onAddChildWidget({parentWidgetId: this.__id, + dragElementID: draggedElement.getAttribute("data-widget-id"), + swap: swapArea || false + }) + + }else if (container === WidgetContainer.SIDEBAR){ + + // console.log("Dropped on Sidebar: ", this.__id) + this.props.onCreateWidgetRequest(widgetClass, ({id, widgetRef}) => { + this.props.onAddChildWidget({parentWidgetId: this.__id, dragElementID: id}) // if dragged from the sidebar create the widget first + }) + + } + + } + + + handleDragLeave = (e, draggedElement) => { + + // console.log("Left: ", e.currentTarget, e.relatedTarget, draggedElement) + + if (!e.currentTarget.contains(draggedElement)) { + this.setState({ + showDroppableStyle: { + allow: false, + show: false + } + }) + + } + } + + handleDragEnd = (callback) => { + callback() + this.setState({isDragging: false}) + this.enablePointerEvents() + } + + disablePointerEvents = () => { + + if (this.elementRef.current) + this.elementRef.current.style.pointerEvents = "none" + } + + enablePointerEvents = () => { + if (this.elementRef.current) + this.elementRef.current.style.pointerEvents = "auto" + } + + // FIXME: children outside the bounding box, add tw-overflow-hidden renderContent() { // throw new NotImplementedError("render method has to be implemented") return ( -
- +
+ {this.props.children}
) } @@ -530,73 +788,171 @@ class Widget extends React.Component { */ render() { - const widgetStyle = this.state.widgetStyling - - let outerStyle = { cursor: this.cursor, zIndex: this.state.zIndex, - position: "absolute", // don't change this if it has to be movable on the canvas + position: this.state.positionType, // don't change this if it has to be movable on the canvas top: `${this.state.pos.y}px`, left: `${this.state.pos.x}px`, width: `${this.state.size.width}px`, height: `${this.state.size.height}px`, + opacity: this.state.isDragging ? 0.3 : 1, } - let selectionStyle = { - x: "-5px", - y: "-5px", - width: this.boundingRect.width + 5, - height: this.boundingRect.height + 5 - } - - // console.log("selected: ", this.state.selected) return ( -
+ + { + ({draggedElement, widgetClass, onDragStart, onDragEnd, setOverElement}) => ( - {this.renderContent()} -
+
this.handleDragOver(e, draggedElement)} + onDrop={(e) => this.handleDropEvent(e, draggedElement, widgetClass)} -
- + onDragEnter={(e) => this.handleDragEnter(e, draggedElement, setOverElement)} + onDragLeave={(e) => this.handleDragLeave(e, draggedElement)} -
this.startResizing("nw", e)} - /> -
this.startResizing("ne", e)} - /> -
this.startResizing("sw", e)} - /> -
this.startResizing("se", e)} - /> + onDragStart={(e) => this.handleDragStart(e, onDragStart)} + onDragEnd={(e) => this.handleDragEnd(onDragEnd)} + > + {/* FIXME: Swappable when the parent layout is flex/grid and gap is more, this trick won't work, add bg color to check */} + {/* FIXME: Swappable, when the parent layout is gap is 0, it doesn't work well */} +
+
+ {/* helps with swappable: if the mouse is in this area while hovering/dropping, then swap */} +
+
+ {this.renderContent()} +
+ { + // show drop style on drag hover + this.state.showDroppableStyle.show && +
+ `} + style={{ + width: "calc(100% + 10px)", + height: "calc(100% + 10px)", + }} + > +
+ } + +
+ +
{/* ${this.state.isDragging ? "tw-pointer-events-none" : "tw-pointer-events-auto"} */} + + +
{ + e.stopPropagation() + e.preventDefault() + this.props.onWidgetResizing("nw") + this.setState({dragEnabled: false}) + }} + onMouseUp={() => this.setState({dragEnabled: true})} + /> +
{ + e.stopPropagation() + e.preventDefault() + this.props.onWidgetResizing("ne") + this.setState({dragEnabled: false}) + }} + onMouseUp={() => this.setState({dragEnabled: true})} + /> +
{ + e.stopPropagation() + e.preventDefault() + this.props.onWidgetResizing("sw") + this.setState({dragEnabled: false}) + }} + onMouseUp={() => this.setState({dragEnabled: true})} + /> +
{ + e.stopPropagation() + e.preventDefault() + this.props.onWidgetResizing("se") + this.setState({dragEnabled: false}) + }} + onMouseUp={() => this.setState({dragEnabled: true})} + /> + +
+ +
+
+
+ ) + } -
-
+ + // { + // this.setState({ + // showDroppableStyle: showDrop + // }) + // } + // } + // onDragLeave={ () => { + // this.setState({ + // showDroppableStyle: { + // allow: false, + // show: false + // } + // }) + // } + // } + + // > + // ) } diff --git a/src/canvas/widgets/draggableWidgetContext.js b/src/canvas/widgets/draggableWidgetContext.js new file mode 100644 index 0000000..2532041 --- /dev/null +++ b/src/canvas/widgets/draggableWidgetContext.js @@ -0,0 +1,24 @@ +import React, { createContext, useContext, useState } from 'react'; + +const DragWidgetContext = createContext() + +export const useDragWidgetContext = () => useContext(DragWidgetContext) + +// Provider component to wrap around parts of your app that need drag-and-drop functionality +export const DragWidgetProvider = ({ children }) => { + const [draggedElement, setDraggedElement] = useState(null) + + const onDragStart = (element) => { + setDraggedElement(element) + } + + const onDragEnd = () => { + setDraggedElement(null) + } + + return ( + + {children} + + ) +} diff --git a/src/canvas/widgets/widgetDragDrop.js b/src/canvas/widgets/widgetDragDrop.js new file mode 100644 index 0000000..58557a5 --- /dev/null +++ b/src/canvas/widgets/widgetDragDrop.js @@ -0,0 +1,176 @@ +import { memo, useEffect, useRef, useState } from "react" +import { useDragWidgetContext } from "./draggableWidgetContext" +import { useDragContext } from "../../components/draggable/draggableContext" + + +// FIXME: sometimes even after drag end the showDroppable is visible +/** + * @param {} - widgetRef - the widget ref for your widget + * @param {boolean} - enableDraggable - should the widget be draggable + * @param {string} - dragElementType - the widget type of widget so the droppable knows if the widget can be accepted + * @param {() => void} - onDrop - the widget type of widget so the droppable knows if the widget can be accepted + * @param {string[]} - droppableTags - array of widget that can be dropped on the widget + * + */ +const WidgetDraggable = memo(({ widgetRef, enableDrag=true, dragElementType="widget", + onDragEnter, onDragLeave, onDrop, style={}, + droppableTags = ["widget"], ...props }) => { + + // const { draggedElement, onDragStart, onDragEnd } = useDragWidgetContext() + const { draggedElement, onDragStart, onDragEnd, overElement, setOverElement } = useDragContext() + + // const [dragEnabled, setDragEnabled] = useState(enableDraggable) + const [isDragging, setIsDragging] = useState(false) + + const [showDroppable, setShowDroppable] = useState({ + show: false, + allow: false + }) + + const handleDragStart = (e) => { + e.stopPropagation() + setIsDragging(true) + + onDragStart(widgetRef?.current || null) + + console.log("Drag start: ", widgetRef.current) + + // Create custom drag image with full opacity, this will ensure the image isn't taken from part of the canvas + const dragImage = widgetRef?.current.cloneNode(true) + dragImage.style.opacity = '1' // Ensure full opacity + dragImage.style.position = 'absolute' + dragImage.style.top = '-9999px' // Move it out of view + + document.body.appendChild(dragImage) + const rect = widgetRef?.current.getBoundingClientRect() + const offsetX = e.clientX - rect.left + const offsetY = e.clientY - rect.top + + // Set the custom drag image with correct offset to avoid snapping to the top-left corner + e.dataTransfer.setDragImage(dragImage, offsetX, offsetY) + + // Remove the custom drag image after some time to avoid leaving it in the DOM + setTimeout(() => { + document.body.removeChild(dragImage) + }, 0) + } + + const handleDragEnter = (e) => { + + const dragEleType = draggedElement.getAttribute("data-draggable-type") + + // console.log("Drag entering...", overElement === e.currentTarget) + if (draggedElement === widgetRef.current){ + // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + return + } + + setOverElement(e.currentTarget) + + let showDrop = { + allow: true, + show: true + } + + if (droppableTags.length === 0 || droppableTags.includes(dragEleType)) { + showDrop = { + allow: true, + show: true + } + + } else { + showDrop = { + allow: false, + show: true + } + } + + setShowDroppable(showDrop) + if (onDragEnter) + onDragEnter({element: draggedElement, showDrop}) + + } + + const handleDragOver = (e) => { + if (draggedElement === widgetRef.current){ + // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + return + } + // console.log("Drag over: ", e.dataTransfer.getData("text/plain"), e.dataTransfer) + const dragEleType = draggedElement.getAttribute("data-draggable-type") + + if (droppableTags.length === 0 || droppableTags.includes(dragEleType)) { + e.preventDefault() // this is necessary to allow drop to take place + } + + } + + const handleDropEvent = (e) => { + e.preventDefault() + e.stopPropagation() + console.log("Dropped: ", draggedElement, props.children) + + setShowDroppable({ + allow: false, + show: false + }) + + if (onDrop) { + onDrop(e, draggedElement) + } + + // if (draggedElement === widgetRef.current){ + // // prevent drop on itself, since the widget is invisible when dragging, if dropped on itself, it may consume itself + // return + // } + + let currentElement = e.currentTarget + while (currentElement) { + if (currentElement === draggedElement) { + console.log("Dropped into a descendant element, ignoring drop") + return // Exit early to prevent the drop + } + currentElement = currentElement.parentElement // Traverse up to check ancestors + } + + } + + + const handleDragLeave = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setShowDroppable({ + allow: false, + show: false + }) + + if (onDragLeave) + onDragLeave() + + } + } + + const handleDragEnd = () => { + onDragEnd() + setIsDragging(false) + } + + // TODO: FIXME, currently the draggable div doesn't move with the child, instead only child div moves, simulating childrens movement, add color and check + return ( +
+ {props.children} +
+ ) + +}) + + +export default WidgetDraggable \ No newline at end of file diff --git a/src/components/cards.js b/src/components/cards.js index 8ee0b01..38f18a4 100644 --- a/src/components/cards.js +++ b/src/components/cards.js @@ -1,12 +1,14 @@ import { useEffect, useMemo, useRef } from "react" -import Draggable from "./utils/draggable" +import Draggable from "./utils/draggableDnd" import { FileImageOutlined, GithubOutlined, GitlabOutlined, LinkOutlined, AudioOutlined, VideoCameraOutlined, FileTextOutlined} from "@ant-design/icons" +import DraggableWrapper from "./draggable/draggable" +import { useDragContext } from "./draggable/draggableContext" -export function DraggableWidgetCard({ name, img, url, innerRef}){ +export function SidebarWidgetCard({ name, img, url, widgetClass, innerRef}){ const urlIcon = useMemo(() => { if (url){ @@ -23,26 +25,32 @@ export function DraggableWidgetCard({ name, img, url, innerRef}){ }, [url]) - useEffect(() => { - }, []) - return ( - -
-
- {name} -
- {name} -
+ // + + +
+
+ {name} +
+ {name} + +
-
- + + // ) } @@ -80,7 +88,7 @@ export function DraggableAssetCard({file}){ return ( -
{ file.fileType === "image" && @@ -105,7 +113,7 @@ export function DraggableAssetCard({file}){ }
- {file.name} + {file.name}
) diff --git a/src/components/draggable/draggable.js b/src/components/draggable/draggable.js new file mode 100644 index 0000000..1050e7b --- /dev/null +++ b/src/components/draggable/draggable.js @@ -0,0 +1,49 @@ +import { memo, useRef } from "react" +import { useDragContext } from "./draggableContext" + + +/** + * + * @param {string} - dragElementType - this will set the data-draggable-type which can be accessed on droppable to check if its allowed or not + * @returns + */ +const DraggableWrapper = memo(({dragElementType, dragWidgetClass=null, className, children, ...props}) => { + + const { onDragStart, onDragEnd } = useDragContext() + + const draggableRef = useRef(null) + + /** + * + * @param {DragEvent} event + */ + const handleDragStart = (event) => { + + // event.dataTransfer.setData("text/plain", "") + onDragStart(draggableRef?.current, dragWidgetClass) + + } + + const handleDragEnd = (e) => { + // console.log("Drag end: ", e, e.target.closest('div')) + + onDragEnd() + } + + return ( +
+ {children} +
+ ) + +}) + + +export default DraggableWrapper \ No newline at end of file diff --git a/src/components/draggable/draggableContext.js b/src/components/draggable/draggableContext.js new file mode 100644 index 0000000..4925b6d --- /dev/null +++ b/src/components/draggable/draggableContext.js @@ -0,0 +1,36 @@ +import React, { createContext, useContext, useState } from 'react' +import { isSubClassOfWidget } from '../../utils/widget' +// import Widget from '../../canvas/widgets/base' + +export const DragContext = createContext() + +export const useDragContext = () => useContext(DragContext) + +// Provider component to wrap around parts of your app that need drag-and-drop functionality +export const DragProvider = ({ children }) => { + const [draggedElement, setDraggedElement] = useState(null) + const [overElement, setOverElement] = useState(null) // the element the dragged items is over + + const [widgetClass, setWidgetClass] = useState(null) // helper to help pass the widget type from sidebar to canvas + + const onDragStart = (element, widgetClass=null) => { + setDraggedElement(element) + + if (widgetClass && !isSubClassOfWidget(widgetClass)) + throw new Error("widgetClass must inherit from the Widget base class") + + setWidgetClass(() => widgetClass) // store the class so later it can be passed to the canvas from sidebar + } + + const onDragEnd = () => { + setDraggedElement(null) + setWidgetClass(null) + } + + return ( + + {children} + + ) +} diff --git a/src/components/draggable/droppable.js b/src/components/draggable/droppable.js new file mode 100644 index 0000000..eb7c9bc --- /dev/null +++ b/src/components/draggable/droppable.js @@ -0,0 +1,95 @@ +import { memo, useState } from "react" +import { useDragContext } from "./draggableContext" + + +const DroppableWrapper = memo(({onDrop, droppableTags=["widget"], ...props}) => { + + + const { draggedElement, overElement, setOverElement, widgetClass } = useDragContext() + + const [showDroppable, setShowDroppable] = useState({ + show: false, + allow: false + }) + + + const handleDragEnter = (e) => { + + const dragElementType = draggedElement.getAttribute("data-draggable-type") + + // console.log("Current target: ", e.currentTarget) + + setOverElement(e.currentTarget) + + if (droppableTags.length === 0 || droppableTags.includes(dragElementType)){ + setShowDroppable({ + allow: true, + show: true + }) + }else{ + setShowDroppable({ + allow: false, + show: true + }) + } + } + + const handleDragOver = (e) => { + // console.log("Drag over: ", e.dataTransfer.getData("text/plain"), e.dataTransfer) + const dragElementType = draggedElement.getAttribute("data-draggable-type") + + if (droppableTags.length === 0 || droppableTags.includes(dragElementType)){ + e.preventDefault() // this is necessary to allow drop to take place + } + + } + + const handleDropEvent = (e) => { + e.stopPropagation() + + setShowDroppable({ + allow: false, + show: false + }) + + if(onDrop){ + onDrop(e, draggedElement, widgetClass) + } + } + + + const handleDragLeave = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setShowDroppable({ + allow: false, + show: false + }) + } + } + + return ( +
+ + {props.children} + + { + showDroppable.show && +
+
+ } + +
+ ) + +}) + + +export default DroppableWrapper \ No newline at end of file diff --git a/src/components/utils/draggable.js b/src/components/utils/draggableDnd.js similarity index 99% rename from src/components/utils/draggable.js rename to src/components/utils/draggableDnd.js index 7cca531..2cb3238 100644 --- a/src/components/utils/draggable.js +++ b/src/components/utils/draggableDnd.js @@ -2,6 +2,7 @@ import React from "react" import {useDraggable} from "@dnd-kit/core" import { CSS } from "@dnd-kit/utilities" + function Draggable(props) { const {attributes, listeners, setNodeRef, transform} = useDraggable({ id: props.id, diff --git a/src/components/utils/droppable.js b/src/components/utils/droppableDnd.js similarity index 100% rename from src/components/utils/droppable.js rename to src/components/utils/droppableDnd.js diff --git a/src/components/utils/panzoom.js b/src/components/utils/panzoom.js index cff9ae4..6fcb320 100644 --- a/src/components/utils/panzoom.js +++ b/src/components/utils/panzoom.js @@ -1,4 +1,4 @@ -// This is only for testing purpose, not really meant to be used +//NOTE: This is only for testing purpose, not really meant to be used import './polyfills' import { diff --git a/src/frameworks/tkinter/assets/widgets/button.png b/src/frameworks/tkinter/assets/widgets/button.png new file mode 100644 index 0000000..2d45182 Binary files /dev/null and b/src/frameworks/tkinter/assets/widgets/button.png differ diff --git a/src/frameworks/tkinter/sidebarWidgets.js b/src/frameworks/tkinter/sidebarWidgets.js new file mode 100644 index 0000000..b60ac4a --- /dev/null +++ b/src/frameworks/tkinter/sidebarWidgets.js @@ -0,0 +1,41 @@ + +import Widget from "../../canvas/widgets/base" + +import ButtonWidget from "./assets/widgets/button.png" + + +const TkinterSidebar = [ + { + name: "Main window", + img: ButtonWidget, + link: "https://github.com", + widgetClass: Widget + }, + { + name: "Top Level", + img: ButtonWidget, + link: "https://github.com", + widgetClass: Widget + }, + { + name: "Frame", + img: ButtonWidget, + link: "https://github.com", + widgetClass: Widget + }, + { + name: "Button", + img: ButtonWidget, + link: "https://github.com", + widgetClass: Widget + }, + { + name: "Input", + img: ButtonWidget, + link: "https://github.com", + widgetClass: Widget + }, +] + + +export default TkinterSidebar \ No newline at end of file diff --git a/src/sidebar/sidebar.js b/src/sidebar/sidebar.js index 786cb36..6cbbe09 100644 --- a/src/sidebar/sidebar.js +++ b/src/sidebar/sidebar.js @@ -1,6 +1,11 @@ import { useEffect, useRef, useMemo, useState } from "react"; -import { CloseCircleFilled } from "@ant-design/icons"; +import { CloseCircleFilled, CrownFilled, GithubFilled, ShareAltOutlined } from "@ant-design/icons"; + +import KO_FI from "../assets/logo/ko-fi.png" +import Premium from "./utils/premium"; +import Share from "./utils/share"; + function Sidebar({tabs}){ @@ -44,8 +49,8 @@ function Sidebar({tabs}){ return (
+ + + + + + + + + + + ko-fi + +
diff --git a/src/sidebar/utils/premium.js b/src/sidebar/utils/premium.js new file mode 100644 index 0000000..a5239bf --- /dev/null +++ b/src/sidebar/utils/premium.js @@ -0,0 +1,268 @@ +import { useState } from "react" + +import { Modal } from "antd" +import { CrownFilled } from "@ant-design/icons" + + +function Premium({ children, className = "" }) { + + const [premiumModalOpen, setPremiumModalOpen] = useState(false) + + + const onClick = () => { + setPremiumModalOpen(true) + } + + const onClose = (event) => { + event.stopPropagation() + setPremiumModalOpen(false) + } + + // FIXME: the pricing section is not responsive + return ( +
+ {children} + Buy Pre-order one Time License} + style={{ zIndex: 14000, gap: '10px', maxWidth: '80vw', placeItems: "center" }} + onCancel={onClose} + centered + onOk={onClose} + footer={null} + width={'auto'} + open={premiumModalOpen} + > +
+ I am Paul, an open-source dev, funding open-source projects by providing custom works. + If you find this tool useful and want to support its development, consider buying a one time license. +
+
+ By buying pre-order license, you get advance features, priority support, early access, upcoming features, and more. + + more. + +
+ +
+

Choose your plan

+
+ {/* Free Plan */} +
+

+ $0 +

+

+ Free to use forever, but for added features and to support open-source development, consider buying a lifetime license. +

+
+
    +
  • + + Access to web-based editor +
  • +
  • + + Commercial use +
  • +
  • + + Downloadable UI builder exe for local development +
  • +
  • + + Support for PySlide/PyQt +
  • +
  • + + Preview live +
  • +
  • + + Save and load files +
  • +
  • + + Load plugins locally +
  • +
  • + + Dark theme +
  • +
  • + + Priority support +
  • +
  • + + Early access to new features +
  • + +
+
+ + {/* Paid Plan */} +
+
+ Limited time offer +
+
+ Hobby +
+

+ + $129 + $29 + + Forever +

+

+ Support open-source development 🚀. Plus, get added benefits. +

+
+
    +
  • + + Access to web-based editor +
  • +
  • + + Downloadable UI builder exe for local development +
  • +
  • + + Preview live +
  • +
  • + + Save and load files +
  • +
  • + + Load plugins locally +
  • +
  • + + Dark theme +
  • +
  • + + Priority support +
  • +
  • + + Early access to new features +
  • +
  • + + Support for PySlide/PyQt +
  • +
  • + + Commercial use +
  • + +
+ + + Buy License + + +
+ + {/* Paid Plan */} +
+
+ Limited time offer +
+
+ Commercial +
+

+ + $180 + $49 + + Forever +

+

+ Support open-source development 🚀. Plus, get added benefits. +

+
+
    +
  • + + Access to web-based editor +
  • +
  • + + Downloadable UI builder exe for local development +
  • +
  • + + Preview live +
  • +
  • + + Save and load files +
  • +
  • + + Load plugins locally +
  • +
  • + + Dark theme +
  • +
  • + + Priority support +
  • +
  • + + Early access to new features +
  • +
  • + + Support for PySlide/PyQt +
  • +
  • + + Commercial use +
  • + +
+ + + Buy License + + +
+
+
+ + +
+ +
+ ) + +} + +export default Premium \ No newline at end of file diff --git a/src/sidebar/utils/share.js b/src/sidebar/utils/share.js new file mode 100644 index 0000000..705a708 --- /dev/null +++ b/src/sidebar/utils/share.js @@ -0,0 +1,110 @@ +import { useMemo, useState } from "react" + +import { Modal, message } from "antd" +import { CopyOutlined, FacebookFilled, LinkedinFilled, MediumCircleFilled, RedditCircleFilled, TwitchFilled, TwitterCircleFilled } from "@ant-design/icons" + + +function Share({children, className=""}){ + + const [shareModalOpen, setShareModalOpen] = useState(false) + + const shareInfo = useMemo(() => { + + return { + url: encodeURI("https://github.com/PaulleDemon/font-tester-chrome"), + text: "Check out Framework agnostic GUI builder for python" + } + }, []) + + const onClick = () => { + setShareModalOpen(true) + } + + const onClose = (event) => { + event.stopPropagation() + setShareModalOpen(false) + } + + const onCopy = (event) => { + event.stopPropagation() + navigator.clipboard.writeText(`Check out Font tester: ${shareInfo.url}`).then(function() { + message.success("Link copied to clipboard") + + }, function(err) { + message.error("Error copying to clipboard") + }) + } + + return ( +
+ {children} + Share PyUI Builder with others} + styles={{wrapper: {zIndex: 14000, gap: "10px"}}} + onCancel={onClose} + onOk={onClose} + footer={null} + open={shareModalOpen}> + + + + + +
+ ) + +} + +export default Share \ No newline at end of file diff --git a/src/sidebar/widgetsContainer.js b/src/sidebar/widgetsContainer.js index 3f28f02..b755e8a 100644 --- a/src/sidebar/widgetsContainer.js +++ b/src/sidebar/widgetsContainer.js @@ -2,11 +2,12 @@ import { useEffect, useMemo, useState } from "react" import { CloseCircleFilled, SearchOutlined } from "@ant-design/icons" -import {DraggableWidgetCard} from "../components/cards" +import {SidebarWidgetCard} from "../components/cards" import ButtonWidget from "../assets/widgets/button.png" import { filterObjectListStartingWith } from "../utils/filter" +import Widget from "../canvas/widgets/base" /** @@ -14,55 +15,30 @@ import { filterObjectListStartingWith } from "../utils/filter" * @param {function} onWidgetsUpdate - this is a callback that will be called once the sidebar is populated with widgets * @returns */ -function WidgetsContainer({onWidgetsUpdate}){ +function WidgetsContainer({sidebarContent, onWidgetsUpdate}){ - const widgets = useMemo(() => { - return [ - { - name: "TopLevel", - img: ButtonWidget, - link: "https://github.com" - }, - { - name: "Frame", - img: ButtonWidget, - link: "https://github.com" - }, - { - name: "Button", - img: ButtonWidget, - link: "https://github.com" - }, - { - name: "Input", - img: ButtonWidget, - link: "https://github.com" - }, - ] - }, []) const [searchValue, setSearchValue] = useState("") - const [widgetData, setWidgetData] = useState(widgets) + const [widgetData, setWidgetData] = useState(sidebarContent) useEffect(() => { - setWidgetData(widgets) + setWidgetData(sidebarContent) + // if (onWidgetsUpdate){ + // onWidgetsUpdate(widgets) + // } - if (onWidgetsUpdate){ - onWidgetsUpdate(widgets) - } - - }, [widgets]) + }, [sidebarContent]) useEffect(() => { if (searchValue.length > 0){ - const searchData = filterObjectListStartingWith(widgets, "name", searchValue) + const searchData = filterObjectListStartingWith(sidebarContent, "name", searchValue) setWidgetData(searchData) }else{ - setWidgetData(widgets) + setWidgetData(sidebarContent) } }, [searchValue]) @@ -94,10 +70,11 @@ function WidgetsContainer({onWidgetsUpdate}){ { widgetData.map((widget, index) => { return ( - ) diff --git a/src/styles/index.css b/src/styles/index.css index 961219b..c3c03fa 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -17,6 +17,16 @@ body { background-size: cover; } +.stripes-bg { + width: 100%; + height: 100%; + --color: #E1E1E1; + background-color: #F3F3F3; + background-image: linear-gradient(0deg, transparent 24%, var(--color) 25%, var(--color) 26%, transparent 27%,transparent 74%, var(--color) 75%, var(--color) 76%, transparent 77%,transparent), + linear-gradient(90deg, transparent 24%, var(--color) 25%, var(--color) 26%, transparent 27%,transparent 74%, var(--color) 75%, var(--color) 76%, transparent 77%,transparent); + background-size: 55px 55px; + } + .input{ border: 2px solid #e3e5e8; padding: 2px 8px; diff --git a/src/utils/widget.js b/src/utils/widget.js new file mode 100644 index 0000000..f839b39 --- /dev/null +++ b/src/utils/widget.js @@ -0,0 +1,16 @@ +import Widget from "../canvas/widgets/base" + +// checks if the object is instance is instance of widget class +export const isSubClassOfWidget = (_class) => { + + return Widget.isPrototypeOf(_class) || _class === Widget +} + + + +export const isInstanceOfWidget = (_class) => { + + console.log("Widget is instance of Object: ", _class) + + return _class instanceof Widget +}