diff --git a/app/canvas/canvas.tsx b/app/canvas/canvas.tsx index a112a54d23..510e2f468e 100644 --- a/app/canvas/canvas.tsx +++ b/app/canvas/canvas.tsx @@ -22,12 +22,17 @@ import { useInsertInstance, useDeleteInstance, useReparentInstance, - usePublishSelectedInstance, + usePublishSelectedInstanceData, usePublishRootInstance, useUpdateSelectedInstance, + usePublishSelectedInstanceDataRect, + usePublishHoveredInstanceRect, + usePublishHoveredInstanceData, + useSetHoveredInstance, + useUnselectInstance, } from "./shared/instance"; import { useUpdateStyle } from "./shared/style"; -import { useActiveElementTracking } from "./shared/active-element"; +import { useTrackSelectedElement } from "./shared/use-track-selected-element"; import { WrapperComponentDev } from "./features/wrapper-component"; import { useSync } from "./shared/sync"; import { useManageProps } from "./shared/props"; @@ -42,6 +47,7 @@ import { useRootInstance, } from "~/shared/nano-states"; import { registerContainers } from "./shared/immerhin"; +import { useTrackHoveredElement } from "./shared/use-track-hovered-element"; registerContainers(); @@ -96,15 +102,21 @@ const DesignMode = ({ treeId, project }: DesignModeProps) => { useDragDropHandlers(); useUpdateStyle(); useManageProps(); - usePublishSelectedInstance({ treeId }); + usePublishSelectedInstanceData(treeId); + usePublishHoveredInstanceData(); useHandleBreakpoints(); useInsertInstance(); useReparentInstance(); useDeleteInstance(); usePublishRootInstance(); - useActiveElementTracking(); + useTrackSelectedElement(); + useTrackHoveredElement(); + useSetHoveredInstance(); useSync({ project }); useUpdateSelectedInstance(); + usePublishSelectedInstanceDataRect(); + usePublishHoveredInstanceRect(); + useUnselectInstance(); const elements = useElementsTree(); return ( // Using touch backend becuase html5 drag&drop doesn't fire drag events in our case diff --git a/app/canvas/features/wrapper-component/editable/use-content-editable.tsx b/app/canvas/features/wrapper-component/editable/use-content-editable.tsx index f10584c232..be48678793 100644 --- a/app/canvas/features/wrapper-component/editable/use-content-editable.tsx +++ b/app/canvas/features/wrapper-component/editable/use-content-editable.tsx @@ -4,7 +4,7 @@ import useDebounce from "react-use/lib/useDebounce"; import ObjectId from "bson-objectid"; import { type ChildrenUpdates, type Instance } from "@webstudio-is/sdk"; import { type OnChangeChildren } from "~/shared/tree-utils"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; import { useSelectedInstance } from "~/canvas/shared/nano-states"; import { useEditable, type Content } from "./use-editable"; import { useToolbar } from "./use-toolbar"; diff --git a/app/canvas/features/wrapper-component/use-css.tsx b/app/canvas/features/wrapper-component/use-css.tsx index f5ee97dda4..3e4dd0c8f8 100644 --- a/app/canvas/features/wrapper-component/use-css.tsx +++ b/app/canvas/features/wrapper-component/use-css.tsx @@ -6,55 +6,7 @@ import { useSubscribe, toValue, } from "@webstudio-is/sdk"; -import { useDropData, useSelectedInstance } from "~/canvas/shared/nano-states"; -import { primitives, type StyleUpdates } from "~/shared/component"; - -// @todo this doesn't work with the node at the top edge of the iframe, tag gets hidden. -const componentTagStyle = { - "&::before": { - display: "flex", - content: "attr(data-label)", - padding: "0 $1", - marginTop: "-$4", - height: "$4", - position: "absolute", - backgroundColor: "$blue10", - color: "$loContrast", - alignItems: "center", - justifyContent: "center", - fontSize: "$1", - lineHeight: 1, - minWidth: "$6", - }, -}; - -const hoverOutlineStyle = { - "&:hover": { - outline: "1px solid $blue10", - outlineOffset: -1, - ...componentTagStyle, - }, -}; - -const emptyOutlineStyle = { - "&:empty": { - outline: "1px dashed #555", - outlineOffset: -1, - }, - ...hoverOutlineStyle, -}; - -const selectedOutlineStyle = { - outline: "2px solid $blue10", - outlineOffset: -2, - ...componentTagStyle, -}; - -const dragOverOutlineStyle = { - outline: "2px solid $blue10", - outlineOffset: -2, - ...componentTagStyle, -}; +import { type StyleUpdates } from "~/shared/canvas-components"; type UseCssProps = { instance: Instance; @@ -85,45 +37,40 @@ const usePreviewCss = ({ instance, css }: UseCssProps) => { value: undefined, })); setPreviewCss(reset); - // previewCss in deps leads to an infinite loop + // previewCss in deps leads to an infinite loop, css is like a cache key in this case, + // as soon as `css` changes we can reset the preview + // @todo need a more correct approach than this // eslint-disable-next-line react-hooks/exhaustive-deps }, [css]); return previewCss; }; +const voidElements = + "area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr"; + +const defaultStyle = { + [`&:not(${voidElements}):empty`]: { + outline: "1px dashed #555", + outlineOffset: -1, + paddingTop: "$8", + paddingRight: "$8", + }, + "&[contenteditable], &:focus": { + outline: 0, + }, +}; + export const useCss = ({ instance, css }: UseCssProps): string => { - const [dropData] = useDropData(); - const [selectedInstance] = useSelectedInstance(); const previewCss = usePreviewCss({ instance, css }); return useMemo(() => { - const primitive = primitives[instance.component]; - let overrides: CSS = hoverOutlineStyle; - if (primitive.canAcceptChild()) { - overrides = emptyOutlineStyle; - } - - if (selectedInstance?.id === instance.id) { - overrides = selectedOutlineStyle; - } - - if (dropData !== undefined) { - if (dropData.instance.id === instance.id && primitive.canAcceptChild()) { - overrides = dragOverOutlineStyle; - } - } - + const overrides: CSS = { ...defaultStyle }; for (const update of previewCss) { - // Delete ephemeral value - if (update.value === undefined) { - delete overrides[update.property]; - continue; - } - // @todo workaround for Expression produces a union type that is too complex to represent. + if (update.value === undefined) continue; overrides[update.property as string] = toValue(update.value); } return createCss(css)({ css: overrides }); - }, [dropData, selectedInstance, css, previewCss, instance]); + }, [css, previewCss]); }; diff --git a/app/canvas/features/wrapper-component/use-draggable.ts b/app/canvas/features/wrapper-component/use-draggable.ts index 79b87960b6..640570e43b 100644 --- a/app/canvas/features/wrapper-component/use-draggable.ts +++ b/app/canvas/features/wrapper-component/use-draggable.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useDrag } from "react-dnd"; import { type Instance, publish } from "@webstudio-is/sdk"; -import { type DragData } from "~/shared/component"; +import { type DragData } from "~/shared/canvas-components"; //import { usePointerOutline } from "~/canvas/hooks"; type UseDraggable = { diff --git a/app/canvas/features/wrapper-component/wrapper-component.tsx b/app/canvas/features/wrapper-component/wrapper-component.tsx index 38c3d9bebf..bb209621c5 100644 --- a/app/canvas/features/wrapper-component/wrapper-component.tsx +++ b/app/canvas/features/wrapper-component/wrapper-component.tsx @@ -6,7 +6,7 @@ import { useUserProps, renderWrapperComponentChildren, } from "@webstudio-is/sdk"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; import { useContentEditable } from "./editable"; import { useCss } from "./use-css"; import { useDraggable } from "./use-draggable"; @@ -74,7 +74,6 @@ export const WrapperComponentDev = ({ id={instance.id} tabIndex={0} data-component={instance.component} - data-label={primitives[instance.component].label} data-id={instance.id} ref={refCallback} onClick={(event: MouseEvent) => { diff --git a/app/canvas/shared/active-element.ts b/app/canvas/shared/active-element.ts deleted file mode 100644 index 2c1e360566..0000000000 --- a/app/canvas/shared/active-element.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { type Instance, publish, useSubscribe } from "@webstudio-is/sdk"; -import { findInstanceById } from "~/shared/tree-utils"; -import { useSelectedInstance, useSelectedElement } from "./nano-states"; -import { useRootInstance } from "~/shared/nano-states"; - -const eventOptions = { - passive: true, -}; - -export const useActiveElementTracking = () => { - const [rootInstance] = useRootInstance(); - const [selectedInstance, setSelectedInstance] = useSelectedInstance(); - const [selectedElement, setSelectedElement] = useSelectedElement(); - - useSubscribe("focusElement", (id: Instance["id"]) => { - const element = document.getElementById(id); - element?.focus(); - select(element); - }); - - const select = useCallback( - (element: Element | null) => { - if (element === null || rootInstance === undefined) return; - const id = element?.id; - if (!id) return; - if (selectedElement?.id === id && selectedInstance?.id === id) { - return; - } - const instance = findInstanceById(rootInstance, element.id); - if (instance === undefined) return; - setSelectedInstance(instance); - setSelectedElement(element); - }, - [ - rootInstance, - selectedElement, - setSelectedElement, - selectedInstance, - setSelectedInstance, - ] - ); - - useEffect(() => { - const handleFocus = () => { - select(document.activeElement); - }; - - window.addEventListener("focus", handleFocus, eventOptions); - - const handleClick = (event: MouseEvent) => { - // Notify in general that document was clicked - // e.g. to hide the side panel - publish<"clickCanvas">({ type: "clickCanvas" }); - if (event.target instanceof Element) select(event.target); - }; - window.addEventListener("click", handleClick, eventOptions); - - return () => { - window.removeEventListener("focus", handleFocus); - window.removeEventListener("click", handleClick); - }; - }, [select]); - - return selectedInstance; -}; diff --git a/app/canvas/shared/instance.ts b/app/canvas/shared/instance.ts index 1e2a14a2f2..91bbdb4427 100644 --- a/app/canvas/shared/instance.ts +++ b/app/canvas/shared/instance.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import ObjectId from "bson-objectid"; import { type InstanceProps, @@ -19,9 +19,19 @@ import { findInstanceById, } from "~/shared/tree-utils"; import store from "immerhin"; -import { DropData, type SelectedInstanceData } from "~/shared/component"; -import { useSelectedInstance, useSelectedElement } from "./nano-states"; +import { + DropData, + HoveredInstanceData, + type SelectedInstanceData, +} from "~/shared/canvas-components"; +import { + useSelectedInstance, + useSelectedElement, + useHoveredElement, + useHoveredInstance, +} from "./nano-states"; import { rootInstanceContainer, useRootInstance } from "~/shared/nano-states"; +import { useMeasure } from "~/shared/dom-hooks"; export const usePopulateRootInstance = (tree: Tree) => { const [, setRootInstance] = useRootInstance(); @@ -112,11 +122,7 @@ export const useDeleteInstance = () => { ); }; -export const usePublishSelectedInstance = ({ - treeId, -}: { - treeId: Tree["id"]; -}) => { +export const usePublishSelectedInstanceData = (treeId: Tree["id"]) => { const [instance] = useSelectedInstance(); const [selectedElement] = useSelectedElement(); const [allUserProps] = useAllUserProps(); @@ -146,7 +152,6 @@ export const usePublishSelectedInstance = ({ props, }; } - publish<"selectInstance", SelectedInstanceData>({ type: "selectInstance", payload, @@ -154,24 +159,21 @@ export const usePublishSelectedInstance = ({ }, [instance, allUserProps, treeId, browserStyle]); }; -/** - * We need to set the selected instance after a any root instance update, - * because anything that we change on the selected instance is actually done on the root, so - * when we run "undo", root is going to be undone but not the selected instance, unless we update it here. - */ -export const useUpdateSelectedInstance = () => { - const [rootInstance] = useRootInstance(); - const [selectedInstance, setSelectedInstance] = useSelectedInstance(); +export const usePublishHoveredInstanceData = () => { + const [instance] = useHoveredInstance(); useEffect(() => { - if (rootInstance === undefined || selectedInstance === undefined) return; - const nextSelectedInstance = findInstanceById( - rootInstance, - selectedInstance.id - ); - if (nextSelectedInstance === undefined) return; - setSelectedInstance(nextSelectedInstance); - }, [rootInstance, selectedInstance, setSelectedInstance]); + const payload = instance + ? { + id: instance.id, + component: instance.component, + } + : undefined; + publish<"hoverInstance", HoveredInstanceData | undefined>({ + type: "hoverInstance", + payload, + }); + }, [instance]); }; export const usePublishRootInstance = () => { @@ -183,3 +185,77 @@ export const usePublishRootInstance = () => { }); }, [rootInstance]); }; + +const publishRect = (rect: DOMRect) => { + publish<"selectedInstanceRect", DOMRect>({ + type: "selectedInstanceRect", + payload: rect, + }); +}; + +export const usePublishSelectedInstanceDataRect = () => { + const [element] = useSelectedElement(); + const [refCallback, rect] = useMeasure(); + + useEffect(() => { + // Disconnect observer when there is no element. + refCallback(element ?? null); + }, [element, refCallback]); + + useEffect(() => { + if (rect !== undefined) publishRect(rect); + }, [rect]); +}; + +export const usePublishHoveredInstanceRect = () => { + const [element] = useHoveredElement(); + const publishRect = useCallback(() => { + if (element === undefined) return; + publish<"hoveredInstanceRect", DOMRect>({ + type: "hoveredInstanceRect", + payload: element.getBoundingClientRect(), + }); + }, [element]); + useEffect(publishRect, [publishRect]); +}; + +export const useSetHoveredInstance = () => { + const [rootInstance] = useRootInstance(); + const [hoveredElement] = useHoveredElement(); + const [, setHoveredInstance] = useHoveredInstance(); + + useEffect(() => { + let instance; + if (rootInstance !== undefined && hoveredElement?.id) { + instance = findInstanceById(rootInstance, hoveredElement.id); + } + setHoveredInstance(instance); + }, [rootInstance, hoveredElement, setHoveredInstance]); +}; + +/** + * We need to set the selected instance after a any root instance update, + * because anything that we change on the selected instance is actually done on the root, so + * when we run "undo", root is going to be undone but not the selected instance, unless we update it here. + */ +export const useUpdateSelectedInstance = () => { + const [rootInstance] = useRootInstance(); + const [selectedInstance, setSelectedInstance] = useSelectedInstance(); + + // When selected instance or root instance changes - we want to make sure the instance with that id still exists in the root. + useEffect(() => { + let instance; + if (rootInstance !== undefined && selectedInstance?.id) { + instance = findInstanceById(rootInstance, selectedInstance.id); + } + // When it's a new inserted instance, it will be undefined, so we can't set it to undefined and remove it. + if (instance !== undefined) setSelectedInstance(instance); + }, [rootInstance, selectedInstance, setSelectedInstance]); +}; + +export const useUnselectInstance = () => { + const [, setSelectedInstance] = useSelectedInstance(); + useSubscribe("unselectInstance", () => { + setSelectedInstance(undefined); + }); +}; diff --git a/app/canvas/shared/nano-states.ts b/app/canvas/shared/nano-states.ts index 75b85f5b1d..d41ec81289 100644 --- a/app/canvas/shared/nano-states.ts +++ b/app/canvas/shared/nano-states.ts @@ -1,6 +1,6 @@ import { createValueContainer, useValue } from "react-nano-state"; import { type Instance } from "@webstudio-is/sdk"; -import { type DropData } from "~/shared/component"; +import { type DropData } from "~/shared/canvas-components"; const dropDataContainer = createValueContainer(); export const useDropData = () => useValue(dropDataContainer); @@ -10,5 +10,15 @@ export const selectedInstanceContainer = createValueContainer< >(); export const useSelectedInstance = () => useValue(selectedInstanceContainer); -const selectedElementContainer = createValueContainer(); +export const hoveredInstanceContainer = createValueContainer< + Instance | undefined +>(); +export const useHoveredInstance = () => useValue(hoveredInstanceContainer); + +const selectedElementContainer = createValueContainer< + HTMLElement | undefined +>(); export const useSelectedElement = () => useValue(selectedElementContainer); + +const hoveredElementContainer = createValueContainer(); +export const useHoveredElement = () => useValue(hoveredElementContainer); diff --git a/app/canvas/shared/style.ts b/app/canvas/shared/style.ts index ed0ca85e2d..a402079bff 100644 --- a/app/canvas/shared/style.ts +++ b/app/canvas/shared/style.ts @@ -1,6 +1,6 @@ import store from "immerhin"; import { useSubscribe } from "@webstudio-is/sdk"; -import { type StyleUpdates } from "~/shared/component"; +import { type StyleUpdates } from "~/shared/canvas-components"; import { setInstanceStyleMutable } from "~/shared/tree-utils"; import { useSelectedInstance } from "./nano-states"; import { rootInstanceContainer } from "~/shared/nano-states"; diff --git a/app/canvas/shared/use-drag-drop-handlers.ts b/app/canvas/shared/use-drag-drop-handlers.ts index 3cdb88924e..a7eaa1e1f4 100644 --- a/app/canvas/shared/use-drag-drop-handlers.ts +++ b/app/canvas/shared/use-drag-drop-handlers.ts @@ -1,27 +1,35 @@ import { useState } from "react"; -import memoize from "lodash.memoize"; import { publish, useSubscribe } from "@webstudio-is/sdk"; -import { type DragData, type DropData } from "~/shared/component"; +import { type DragData, type DropData } from "~/shared/canvas-components"; import { findInstanceById } from "~/shared/tree-utils"; import { findClosestChild, findInsertionIndex, getDragOverInfo, } from "~/shared/dom-utils"; -import { useDropData, useSelectedInstance } from "./nano-states"; +import { + useDropData, + useHoveredElement, + useHoveredInstance, + useSelectedInstance, +} from "./nano-states"; import { useRootInstance } from "~/shared/nano-states"; +import memoize from "lodash.memoize"; //import {usePointerOutline} from './use-pointer-outline' -// Avoid recalculating rects for each node during dragging. -const getBoundingClientRect = memoize((element) => +const getBoundingClientRect = memoize((element: Element | HTMLElement) => element.getBoundingClientRect() ); -const getComputedStyle = memoize((element) => window.getComputedStyle(element)); +const getComputedStyle = memoize((element: Element | HTMLElement) => + window.getComputedStyle(element) +); export const useDragDropHandlers = () => { const [rootInstance] = useRootInstance(); const [, setSelectedInstance] = useSelectedInstance(); + const [, setHoveredInstance] = useHoveredInstance(); + const [, setHoveredElement] = useHoveredElement(); const [dropData, setDropData] = useDropData(); const [dragData, setDragData] = useState(); @@ -87,20 +95,22 @@ export const useDragDropHandlers = () => { getComputedStyle ); - let insertionIndex = 0; + let position = 0; // When element has children. if (dragOver.element !== undefined && closestChild !== undefined) { - insertionIndex = findInsertionIndex(dragOver, closestChild); + position = findInsertionIndex(dragOver, closestChild); } const dropData = { instance: dropInstance, - position: insertionIndex, + position, }; setDragData(dragData); setDropData(dropData); + setHoveredInstance(dropInstance); + setHoveredElement(dragOver.element); publish<"dropPreview", { dropData: DropData; dragData: DragData }>({ type: "dropPreview", payload: { dropData, dragData }, diff --git a/app/canvas/shared/use-track-hovered-element.ts b/app/canvas/shared/use-track-hovered-element.ts new file mode 100644 index 0000000000..872828b03d --- /dev/null +++ b/app/canvas/shared/use-track-hovered-element.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useSelectedElement, useHoveredElement } from "./nano-states"; +import { useRootInstance } from "~/shared/nano-states"; + +const eventOptions = { + passive: true, +}; + +export const useTrackHoveredElement = () => { + const [rootInstance] = useRootInstance(); + const [, setHoveredElement] = useHoveredElement(); + const [selectedElement] = useSelectedElement(); + + useEffect(() => { + const handleMouseOver = (event: MouseEvent) => { + const element = event.target; + if (rootInstance === undefined || !(element instanceof HTMLElement)) { + return; + } + setHoveredElement(element); + }; + + const handleMouseOut = () => { + if (rootInstance === undefined) return; + setHoveredElement(undefined); + }; + + window.addEventListener("mouseover", handleMouseOver, eventOptions); + window.addEventListener("mouseout", handleMouseOut, eventOptions); + + return () => { + window.removeEventListener("mouseover", handleMouseOver); + window.removeEventListener("mouseout", handleMouseOut); + }; + }, [rootInstance, selectedElement, setHoveredElement]); +}; diff --git a/app/canvas/shared/use-track-selected-element.ts b/app/canvas/shared/use-track-selected-element.ts new file mode 100644 index 0000000000..e7fea08806 --- /dev/null +++ b/app/canvas/shared/use-track-selected-element.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect } from "react"; +import { type Instance, publish, useSubscribe } from "@webstudio-is/sdk"; +import { useSelectedElement, useSelectedInstance } from "./nano-states"; +import { useRootInstance } from "~/shared/nano-states"; +import { findInstanceById } from "~/shared/tree-utils"; + +const eventOptions = { + passive: true, +}; + +export const useTrackSelectedElement = () => { + const [selectedElement, setSelectedElement] = useSelectedElement(); + const [selectedInstance, setSelectedInstance] = useSelectedInstance(); + const [rootInstance] = useRootInstance(); + + const selectInstance = useCallback( + (id) => { + if (rootInstance === undefined) return; + const instance = findInstanceById(rootInstance, id); + setSelectedInstance(instance); + }, + [setSelectedInstance, rootInstance] + ); + + const focusAndSelect = useCallback( + (id: Instance["id"]) => { + const element = document.getElementById(id); + if (element === null) return; + element.focus(); + setSelectedElement(element); + }, + [setSelectedElement] + ); + + useSubscribe("selectInstance", focusAndSelect); + + // Focus and select the element when selected instance changes + useEffect(() => { + if ( + selectedInstance !== undefined && + (selectedElement === undefined || + selectedInstance?.id !== selectedElement.id) + ) { + focusAndSelect(selectedInstance.id); + } + }, [selectedInstance, selectedElement, focusAndSelect]); + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + // Notify in general that document was clicked + // e.g. to hide the side panel + publish<"clickCanvas">({ type: "clickCanvas" }); + if (event.target instanceof HTMLElement) { + selectInstance(event.target.id); + } + }; + window.addEventListener("click", handleClick, eventOptions); + + return () => { + window.removeEventListener("click", handleClick); + }; + }, [selectInstance]); +}; diff --git a/app/designer/designer.tsx b/app/designer/designer.tsx index 3d5dcf94af..f2814f61fb 100644 --- a/app/designer/designer.tsx +++ b/app/designer/designer.tsx @@ -3,16 +3,18 @@ import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { type Project, useSubscribe, usePublish } from "@webstudio-is/sdk"; import type { Config } from "~/config"; -import type { SelectedInstanceData } from "~/shared/component"; +import type { + HoveredInstanceData, + SelectedInstanceData, +} from "~/shared/canvas-components"; import { Box, Flex, Grid, type CSS } from "~/shared/design-system"; import interStyles from "~/shared/font-faces/inter.css"; import { SidebarLeft } from "./features/sidebar-left"; import { Inspector } from "./features/inspector"; -import { CanvasIframe } from "./shared/canvas-iframe"; import { + useHoveredInstanceData, useSelectedInstanceData, useSyncStatus, - useCanvasWidth, } from "./shared/nano-states"; import { Topbar } from "./features/topbar"; import designerStyles from "./designer.css"; @@ -22,7 +24,11 @@ import { useUpdateCanvasWidth, useSubscribeBreakpoints, } from "./features/breakpoints"; -import { useReadCanvasRect, Workspace } from "./features/workspace"; +import { + useReadCanvasRect, + Workspace, + CanvasIframe, +} from "./features/workspace"; import { usePublishShortcuts } from "./shared/shortcuts"; import { type SyncStatus } from "~/shared/sync"; import { useIsPreviewMode, useRootInstance } from "~/shared/nano-states"; @@ -47,6 +53,11 @@ const useSubscribeSelectedInstanceData = () => { ); }; +const useSubscribeHoveredInstanceData = () => { + const [, setValue] = useHoveredInstanceData(); + useSubscribe<"hoverInstance", HoveredInstanceData>("hoverInstance", setValue); +}; + const useSubscribeSyncStatus = () => { const [, setValue] = useSyncStatus(); useSubscribe<"syncStatus", SyncStatus>("syncStatus", setValue); @@ -156,12 +167,12 @@ export const Designer = ({ config, project }: DesignerProps) => { useSubscribeSyncStatus(); useSubscribeRootInstance(); useSubscribeSelectedInstanceData(); + useSubscribeHoveredInstanceData(); useSubscribeBreakpoints(); const [publish, publishRef] = usePublish(); const [isPreviewMode] = useIsPreviewMode(); const [isDragging, setIsDragging] = useIsDragging(); usePublishShortcuts(publish); - const [canvasWidth] = useCanvasWidth(); const onRefReadCanvasWidth = useUpdateCanvasWidth(); const { onRef: onRefReadCanvas, onTransitionEnd } = useReadCanvasRect(); @@ -187,7 +198,7 @@ export const Designer = ({ config, project }: DesignerProps) => { publish={publish} />
- + { title={project.title} css={{ height: "100%", + width: "100%", }} - style={{ width: canvasWidth }} /> diff --git a/app/designer/features/breadcrumbs/breadcrumbs.tsx b/app/designer/features/breadcrumbs/breadcrumbs.tsx index 4e52099a31..ffc15f68b6 100644 --- a/app/designer/features/breadcrumbs/breadcrumbs.tsx +++ b/app/designer/features/breadcrumbs/breadcrumbs.tsx @@ -48,8 +48,8 @@ export const Breadcrumbs = ({ publish }: BreadcrumbsProps) => { key={instance.id} component={instance.component} onClick={() => { - publish<"focusElement", Instance["id"]>({ - type: "focusElement", + publish<"selectInstance", Instance["id"]>({ + type: "selectInstance", payload: instance.id, }); }} diff --git a/app/designer/features/settings-panel/settings-panel.tsx b/app/designer/features/settings-panel/settings-panel.tsx index b5f7a33225..73d0821f6e 100644 --- a/app/designer/features/settings-panel/settings-panel.tsx +++ b/app/designer/features/settings-panel/settings-panel.tsx @@ -2,7 +2,7 @@ import { type UserProp, type Publish } from "@webstudio-is/sdk"; import { CollapsibleSection, ComponentInfo } from "~/designer/shared/inspector"; import { TextField, Flex, Button, Box } from "~/shared/design-system"; import { PlusIcon, TrashIcon } from "~/shared/icons"; -import type { SelectedInstanceData } from "~/shared/component"; +import type { SelectedInstanceData } from "~/shared/canvas-components"; import { usePropsLogic } from "./use-props-logic"; type PropertyProps = { diff --git a/app/designer/features/settings-panel/use-props-logic.ts b/app/designer/features/settings-panel/use-props-logic.ts index 20fb7884af..5fe80661fa 100644 --- a/app/designer/features/settings-panel/use-props-logic.ts +++ b/app/designer/features/settings-panel/use-props-logic.ts @@ -8,7 +8,7 @@ import type { UserPropsUpdates, Publish, } from "@webstudio-is/sdk"; -import type { SelectedInstanceData } from "~/shared/component"; +import type { SelectedInstanceData } from "~/shared/canvas-components"; const getInitialProp = () => ({ id: ObjectId().toString(), diff --git a/app/designer/features/sidebar-left/panels/components/component-thumb.tsx b/app/designer/features/sidebar-left/panels/components/component-thumb.tsx index b846a415e6..82d682fa7a 100644 --- a/app/designer/features/sidebar-left/panels/components/component-thumb.tsx +++ b/app/designer/features/sidebar-left/panels/components/component-thumb.tsx @@ -1,6 +1,6 @@ import { type Instance } from "@webstudio-is/sdk"; import { forwardRef, type ElementRef, type ComponentProps } from "react"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; import { Flex, Text, styled } from "~/shared/design-system"; const Thumb = styled(Flex, { diff --git a/app/designer/features/sidebar-left/panels/components/components.tsx b/app/designer/features/sidebar-left/panels/components/components.tsx index a0c56a23c7..9ff340da79 100644 --- a/app/designer/features/sidebar-left/panels/components/components.tsx +++ b/app/designer/features/sidebar-left/panels/components/components.tsx @@ -4,7 +4,7 @@ import { getEmptyImage } from "react-dnd-html5-backend"; import { type Instance, type Publish } from "@webstudio-is/sdk"; import { Flex } from "~/shared/design-system"; import { PlusIcon } from "~/shared/icons"; -import { primitives, type DragData } from "~/shared/component"; +import { primitives, type DragData } from "~/shared/canvas-components"; import { createInstance } from "~/shared/tree-utils"; import type { TabName } from "../../types"; import { CustomDragLayer } from "./custom-drag-layer"; diff --git a/app/designer/features/sidebar-left/panels/tree.tsx b/app/designer/features/sidebar-left/panels/tree.tsx index a14464067d..12ab4a1978 100644 --- a/app/designer/features/sidebar-left/panels/tree.tsx +++ b/app/designer/features/sidebar-left/panels/tree.tsx @@ -1,7 +1,7 @@ import { type Instance, type Publish } from "@webstudio-is/sdk"; import { ListNestedIcon } from "~/shared/icons"; import { Flex } from "~/shared/design-system"; -import { type SelectedInstanceData } from "~/shared/component"; +import { type SelectedInstanceData } from "~/shared/canvas-components"; import { useSelectedInstancePath } from "~/designer/shared/instance/use-selected-instance-path"; import { Tree } from "~/designer/shared/tree"; import { useRootInstance } from "~/shared/nano-states"; @@ -29,8 +29,8 @@ export const TabContent = ({ selectedInstancePath={selectedInstancePath} selectedInstanceId={selectedInstanceData?.id} onSelect={(instance) => { - publish<"focusElement", Instance["id"]>({ - type: "focusElement", + publish<"selectInstance", Instance["id"]>({ + type: "selectInstance", payload: instance.id, }); }} diff --git a/app/designer/features/style-panel/settings.tsx b/app/designer/features/style-panel/settings.tsx index d78de80872..fbe29866b2 100644 --- a/app/designer/features/style-panel/settings.tsx +++ b/app/designer/features/style-panel/settings.tsx @@ -14,7 +14,7 @@ import { renderProperty } from "./render-property"; import { dependencies } from "./dependencies"; import { type InheritedStyle } from "./get-inherited-style"; import { type SetProperty } from "./use-style-data"; -import { type SelectedInstanceData } from "~/shared/component"; +import { type SelectedInstanceData } from "~/shared/canvas-components"; // Finds a property/value by using any available form: property, label, value const filterProperties = (properties: Array, search: string) => { diff --git a/app/designer/features/style-panel/style-panel.tsx b/app/designer/features/style-panel/style-panel.tsx index 3051bbf62a..8cccc80c43 100644 --- a/app/designer/features/style-panel/style-panel.tsx +++ b/app/designer/features/style-panel/style-panel.tsx @@ -1,7 +1,7 @@ import type { Publish } from "@webstudio-is/sdk"; import { willRender } from "~/designer/shared/breakpoints"; import { Box, Card, Paragraph } from "~/shared/design-system"; -import type { SelectedInstanceData } from "~/shared/component"; +import type { SelectedInstanceData } from "~/shared/canvas-components"; import { useStyleData } from "./use-style-data"; import { ComponentInfo } from "../../shared/inspector"; import { VisualSettings } from "./settings"; diff --git a/app/designer/features/style-panel/use-style-data.ts b/app/designer/features/style-panel/use-style-data.ts index e4546c22b2..7a6dccd578 100644 --- a/app/designer/features/style-panel/use-style-data.ts +++ b/app/designer/features/style-panel/use-style-data.ts @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useCallback } from "react"; -import type { SelectedInstanceData, StyleUpdates } from "~/shared/component"; +import type { SelectedInstanceData, StyleUpdates } from "~/shared/canvas-components"; import { type StyleProperty, type Publish } from "@webstudio-is/sdk"; import { useSelectedBreakpoint } from "../../shared/nano-states"; import { parseCssValue } from "./parse-css-value"; diff --git a/app/designer/features/tree-preview/tree-preview.tsx b/app/designer/features/tree-preview/tree-preview.tsx index a32e49b2ea..a1b9e0dd6e 100644 --- a/app/designer/features/tree-preview/tree-preview.tsx +++ b/app/designer/features/tree-preview/tree-preview.tsx @@ -2,7 +2,7 @@ import { Instance, useSubscribe } from "@webstudio-is/sdk"; import produce from "immer"; import { useState } from "react"; import { Tree } from "~/designer/shared/tree"; -import { type DragData, type DropData } from "~/shared/component"; +import { type DragData, type DropData } from "~/shared/canvas-components"; import { Flex } from "~/shared/design-system"; import { useRootInstance } from "~/shared/nano-states"; import { diff --git a/app/designer/shared/canvas-iframe/canvas-iframe.tsx b/app/designer/features/workspace/canvas-iframe.tsx similarity index 100% rename from app/designer/shared/canvas-iframe/canvas-iframe.tsx rename to app/designer/features/workspace/canvas-iframe.tsx diff --git a/app/designer/features/workspace/canvas-tools/canvas-tools.tsx b/app/designer/features/workspace/canvas-tools/canvas-tools.tsx new file mode 100644 index 0000000000..70f1d3c4cf --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/canvas-tools.tsx @@ -0,0 +1,26 @@ +import { Box } from "~/shared/design-system"; +import { useIsPreviewMode } from "~/shared/nano-states"; +import { HoveredInstanceOutline, SelectedInstanceOutline } from "./outline"; +import { useSubscribeInstanceRect } from "./use-subscribe-instance-rect"; + +const toolsStyle = { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + pointerEvents: "none", + overflow: "hidden", +}; + +export const CanvasTools = () => { + useSubscribeInstanceRect(); + const [isPreviewMode] = useIsPreviewMode(); + if (isPreviewMode) return null; + return ( + + + + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/index.ts b/app/designer/features/workspace/canvas-tools/index.ts new file mode 100644 index 0000000000..dcd1e2bae5 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/index.ts @@ -0,0 +1 @@ +export { CanvasTools } from "./canvas-tools"; diff --git a/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx b/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx new file mode 100644 index 0000000000..0c3e6acd57 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/hovered-instance-outline.tsx @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { useHoveredInstanceRect } from "~/shared/nano-states"; +import { + useHoveredInstanceData, + useSelectedInstanceData, +} from "~/designer/shared/nano-states"; +import { Outline } from "./outline"; +import { Label } from "./label"; + +const useStyle = (rect?: DOMRect) => { + return useMemo(() => { + if (rect === undefined) return; + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }, [rect]); +}; + +export const HoveredInstanceOutline = () => { + const [selectedInstanceData] = useSelectedInstanceData(); + const [instanceRect] = useHoveredInstanceRect(); + const style = useStyle(instanceRect); + const [instanceData] = useHoveredInstanceData(); + + if ( + style === undefined || + instanceData === undefined || + instanceRect === undefined || + selectedInstanceData?.id === instanceData.id + ) { + return null; + } + + return ( + + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/outline/index.ts b/app/designer/features/workspace/canvas-tools/outline/index.ts new file mode 100644 index 0000000000..1cf83e8390 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/index.ts @@ -0,0 +1,2 @@ +export { SelectedInstanceOutline } from "./selected-instance-outline"; +export { HoveredInstanceOutline } from "./hovered-instance-outline"; diff --git a/app/designer/features/workspace/canvas-tools/outline/label.tsx b/app/designer/features/workspace/canvas-tools/outline/label.tsx new file mode 100644 index 0000000000..bf6add852b --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/label.tsx @@ -0,0 +1,86 @@ +import { useCallback, useState } from "react"; +import { styled } from "~/shared/design-system"; +import { primitives } from "~/shared/canvas-components"; +import { type Instance } from "@webstudio-is/sdk"; + +type LabelPosition = "top" | "inside" | "bottom"; +type LabelRefCallback = (element: HTMLElement | null) => void; + +/** + * Detects if there is no space on top and for the label and tells to show it inside. + * - if there is enough space for the label on top of the instance - top + * - else if instance height is more than 250px - bottom + * - else inside-top - last resort because it covers a bit of the instance content + */ +const useLabelPosition = ( + instanceRect: DOMRect +): [LabelRefCallback, LabelPosition] => { + const [position, setPosition] = useState("top"); + + const ref = useCallback( + (element) => { + if (element === null || instanceRect === undefined) return; + const labelRect = element.getBoundingClientRect(); + let nextPosition: LabelPosition = "top"; + if (labelRect.height > instanceRect.top) { + nextPosition = instanceRect.height < 250 ? "bottom" : "inside"; + } + setPosition(nextPosition); + }, + [instanceRect] + ); + + return [ref, position]; +}; + +const LabelContainer = styled( + "div", + { + position: "absolute", + display: "flex", + padding: "0 $1", + height: "$4", + color: "$hiContrast", + alignItems: "center", + justifyContent: "center", + gap: "$1", + fontSize: "$2", + fontFamily: "$sans", + lineHeight: 1, + minWidth: "$6", + whiteSpace: "nowrap", + backgroundColor: "$blue9", + }, + { + variants: { + position: { + top: { + top: "-$4", + }, + inside: { + top: 0, + }, + bottom: { + bottom: "-$4", + }, + }, + }, + } +); + +type LabelProps = { + component: Instance["component"]; + instanceRect: DOMRect; +}; + +export const Label = ({ component, instanceRect }: LabelProps) => { + const [labelRef, position] = useLabelPosition(instanceRect); + const primitive = primitives[component]; + const { Icon } = primitive; + return ( + + + {primitive.label} + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/outline/outline.tsx b/app/designer/features/workspace/canvas-tools/outline/outline.tsx new file mode 100644 index 0000000000..f8f5b52621 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/outline.tsx @@ -0,0 +1,10 @@ +import { styled } from "~/shared/design-system"; + +export const Outline = styled("div", { + position: "absolute", + pointerEvents: "none", + outline: "2px solid $blue9", + outlineOffset: -2, + top: 0, + left: 0, +}); diff --git a/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx b/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx new file mode 100644 index 0000000000..267c09b233 --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/outline/selected-instance-outline.tsx @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useSelectedInstanceRect } from "~/shared/nano-states"; +import { useSelectedInstanceData } from "~/designer/shared/nano-states"; +import { Outline } from "./outline"; +import { Label } from "./label"; + +const useStyle = (rect?: DOMRect) => { + return useMemo(() => { + if (rect === undefined) return; + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }, [rect]); +}; + +export const SelectedInstanceOutline = () => { + const [instanceRect] = useSelectedInstanceRect(); + const style = useStyle(instanceRect); + const [instanceData] = useSelectedInstanceData(); + + if ( + style === undefined || + instanceData === undefined || + instanceRect === undefined + ) { + return null; + } + + return ( + + + ); +}; diff --git a/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts b/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts new file mode 100644 index 0000000000..53ac4871bd --- /dev/null +++ b/app/designer/features/workspace/canvas-tools/use-subscribe-instance-rect.ts @@ -0,0 +1,18 @@ +import { useSubscribe } from "@webstudio-is/sdk"; +import { + useSelectedInstanceRect, + useHoveredInstanceRect, +} from "~/shared/nano-states"; + +export const useSubscribeInstanceRect = () => { + const [, setSelectedRect] = useSelectedInstanceRect(); + useSubscribe<"selectedInstanceRect", DOMRect>( + "selectedInstanceRect", + setSelectedRect + ); + const [, setHoveredRect] = useHoveredInstanceRect(); + useSubscribe<"hoveredInstanceRect", DOMRect>( + "hoveredInstanceRect", + setHoveredRect + ); +}; diff --git a/app/designer/features/workspace/index.ts b/app/designer/features/workspace/index.ts index 8050a4467d..202eb336a2 100644 --- a/app/designer/features/workspace/index.ts +++ b/app/designer/features/workspace/index.ts @@ -1,2 +1,3 @@ export * from "./workspace"; export * from "./use-read-canvas-rect"; +export * from "./canvas-iframe"; diff --git a/app/designer/features/workspace/use-read-canvas-rect.ts b/app/designer/features/workspace/use-read-canvas-rect.ts index 389c596593..b84b7583a4 100644 --- a/app/designer/features/workspace/use-read-canvas-rect.ts +++ b/app/designer/features/workspace/use-read-canvas-rect.ts @@ -1,29 +1,41 @@ -import { useCallback, useEffect, useRef } from "react"; -import { useCanvasRect } from "~/designer/shared/nano-states"; +import { useCallback, useEffect, useState } from "react"; +import { useZoom } from "~/designer/shared/nano-states"; +import { useCanvasRect, useCanvasWidth } from "~/designer/shared/nano-states"; +import { useWindowResize } from "~/shared/dom-hooks"; /** * Reads the canvas iframe dom rect and puts it into nano state * so that this is the only place we do it. */ export const useReadCanvasRect = () => { - const iframeRef = useRef(); + const [iframeElement, setIframeElement] = useState( + null + ); const [, setCanvasRect] = useCanvasRect(); + const [canvasWidth] = useCanvasWidth(); + const [zoom] = useZoom(); + const [recalcFlag, forceRecalc] = useState(false); + useWindowResize(() => { + forceRecalc(!recalcFlag); + }); - const handleRef = useCallback((iframe: HTMLIFrameElement | null) => { - if (iframe === null || iframeRef.current === iframe) return; - iframeRef.current = iframe; - }, []); - - const readRect = useCallback(() => { - if (iframeRef.current === undefined) return; - const rect = iframeRef.current.getBoundingClientRect(); - setCanvasRect(rect); - }, [setCanvasRect]); + const readRect = useCallback( + () => { + if (iframeElement === null) return; + requestAnimationFrame(() => { + const rect = iframeElement.getBoundingClientRect(); + setCanvasRect(rect); + }); + }, + // canvasWidth will change the canvas width, so we need to React it to it and update the rect, even though we don't necessary usenthe value + // eslint-disable-next-line react-hooks/exhaustive-deps + [iframeElement, setCanvasRect, canvasWidth, zoom, recalcFlag] + ); useEffect(readRect, [readRect]); return { - onRef: handleRef, + onRef: setIframeElement, onTransitionEnd: readRect, }; }; diff --git a/app/designer/features/workspace/workspace.tsx b/app/designer/features/workspace/workspace.tsx index 743952ff94..5966133edc 100644 --- a/app/designer/features/workspace/workspace.tsx +++ b/app/designer/features/workspace/workspace.tsx @@ -1,38 +1,58 @@ import { Box, Flex } from "~/shared/design-system"; -import { useZoom } from "~/designer/shared/nano-states"; +import { useCanvasWidth, useZoom } from "~/designer/shared/nano-states"; +import { CanvasTools } from "./canvas-tools"; +import { type Publish } from "@webstudio-is/sdk"; + +const workspaceStyle = { + flexGrow: 1, + background: "$gray8", + overflow: "auto", + scrollbarGutter: "stable", +}; + +const zoomStyle = { + transformStyle: "preserve-3d", + transition: "transform 200ms ease-out", + height: "100%", + width: "100%", +}; + +const canvasContainerStyle = { + position: "relative", + height: "100%", +}; type WorkspaceProps = { children: JSX.Element; onTransitionEnd: () => void; + publish: Publish; }; -export const Workspace = ({ children, onTransitionEnd }: WorkspaceProps) => { +export const Workspace = ({ + children, + onTransitionEnd, + publish, +}: WorkspaceProps) => { const [zoom] = useZoom(); + const [canvasWidth] = useCanvasWidth(); + + const handleWorkspaceClick = () => { + publish<"unselectInstance">({ type: "unselectInstance" }); + }; return ( - + - {children} + + {children} + + ); diff --git a/app/designer/shared/canvas-iframe/index.ts b/app/designer/shared/canvas-iframe/index.ts deleted file mode 100644 index c9935a225b..0000000000 --- a/app/designer/shared/canvas-iframe/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CanvasIframe } from "./canvas-iframe"; diff --git a/app/designer/shared/inspector/component-info.tsx b/app/designer/shared/inspector/component-info.tsx index c0655ade64..72b2c9af15 100644 --- a/app/designer/shared/inspector/component-info.tsx +++ b/app/designer/shared/inspector/component-info.tsx @@ -1,6 +1,6 @@ import { Flex, Text } from "~/shared/design-system"; -import type { SelectedInstanceData } from "~/shared/component"; -import { primitives } from "~/shared/component"; +import type { SelectedInstanceData } from "~/shared/canvas-components"; +import { primitives } from "~/shared/canvas-components"; export const ComponentInfo = ({ selectedInstanceData, diff --git a/app/designer/shared/nano-states/index.ts b/app/designer/shared/nano-states/index.ts index 5b47dc76b9..a5bde7cb82 100644 --- a/app/designer/shared/nano-states/index.ts +++ b/app/designer/shared/nano-states/index.ts @@ -1,6 +1,9 @@ import { createValueContainer, useValue } from "react-nano-state"; import { type Breakpoint } from "@webstudio-is/sdk"; -import { SelectedInstanceData } from "~/shared/component"; +import { + type SelectedInstanceData, + type HoveredInstanceData, +} from "~/shared/canvas-components"; import { type SyncStatus } from "~/shared/sync"; const selectedInstanceDataContainer = createValueContainer< @@ -9,6 +12,12 @@ const selectedInstanceDataContainer = createValueContainer< export const useSelectedInstanceData = () => useValue(selectedInstanceDataContainer); +const hoveredInstanceDataContainer = createValueContainer< + HoveredInstanceData | undefined +>(); +export const useHoveredInstanceData = () => + useValue(hoveredInstanceDataContainer); + const isShareDialogOpenContainer = createValueContainer(false); export const useIsShareDialogOpen = () => useValue(isShareDialogOpenContainer); diff --git a/app/designer/shared/tree/tree.tsx b/app/designer/shared/tree/tree.tsx index 2c4590da5c..cf464888fb 100644 --- a/app/designer/shared/tree/tree.tsx +++ b/app/designer/shared/tree/tree.tsx @@ -9,7 +9,7 @@ import { styled, } from "~/shared/design-system"; import { TriangleRightIcon, TriangleDownIcon } from "~/shared/icons"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; import noop from "lodash.noop"; const openKeyframes = keyframes({ diff --git a/app/shared/component/draggable.ts b/app/shared/canvas-components/draggable.ts similarity index 100% rename from app/shared/component/draggable.ts rename to app/shared/canvas-components/draggable.ts diff --git a/app/shared/component/index.ts b/app/shared/canvas-components/index.ts similarity index 100% rename from app/shared/component/index.ts rename to app/shared/canvas-components/index.ts diff --git a/app/shared/component/instance-data.ts b/app/shared/canvas-components/instance-data.ts similarity index 84% rename from app/shared/component/instance-data.ts rename to app/shared/canvas-components/instance-data.ts index 7e728e8f5f..74271b920e 100644 --- a/app/shared/component/instance-data.ts +++ b/app/shared/canvas-components/instance-data.ts @@ -16,6 +16,11 @@ export type SelectedInstanceData = { props: InstanceProps; }; +export type HoveredInstanceData = { + id: Instance["id"]; + component: Instance["component"]; +} + type StyleUpdate = { property: StyleProperty; value: StyleValue; diff --git a/app/shared/component/primitives/bold.tsx b/app/shared/canvas-components/primitives/bold.tsx similarity index 100% rename from app/shared/component/primitives/bold.tsx rename to app/shared/canvas-components/primitives/bold.tsx diff --git a/app/shared/component/primitives/box.tsx b/app/shared/canvas-components/primitives/box.tsx similarity index 88% rename from app/shared/component/primitives/box.tsx rename to app/shared/canvas-components/primitives/box.tsx index 84a531834f..70246d083c 100644 --- a/app/shared/component/primitives/box.tsx +++ b/app/shared/canvas-components/primitives/box.tsx @@ -3,11 +3,6 @@ export { SquareIcon as Icon } from "~/shared/icons"; // Webstudio specific default styles for the node type. export const defaultStyle: Style = { - minHeight: { - type: "unit", - unit: "px", - value: 100, - }, boxSizing: { type: "keyword", value: "border-box", diff --git a/app/shared/component/primitives/button.tsx b/app/shared/canvas-components/primitives/button.tsx similarity index 100% rename from app/shared/component/primitives/button.tsx rename to app/shared/canvas-components/primitives/button.tsx diff --git a/app/shared/component/primitives/form.tsx b/app/shared/canvas-components/primitives/form.tsx similarity index 100% rename from app/shared/component/primitives/form.tsx rename to app/shared/canvas-components/primitives/form.tsx diff --git a/app/shared/component/primitives/heading.tsx b/app/shared/canvas-components/primitives/heading.tsx similarity index 82% rename from app/shared/component/primitives/heading.tsx rename to app/shared/canvas-components/primitives/heading.tsx index c3adb29350..4fd1c7fc01 100644 --- a/app/shared/component/primitives/heading.tsx +++ b/app/shared/canvas-components/primitives/heading.tsx @@ -2,13 +2,7 @@ import { type Style, components } from "@webstudio-is/sdk"; export { HeadingIcon as Icon } from "~/shared/icons"; // Webstudio specific default styles for the node type. -export const defaultStyle: Style = { - minHeight: { - type: "unit", - unit: "em", - value: 1, - }, -}; +export const defaultStyle: Style = {}; export const children = ["Heading you can edit"]; diff --git a/app/shared/component/primitives/index.ts b/app/shared/canvas-components/primitives/index.ts similarity index 100% rename from app/shared/component/primitives/index.ts rename to app/shared/canvas-components/primitives/index.ts diff --git a/app/shared/component/primitives/input.tsx b/app/shared/canvas-components/primitives/input.tsx similarity index 100% rename from app/shared/component/primitives/input.tsx rename to app/shared/canvas-components/primitives/input.tsx diff --git a/app/shared/component/primitives/italic.tsx b/app/shared/canvas-components/primitives/italic.tsx similarity index 100% rename from app/shared/component/primitives/italic.tsx rename to app/shared/canvas-components/primitives/italic.tsx diff --git a/app/shared/component/primitives/link.tsx b/app/shared/canvas-components/primitives/link.tsx similarity index 100% rename from app/shared/component/primitives/link.tsx rename to app/shared/canvas-components/primitives/link.tsx diff --git a/app/shared/component/primitives/paragraph.tsx b/app/shared/canvas-components/primitives/paragraph.tsx similarity index 82% rename from app/shared/component/primitives/paragraph.tsx rename to app/shared/canvas-components/primitives/paragraph.tsx index c48b2b0539..c244294c8e 100644 --- a/app/shared/component/primitives/paragraph.tsx +++ b/app/shared/canvas-components/primitives/paragraph.tsx @@ -2,13 +2,7 @@ import { type Style, components } from "@webstudio-is/sdk"; export { TextAlignLeftIcon as Icon } from "~/shared/icons"; // Webstudio specific default styles for the node type. -export const defaultStyle: Style = { - minHeight: { - type: "unit", - unit: "em", - value: 1, - }, -}; +export const defaultStyle: Style = {}; export const children = ["Pragraph you can edit"]; diff --git a/app/shared/component/primitives/text-block.tsx b/app/shared/canvas-components/primitives/text-block.tsx similarity index 100% rename from app/shared/component/primitives/text-block.tsx rename to app/shared/canvas-components/primitives/text-block.tsx diff --git a/app/shared/db/tree.server.ts b/app/shared/db/tree.server.ts index 97998fdfdb..26784ddcef 100644 --- a/app/shared/db/tree.server.ts +++ b/app/shared/db/tree.server.ts @@ -34,8 +34,8 @@ export const createRootInstance = (breakpoints: Array) => { }, lineHeight: { type: "unit", - unit: "px", - value: 20, + unit: "number", + value: 1.5, }, color: { type: "keyword", diff --git a/app/shared/dom-hooks/index.ts b/app/shared/dom-hooks/index.ts new file mode 100644 index 0000000000..5a084db383 --- /dev/null +++ b/app/shared/dom-hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-window-resize"; +export * from "./use-measure"; diff --git a/app/shared/dom-hooks/use-measure.ts b/app/shared/dom-hooks/use-measure.ts new file mode 100644 index 0000000000..84a9196c0b --- /dev/null +++ b/app/shared/dom-hooks/use-measure.ts @@ -0,0 +1,42 @@ +// This hook is based on https://github.com/streamich/react-use/blob/master/src/useMeasure.ts +// The problem is that contentRect has wrong x/y values for absolutely positioned element. +// We have to use getBoundingClientRect instead. +// @todo optimize for the case when many consumers need to measure the same element + +import { useEffect, useMemo, useState } from "react"; + +export type UseMeasureRef = ( + element: MeasuredElement | null +) => void; +export type UseMeasureResult< + MeasuredElement extends HTMLElement = HTMLElement +> = [UseMeasureRef, DOMRect | undefined]; + +export const useMeasure = < + MeasuredElement extends HTMLElement = HTMLElement +>(): UseMeasureResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(); + + const observer = useMemo(() => { + if (element === null || typeof window === "undefined") return; + return new window.ResizeObserver(() => { + if (element === null) return; + requestAnimationFrame(() => { + setRect(element.getBoundingClientRect()); + }); + }); + }, [element]); + + useEffect(() => { + if (observer) { + if (element === null) observer.disconnect(); + else observer.observe(element); + } + return () => { + observer?.disconnect(); + }; + }, [element, observer]); + + return [ref, rect]; +}; diff --git a/app/shared/dom-hooks/use-window-resize.ts b/app/shared/dom-hooks/use-window-resize.ts new file mode 100644 index 0000000000..8c07e4a445 --- /dev/null +++ b/app/shared/dom-hooks/use-window-resize.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import mitt from "mitt"; + +const emitter = mitt(); + +if (typeof window === "object") { + window.addEventListener( + "resize", + () => { + emitter.emit("resize"); + }, + false + ); +} + +/** + * Subscribe to DOM resize event only once and then notify all listeners over js only. + * This allows us to subscribe to resize by many listeners without perf issues, since its going to be the same resize event. + * TODO: We can add throttling and RAF if needed. + */ +export const useWindowResize = (onResize: () => void) => { + useEffect(() => { + emitter.on("resize", onResize); + return () => { + emitter.off("resize", onResize); + }; + }, [onResize]); +}; diff --git a/app/shared/dom-utils/get-drag-over-info.ts b/app/shared/dom-utils/get-drag-over-info.ts index 967b778ce3..ad5e6e7e14 100644 --- a/app/shared/dom-utils/get-drag-over-info.ts +++ b/app/shared/dom-utils/get-drag-over-info.ts @@ -1,5 +1,10 @@ import type { Coordinate, DragOverInfo } from "./types"; +const elementFromPoint = (coordinate: Coordinate): HTMLElement | undefined => { + const element = document.elementFromPoint(coordinate.x, coordinate.y); + if (element instanceof HTMLElement) return element; +}; + /** * Get element we are hovering over from coordinate. * When nearing to an edge of an element - return the parent. @@ -8,7 +13,7 @@ export const getDragOverInfo = ( offset: Coordinate, getBoundingClientRect: (element: Element) => DOMRect ): DragOverInfo => { - let element = document.elementFromPoint(offset.x, offset.y) || undefined; + let element = elementFromPoint(offset); let edge: DragOverInfo["edge"] = "none"; if (element === undefined) return { element, edge }; diff --git a/app/shared/dom-utils/types.ts b/app/shared/dom-utils/types.ts index 638a26938d..b9b2fe1adc 100644 --- a/app/shared/dom-utils/types.ts +++ b/app/shared/dom-utils/types.ts @@ -1,6 +1,6 @@ export type Coordinate = { x: number; y: number }; export type DragOverInfo = { - element: Element | undefined; + element: HTMLElement | undefined; edge: "top" | "bottom" | "none"; }; export type ClosestChildInfo = { diff --git a/app/shared/nano-states/index.ts b/app/shared/nano-states/index.ts index 8a47eddd48..7e3f34408f 100644 --- a/app/shared/nano-states/index.ts +++ b/app/shared/nano-states/index.ts @@ -11,3 +11,15 @@ export const useBreakpoints = () => useValue(breakpointsContainer); const isPreviewModeContainer = createValueContainer(false); export const useIsPreviewMode = () => useValue(isPreviewModeContainer); + +const selectedInstanceRectContainer = createValueContainer< + DOMRect | undefined +>(); +export const useSelectedInstanceRect = () => + useValue(selectedInstanceRectContainer); + +const hoveredInstanceRectContainer = createValueContainer< + DOMRect | undefined +>(); +export const useHoveredInstanceRect = () => + useValue(hoveredInstanceRectContainer); diff --git a/app/shared/tree-utils/create-instance.ts b/app/shared/tree-utils/create-instance.ts index 5d32348028..a54ef85a8c 100644 --- a/app/shared/tree-utils/create-instance.ts +++ b/app/shared/tree-utils/create-instance.ts @@ -1,6 +1,6 @@ import ObjectId from "bson-objectid"; import { type CssRule, type Instance } from "@webstudio-is/sdk"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; export const createInstance = ({ component, diff --git a/app/shared/tree-utils/find-instance.ts b/app/shared/tree-utils/find-instance.ts index ac665ee792..b4e30d54a7 100644 --- a/app/shared/tree-utils/find-instance.ts +++ b/app/shared/tree-utils/find-instance.ts @@ -1,5 +1,6 @@ import { type Instance } from "@webstudio-is/sdk"; +// @todo should be optimized because it will be a frequent operation on a potentially large tree. export const findInstanceById = ( instance: Instance, id: Instance["id"] diff --git a/app/shared/tree-utils/populate.ts b/app/shared/tree-utils/populate.ts index 3e27dd4526..60ffb271d7 100644 --- a/app/shared/tree-utils/populate.ts +++ b/app/shared/tree-utils/populate.ts @@ -1,5 +1,5 @@ import { type Instance } from "@webstudio-is/sdk"; -import { primitives } from "~/shared/component"; +import { primitives } from "~/shared/canvas-components"; /** * Populate instance with: diff --git a/app/shared/tree-utils/set-instance-style.ts b/app/shared/tree-utils/set-instance-style.ts index b0e2da0b0f..ad0b3c8f13 100644 --- a/app/shared/tree-utils/set-instance-style.ts +++ b/app/shared/tree-utils/set-instance-style.ts @@ -1,5 +1,5 @@ import { Breakpoint, type Instance } from "@webstudio-is/sdk"; -import { type StyleUpdates } from "~/shared/component"; +import { type StyleUpdates } from "~/shared/canvas-components"; import { findInstanceById } from "./find-instance"; export const setInstanceStyleMutable = ( diff --git a/package.json b/package.json index 1ca42def4c..099c9837e7 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "lodash.merge": "^4.6.2", "lodash.noop": "^3.0.1", "lodash.snakecase": "^4.1.1", + "mitt": "^3.0.0", "nanoid": "^3.2.0", "react": "^17.0.2", "react-color": "^2.19.3", @@ -105,6 +106,7 @@ "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.3.0", "jest": "^27.5.1", + "prettier": "^2.6.2", "prisma": "^3.14.0", "relative-deps": "^1.0.7", "typescript": "^4.1.2" diff --git a/yarn.lock b/yarn.lock index ef6c2269e0..78f990bb9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11347,6 +11347,11 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18" integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w== +prettier@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032" + integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew== + pretty-error@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"