diff --git a/package-lock.json b/package-lock.json index f3a2101..8414161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ant-design/icons": "^5.4.0", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/utilities": "^3.2.2", "@reduxjs/toolkit": "^2.2.7", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", diff --git a/package.json b/package.json index b7c54e3..14d702e 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dependencies": { "@ant-design/icons": "^5.4.0", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/utilities": "^3.2.2", "@reduxjs/toolkit": "^2.2.7", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", diff --git a/src/App.js b/src/App.js index bf76ed1..6f32af5 100644 --- a/src/App.js +++ b/src/App.js @@ -7,17 +7,28 @@ import WidgetsContainer from './sidebar/widgetsContainer' import UploadsContainer from './sidebar/uploadsContainer' import Canvas from './canvas/canvas' import Header from './components/header' +import { DndContext, useSensors, useSensor, PointerSensor, closestCorners, DragOverlay } from '@dnd-kit/core' +import { DraggableWidgetCard } from './components/cards' + function App() { const [uploadedAssets, setUploadedAssets] = useState([]) // a global storage for assets, since redux can't store files(serialize files) + const [sidebarWidgets, setSidebarWidgets] = useState([]) + const [canvasWidgets, setCanvasWidgets] = useState([]) // contains the reference to the widgets inside the canvas + + const [activeSidebarWidget, setActiveSidebarWidget] = useState(null) // helps with the dnd overlay - const tabs = [ + const sensors = useSensors( + useSensor(PointerSensor) + ) + + const sidebarTabs = [ { name: "Widgets", icon: , - content: + content: setSidebarWidgets(widgets)}/> }, { name: "Extensions", @@ -32,15 +43,56 @@ function App() { } ] + const handleDragStart = (event) => { + console.log("Dragging", event.active) + const draggedItem = sidebarWidgets.find((item) => item.name === event.active.id) + setActiveSidebarWidget(draggedItem) + } + + const handleDragMove = (event) => { + } + + const handleDragEnd = (event) => { + // add items to canvas from sidebar + const widgetItem = event.active.data.current?.title + + if (event.over?.id !== "cart-droppable" || !widgetItem) return + // const temp = [...widgets] + // temp.push(widgetItem) + // setCanvasWidgets(temp) + setActiveSidebarWidget(null) + + } + + return (
-
- - -
+ + +
+ + +
+ + {/* dragOverlay (dnd-kit) helps move items from one container to another */} + + {activeSidebarWidget ? ( + + ): + null} + + +
- ); + ) } export default App; diff --git a/src/canvas/canvas.js b/src/canvas/canvas.js index 656eb57..7671500 100644 --- a/src/canvas/canvas.js +++ b/src/canvas/canvas.js @@ -1,8 +1,11 @@ import React from "react" -import * as fabric from 'fabric' + +import {DndContext} from '@dnd-kit/core' + import { FullscreenOutlined, ReloadOutlined } from "@ant-design/icons" import { Button, Tooltip } from "antd" +import Droppable from "../components/utils/droppable" import Widget from "./widgets/base" import Cursor from "./constants/cursor" import { UID } from "../utils/uid" @@ -60,10 +63,6 @@ class Canvas extends React.Component { this.getCanvasObjectsBoundingBox = this.getCanvasObjectsBoundingBox.bind(this) this.fitCanvasToBoundingBox = this.fitCanvasToBoundingBox.bind(this) - this.updateWidgetPosition = this.updateWidgetPosition.bind(this) - - this.checkAndExpandCanvas = this.checkAndExpandCanvas.bind(this) - this.expandCanvas = this.expandCanvas.bind(this) this.clearSelections = this.clearSelections.bind(this) this.clearCanvas = this.clearCanvas.bind(this) @@ -195,7 +194,7 @@ class Canvas extends React.Component { const newPosY = y + (deltaY/this.state.zoom) // account for the zoom, since the widget is relative to canvas widget.setPos(newPosX, newPosY) - this.checkAndExpandCanvas(newPosX, newPosY, widget.getSize().width, widget.getSize().height) + // this.checkAndExpandCanvas(newPosX, newPosY, widget) }) // this.fitCanvasToBoundingBox(10) @@ -219,123 +218,10 @@ class Canvas extends React.Component { wheelZoom(event){ let delta = event.deltaY let zoom = this.state.zoom * 0.999 ** delta + this.setZoom(zoom, {x: event.offsetX, y: event.offsetY}) } - checkAndExpandCanvas(widgetX, widgetY, widgetWidth, widgetHeight) { - const canvasWidth = this.canvasRef.current.offsetWidth - const canvasHeight = this.canvasRef.current.offsetHeight - - const canvasRect = this.canvasRef.current.getBoundingClientRect() - - // Get the zoom level - const zoom = this.state.zoom - - // Calculate effective canvas boundaries considering zoom - const effectiveCanvasRight = canvasWidth - const effectiveCanvasBottom = canvasHeight - - // Calculate widget boundaries - const widgetRight = widgetX + widgetWidth - const widgetBottom = widgetY + widgetHeight - - // Determine if expansion is needed - const expandRight = widgetRight > effectiveCanvasRight - const expandDown = widgetBottom > effectiveCanvasBottom - const expandLeft = widgetX < canvasRect.left * this.state.zoom - const expandUp = widgetY < canvasRect.top - - if (expandRight || expandLeft || expandDown || expandUp) { - this.expandCanvas(expandRight, expandLeft, expandDown, expandUp, widgetX, widgetY, widgetWidth, widgetHeight) - } - } - - // Expand the canvas method - /** - * - * @param {boolean} expandRight - * @param {boolean} expandLeft - * @param {boolean} expandDown - * @param {boolean} expandUp - * @param {number} widgetX - * @param {number} widgetY - * @param {number} widgetRight - * @param {number} widgetBottom - */ - expandCanvas(expandRight, expandLeft, expandDown, expandUp, widgetX, widgetY, widgetWidth, widgetHeight) { - const currentWidth = this.canvasRef.current.offsetWidth - const currentHeight = this.canvasRef.current.offsetHeight - - console.log("current: ", expandRight, expandDown, expandLeft, expandUp) - - let newWidth = currentWidth - let newHeight = currentHeight - let newTranslateX = this.state.currentTranslate.x - let newTranslateY = this.state.currentTranslate.y - - if (expandRight) { - // const requiredWidth = widgetRight - newTranslateX // Add padding - // newWidth = Math.max(requiredWidth, currentWidth) - newWidth = currentWidth + 50 - } - - if (expandLeft) { - // const leftOffset = widgetX + newTranslateX // Position of the widget relative to the left edge - // const requiredLeftExpansion = -leftOffset + 50 // Add padding - newWidth = currentWidth + widgetWidth - newTranslateX -= widgetWidth // Adjust translation to move the canvas to the left - } - - if (expandDown) { - newHeight = currentHeight + 50 - - // const requiredHeight = widgetBottom - newTranslateY // Add padding - // newHeight = Math.max(requiredHeight, currentHeight) - } - - if (expandUp) { - newHeight = currentHeight + widgetHeight - newTranslateY -= widgetHeight - // const topOffset = widgetY + newTranslateY // Position of the widget relative to the top edge - // const requiredTopExpansion = -topOffset + 50 // Add padding - // newHeight = currentHeight + requiredTopExpansion - // newTranslateY -= requiredTopExpansion // Adjust translation to move the canvas upwards - } - - // Apply new dimensions and translation - this.canvasRef.current.style.width = `${newWidth}px` - this.canvasRef.current.style.height = `${newHeight}px` - - console.log("translate: ", this.canvasRef.current.offsetWidth, ) - // Now, to keep the widget in the same relative position: - const updatedWidgetX = widgetX - newTranslateX / this.state.zoom; - const updatedWidgetY = widgetY - newTranslateY / this.state.zoom; - - this.setState({ - currentTranslate: { - x: newTranslateX, - y: newTranslateY - } - }, () => { - this.applyTransform() - this.updateWidgetPosition(updatedWidgetX, updatedWidgetY, widgetWidth, widgetHeight) - }) - - } - - // TODO: FIX this, to ensure that the widget position remains the same - // Function to update the widget's position based on new updated canvas coordinates, use it after expandCanvas - updateWidgetPosition(widgetX, widgetY, widgetWidth, widgetHeight) { - const widgetElement = this.selectedWidgets[0].current; // Assuming the widget is referenced via `widgetRef` - - console.log("widget element: ", this.selectedWidgets[0].current) - widgetElement.style.left = `${widgetX}px`; - widgetElement.style.top = `${widgetY}px`; - widgetElement.style.width = `${widgetWidth}px`; - widgetElement.style.height = `${widgetHeight}px`; - } - - /** * fits the canvas size to fit the widgets bounding box */ @@ -382,6 +268,9 @@ class Canvas extends React.Component { } }, this.applyTransform) + // this.canvasRef.current.style.width = `${100/zoom}%` + // this.canvasRef.current.style.height = `${100/zoom}%` + } resetTransforms() { @@ -485,19 +374,24 @@ class Canvas extends React.Component { -
-
-
- { - this.state.widgets.map(this.renderWidget) - } + + {/* Canvas container */} +
+ {/* Canvas */} +
+
+ { + this.state.widgets.map(this.renderWidget) + } +
+
-
-
+
) } diff --git a/src/components/cards.js b/src/components/cards.js index 17ee7d1..6595281 100644 --- a/src/components/cards.js +++ b/src/components/cards.js @@ -6,7 +6,7 @@ import { FileImageOutlined, GithubOutlined, GitlabOutlined, LinkOutlined, FileTextOutlined} from "@ant-design/icons" -export function DraggableWidgetCard({name, img, url}){ +export function DraggableWidgetCard({ name, img, url}){ const urlIcon = useMemo(() => { if (url){ @@ -28,8 +28,8 @@ export function DraggableWidgetCard({name, img, url}){ return ( - -
+
{name} diff --git a/src/components/utils/draggable.js b/src/components/utils/draggable.js index 237b0af..1639494 100644 --- a/src/components/utils/draggable.js +++ b/src/components/utils/draggable.js @@ -1,18 +1,23 @@ 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: 'draggable', + id: props.id, + data: {title: props.children} }) const style = transform ? { - transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + transform: CSS.Translate.toString(transform), } : undefined return ( - ) diff --git a/src/components/utils/droppable.js b/src/components/utils/droppable.js new file mode 100644 index 0000000..00ee790 --- /dev/null +++ b/src/components/utils/droppable.js @@ -0,0 +1,20 @@ +import React from 'react' +import {useDroppable} from '@dnd-kit/core' + +function Droppable(props) { + const {isOver, setNodeRef} = useDroppable({ + id: props.id, + }) + const style = { + color: isOver ? 'green' : undefined, + } + + + return ( +
+ {props.children} +
+ ) +} + +export default Droppable \ No newline at end of file diff --git a/src/components/utils/panzoom.js b/src/components/utils/panzoom.js new file mode 100644 index 0000000..cff9ae4 --- /dev/null +++ b/src/components/utils/panzoom.js @@ -0,0 +1,415 @@ +// This is only for testing purpose, not really meant to be used +import './polyfills' + +import { + addPointer, + getDistance, + getMiddle, + removePointer +} from './pointers' +import { destroyPointer, eventNames, onPointer } from './events' +import { getDimensions, setStyle, setTransform, setTransition } from './css' + +import isAttached from './isAttached' +import isExcluded from './isExcluded' +import isSVGElement from './isSVGElement' +import shallowClone from './shallowClone' + +const defaultOptions = { + animate: false, + canvas: false, + cursor: 'move', + disablePan: false, + disableZoom: false, + disableXAxis: false, + disableYAxis: false, + duration: 200, + easing: 'ease-in-out', + exclude: [], + excludeClass: 'panzoom-exclude', + handleStartEvent: (e) => { + e.preventDefault() + e.stopPropagation() + }, + maxScale: 4, + minScale: 0.125, + overflow: 'hidden', + panOnlyWhenZoomed: false, + pinchAndPan: false, + relative: false, + setTransform, + startX: 0, + startY: 0, + startScale: 1, + step: 0.3, + touchAction: 'none' +} + +function Panzoom(elem, options = {}) { + if (!elem) { + throw new Error('Panzoom requires an element as an argument') + } + if (elem.nodeType !== 1) { + throw new Error('Panzoom requires an element with a nodeType of 1') + } + if (!isAttached(elem)) { + throw new Error('Panzoom should be called on elements that have been attached to the DOM') + } + + options = { + ...defaultOptions, + ...options + } + + const isSVG = isSVGElement(elem) + const parent = elem.parentNode + + // Set parent styles + parent.style.overflow = options.overflow // hidden + parent.style.userSelect = 'none' + parent.style.touchAction = options.touchAction + (options.canvas ? parent : elem).style.cursor = options.cursor + + // Set element styles + elem.style.userSelect = 'none' + elem.style.touchAction = options.touchAction + setStyle( + elem, + 'transformOrigin', + typeof options.origin === 'string' ? options.origin : isSVG ? '0 0' : '50% 50%' + ) + + function resetStyle() { + parent.style.overflow = '' + parent.style.userSelect = '' + parent.style.touchAction = '' + parent.style.cursor = '' + elem.style.cursor = '' + elem.style.userSelect = '' + elem.style.touchAction = '' + setStyle(elem, 'transformOrigin', '') + } + + function setOptions(opts = {}) { + for (const key in opts) { + if (opts.hasOwnProperty(key)) { + options[key] = opts[key] + } + } + // Handle option side-effects + if (opts.hasOwnProperty('cursor') || opts.hasOwnProperty('canvas')) { + parent.style.cursor = elem.style.cursor = '' + (options.canvas ? parent : elem).style.cursor = options.cursor + } + if (opts.hasOwnProperty('overflow')) { + parent.style.overflow = opts.overflow + } + if (opts.hasOwnProperty('touchAction')) { + parent.style.touchAction = opts.touchAction + elem.style.touchAction = opts.touchAction + } + } + + let x = 0 + let y = 0 + let scale = 1 + let isPanning = false + zoom(options.startScale, { animate: false, force: true }) + // Wait for scale to update + // for accurate dimensions + // to constrain initial values + setTimeout(() => { + pan(options.startX, options.startY, { animate: false, force: true }) + }) + + function trigger(eventName, detail, opts) { + if (opts.silent) { + return + } + const event = new CustomEvent(eventName, { detail }) + elem.dispatchEvent(event) + } + + function setTransformWithEvent( + eventName, + opts, + originalEvent + ) { + const value = { x, y, scale, isSVG, originalEvent } + requestAnimationFrame(() => { + if (typeof opts.animate === 'boolean') { + if (opts.animate) { + setTransition(elem, opts) + } else { + setStyle(elem, 'transition', 'none') + } + } + opts.setTransform(elem, value, opts) + trigger(eventName, value, opts) + trigger('panzoomchange', value, opts) + }) + return value + } + + function constrainXY( + toX, + toY, + toScale, + panOptions + ) { + const opts = { ...options, ...panOptions } + const result = { x, y, opts } + if (!opts.force && (opts.disablePan || (opts.panOnlyWhenZoomed && scale === opts.startScale))) { + return result + } + toX = parseFloat(toX) + toY = parseFloat(toY) + + if (!opts.disableXAxis) { + result.x = (opts.relative ? x : 0) + toX + } + + if (!opts.disableYAxis) { + result.y = (opts.relative ? y : 0) + toY + } + + if (opts.contain) { + const dims = getDimensions(elem) + const realWidth = dims.elem.width / scale + const realHeight = dims.elem.height / scale + const scaledWidth = realWidth * toScale + const scaledHeight = realHeight * toScale + const diffHorizontal = (scaledWidth - realWidth) / 2 + const diffVertical = (scaledHeight - realHeight) / 2 + + if (opts.contain === 'inside') { + const minX = (-dims.elem.margin.left - dims.parent.padding.left + diffHorizontal) / toScale + const maxX = + (dims.parent.width - + scaledWidth - + dims.parent.padding.left - + dims.elem.margin.left - + dims.parent.border.left - + dims.parent.border.right + + diffHorizontal) / + toScale + result.x = Math.max(Math.min(result.x, maxX), minX) + const minY = (-dims.elem.margin.top - dims.parent.padding.top + diffVertical) / toScale + const maxY = + (dims.parent.height - + scaledHeight - + dims.parent.padding.top - + dims.elem.margin.top - + dims.parent.border.top - + dims.parent.border.bottom + + diffVertical) / + toScale + result.y = Math.max(Math.min(result.y, maxY), minY) + } else if (opts.contain === 'outside') { + const minX = + (-(scaledWidth - dims.parent.width) - + dims.parent.padding.left - + dims.parent.border.left - + dims.parent.border.right + + diffHorizontal) / + toScale + const maxX = (diffHorizontal - dims.parent.padding.left) / toScale + result.x = Math.max(Math.min(result.x, maxX), minX) + const minY = + (-(scaledHeight - dims.parent.height) - + dims.parent.padding.top - + dims.parent.border.top - + dims.parent.border.bottom + + diffVertical) / + toScale + const maxY = (diffVertical - dims.parent.padding.top) / toScale + result.y = Math.max(Math.min(result.y, maxY), minY) + } + } + + if (opts.roundPixels) { + result.x = Math.round(result.x) + result.y = Math.round(result.y) + } + + return result + } + + function constrainScale(toScale, zoomOptions) { + const opts = { ...options, ...zoomOptions } + const result = { scale, opts } + if (!opts.force && opts.disableZoom) { + return result + } + + let minScale = options.minScale + let maxScale = options.maxScale + + if (opts.contain) { + const dims = getDimensions(elem) + const elemWidth = dims.elem.width / scale + const elemHeight = dims.elem.height / scale + if (elemWidth > 1 && elemHeight > 1) { + const parentWidth = dims.parent.width - dims.parent.border.left - dims.parent.border.right + const parentHeight = dims.parent.height - dims.parent.border.top - dims.parent.border.bottom + const elemScaledWidth = parentWidth / elemWidth + const elemScaledHeight = parentHeight / elemHeight + if (options.contain === 'inside') { + maxScale = Math.min(maxScale, elemScaledWidth, elemScaledHeight) + } else if (options.contain === 'outside') { + minScale = Math.max(minScale, elemScaledWidth, elemScaledHeight) + } + } + } + + result.scale = Math.min(Math.max(toScale, minScale), maxScale) + return result + } + + function pan( + toX, + toY, + panOptions, + originalEvent + ) { + const result = constrainXY(toX, toY, scale, panOptions) + + // Only try to set if the result is somehow different + if (x !== result.x || y !== result.y) { + x = result.x + y = result.y + return setTransformWithEvent('panzoompan', result.opts, originalEvent) + } + return { x, y, scale, isSVG, originalEvent } + } + + function zoom( + toScale, + zoomOptions, + focal + ) { + const result = constrainScale(toScale, zoomOptions) + const zoomScale = result.scale + const zoomOpts = result.opts + if (scale !== zoomScale) { + scale = zoomScale + let toX = x + let toY = y + if (focal) { + const focalX = focal.x || 0 + const focalY = focal.y || 0 + const diffScale = zoomScale / scale + toX = focalX - (focalX - x) * diffScale + toY = focalY - (focalY - y) * diffScale + } + return pan(toX, toY, zoomOpts, zoomOptions.originalEvent) + } + return { x, y, scale, isSVG: isSVGElement(elem), originalEvent: zoomOptions.originalEvent } + } + + function zoomIn(zoomOptions) { + zoom(scale + options.step, zoomOptions) + } + + function zoomOut(zoomOptions) { + zoom(scale - options.step, zoomOptions) + } + + function zoomToPoint(toScale, point, zoomOptions) { + zoom(toScale, zoomOptions, point) + } + + function zoomWithWheel(event) { + const delta = Math.sign(event.deltaY) * -0.1 + const zoomFactor = Math.pow(1.2, delta) + zoom(scale * zoomFactor, {}, { + x: event.clientX, + y: event.clientY + }) + } + + function handleDown(event) { + if (isExcluded(event.target, options.exclude, options.excludeClass)) { + return + } + event.preventDefault() + event.stopPropagation() + isPanning = true + const point = { x: event.clientX, y: event.clientY } + addPointer(event) + elem.style.cursor = options.cursor + trigger('panzoomstart', { x, y, scale, originalEvent: event }) + + function handleMove(e) { + if (!isPanning) { + return + } + e.preventDefault() + e.stopPropagation() + if (options.pinchAndPan && e.pointerType === 'touch') { + if (e.pointerId === e.pointers[0].pointerId) { + e.target.style.cursor = options.cursor + return + } + e.target.style.cursor = '' + const dist = getDistance(e) + const middle = getMiddle(e) + zoom(scale * (dist / e.pointers[0].distance), { + animate: false, + originalEvent: e + }, middle) + } else { + pan( + x + (e.clientX - point.x) / scale, + y + (e.clientY - point.y) / scale, + { animate: false, force: true }, + e + ) + } + } + + function handleUp(e) { + isPanning = false + removePointer(e) + if (e.pointerType !== 'touch' || e.pointerId !== e.pointers[0].pointerId) { + return + } + elem.style.cursor = '' + trigger('panzoomend', { x, y, scale, originalEvent: e }) + } + + onPointer(handleMove, handleUp, event) + } + + function reset() { + setOptions({ + ...defaultOptions, + ...options + }) + zoom(options.startScale, { animate: false, force: true }) + pan(options.startX, options.startY, { animate: false, force: true }) + } + + reset() + + return { + zoom, + zoomIn, + zoomOut, + zoomToPoint, + zoomWithWheel, + pan, + reset, + setOptions, + destroy() { + setStyle(elem, 'transition', '') + resetStyle() + destroyPointer() + eventNames.forEach((eventName) => { + elem.removeEventListener(eventName, handleDown) + }) + } + } +} + +export default Panzoom diff --git a/src/sidebar/widgetsContainer.js b/src/sidebar/widgetsContainer.js index 204a951..09b5e11 100644 --- a/src/sidebar/widgetsContainer.js +++ b/src/sidebar/widgetsContainer.js @@ -8,7 +8,13 @@ import ButtonWidget from "../assets/widgets/button.png" import { filterObjectListStartingWith } from "../utils/filter" -function WidgetsContainer(){ + +/** + * + * @param {function} onWidgetsUpdate - this is a callback that will be called once the sidebar is populated with widgets + * @returns + */ +function WidgetsContainer({onWidgetsUpdate}){ const widgets = useMemo(() => { return [ @@ -42,8 +48,14 @@ function WidgetsContainer(){ setWidgetData(widgets) + if (onWidgetsUpdate){ + onWidgetsUpdate(widgets) + } + }, [widgets]) + + useEffect(() => { if (searchValue.length > 0){ @@ -78,14 +90,15 @@ function WidgetsContainer(){
+ { widgetData.map((widget, index) => { return ( + name={widget.name} + img={widget.img} + url={widget.link} + /> ) })