diff --git a/apps/studio/src/lib/editor/engine/element/index.ts b/apps/studio/src/lib/editor/engine/element/index.ts index 73ab3580b..0882275cc 100644 --- a/apps/studio/src/lib/editor/engine/element/index.ts +++ b/apps/studio/src/lib/editor/engine/element/index.ts @@ -39,10 +39,7 @@ export class ElementManager { ...domEl, webviewId: webview.id, }; - const adjustedRect = this.editorEngine.overlay.adaptRectFromSourceElement( - webviewEl.rect, - webview, - ); + const adjustedRect = this.editorEngine.overlay.adaptRect(webviewEl.rect, webview); const isComponent = !!domEl.instanceId; this.editorEngine.overlay.updateHoverRect(adjustedRect, isComponent); this.setHoveredElement(webviewEl); @@ -63,14 +60,8 @@ export class ElementManager { return; } - const selectedRect = this.editorEngine.overlay.adaptRectFromSourceElement( - selectedEl.rect, - webview, - ); - const hoverRect = this.editorEngine.overlay.adaptRectFromSourceElement( - hoverEl.rect, - webview, - ); + const selectedRect = this.editorEngine.overlay.adaptRect(selectedEl.rect, webview); + const hoverRect = this.editorEngine.overlay.adaptRect(hoverEl.rect, webview); this.editorEngine.overlay.updateMeasurement(selectedRect, hoverRect); } @@ -92,10 +83,7 @@ export class ElementManager { this.clearSelectedElements(); for (const domEl of domEls) { - const adjustedRect = this.editorEngine.overlay.adaptRectFromSourceElement( - domEl.rect, - webview, - ); + const adjustedRect = this.editorEngine.overlay.adaptRect(domEl.rect, webview); const isComponent = !!domEl.instanceId; this.editorEngine.overlay.addClickRect(adjustedRect, domEl.styles, isComponent); this.addSelectedElement(domEl); diff --git a/apps/studio/src/lib/editor/engine/overlay/components/BaseRect.tsx b/apps/studio/src/lib/editor/engine/overlay/components/BaseRect.tsx new file mode 100644 index 000000000..fd93d84c6 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/components/BaseRect.tsx @@ -0,0 +1,61 @@ +import { EditorAttributes } from '@onlook/models/constants'; +import { colors } from '@onlook/ui/tokens'; +import React from 'react'; + +export interface RectDimensions { + width: number; + height: number; + top: number; + left: number; +} + +export interface RectProps extends RectDimensions { + isComponent?: boolean; + className?: string; + children?: React.ReactNode; + strokeWidth?: number; +} + +export const BaseRect: React.FC = ({ + width, + height, + top, + left, + isComponent, + className, + children, + strokeWidth = 2, +}) => { + return ( +
+ + + {children} + +
+ ); +}; diff --git a/apps/studio/src/lib/editor/engine/overlay/components/ClickRect.tsx b/apps/studio/src/lib/editor/engine/overlay/components/ClickRect.tsx new file mode 100644 index 000000000..c877fb53f --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/components/ClickRect.tsx @@ -0,0 +1,300 @@ +import { colors } from '@onlook/ui/tokens'; +import { nanoid } from 'nanoid'; +import React from 'react'; +import type { RectDimensions } from './BaseRect'; +import { BaseRect } from './BaseRect'; + +interface ClickRectProps extends RectDimensions { + isComponent?: boolean; + margin?: string; + padding?: string; +} + +const createStripePattern = (color: string) => { + const patternId = `stripe-${nanoid()}`; + return ( + + + + + + + ); +}; + +const parseCssBoxValues = (value: string) => { + const values = value.split(' ').map((v) => parseInt(v)); + switch (values.length) { + case 1: + return { top: values[0], right: values[0], bottom: values[0], left: values[0] }; + case 2: + return { top: values[0], right: values[1], bottom: values[0], left: values[1] }; + case 4: + return { top: values[0], right: values[1], bottom: values[2], left: values[3] }; + default: + return { top: 0, right: 0, bottom: 0, left: 0 }; + } +}; + +export const ClickRect: React.FC = ({ + width, + height, + top, + left, + isComponent, + margin, + padding, +}) => { + const renderMargin = () => { + if (!margin) { + return null; + } + const { + top: mTop, + right: mRight, + bottom: mBottom, + left: mLeft, + } = parseCssBoxValues(margin); + const marginFill = colors.blue[500]; + const marginFillOpacity = 0.1; + const marginText = colors.blue[700]; + + return ( + <> + {/* Margin areas with semi-transparent fills */} + + + + + + {/* Margin labels */} + {mTop > 0 && ( + + {mTop} + + )} + + {mBottom > 0 && ( + + {mBottom} + + )} + + {mLeft > 0 && ( + + {mLeft} + + )} + + {mRight > 0 && ( + + {mRight} + + )} + + ); + }; + + const renderPadding = () => { + if (!padding) { + return null; + } + const { + top: pTop, + right: pRight, + bottom: pBottom, + left: pLeft, + } = parseCssBoxValues(padding); + + const paddingFill = colors.green[500]; + const paddingText = colors.green[700]; + const paddingFillOpacity = 0.1; + return ( + <> + {/* Padding areas with semi-transparent fills */} + + + + + + {pTop > 0 && ( + + {pTop} + + )} + + {pBottom > 0 && ( + + {pBottom} + + )} + + {pLeft > 0 && ( + + {pLeft} + + )} + + {pRight > 0 && ( + + {pRight} + + )} + + ); + }; + + const renderDimensions = () => { + const rectColor = isComponent ? colors.purple[500] : colors.red[500]; + return ( + + + + {`${Math.round(width)} × ${Math.round(height)}`} + + + ); + }; + + return ( + + {renderMargin()} + {renderPadding()} + {renderDimensions()} + + ); +}; diff --git a/apps/studio/src/lib/editor/engine/overlay/components/HoverRect.tsx b/apps/studio/src/lib/editor/engine/overlay/components/HoverRect.tsx new file mode 100644 index 000000000..e004ba271 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/components/HoverRect.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import type { RectDimensions } from './BaseRect'; +import { BaseRect } from './BaseRect'; + +interface HoverRectProps { + rect: RectDimensions | null; + isComponent?: boolean; +} + +export const HoverRect: React.FC = ({ rect, isComponent }) => { + if (!rect) { + return null; + } + return ; +}; diff --git a/apps/studio/src/lib/editor/engine/overlay/components/InsertRect.tsx b/apps/studio/src/lib/editor/engine/overlay/components/InsertRect.tsx new file mode 100644 index 000000000..8e516c076 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/components/InsertRect.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { BaseRect } from './BaseRect'; +import type { RectDimensions } from './BaseRect'; + +interface InsertRectProps { + rect: RectDimensions | null; +} + +export const InsertRect: React.FC = ({ rect }) => { + if (!rect) { + return null; + } + return ; +}; diff --git a/apps/studio/src/lib/editor/engine/overlay/components/index.ts b/apps/studio/src/lib/editor/engine/overlay/components/index.ts new file mode 100644 index 000000000..94dda0662 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/components/index.ts @@ -0,0 +1,4 @@ +export * from './BaseRect'; +export * from './HoverRect'; +export * from './InsertRect'; +export * from './ClickRect'; diff --git a/apps/studio/src/lib/editor/engine/overlay/index.ts b/apps/studio/src/lib/editor/engine/overlay/index.ts index 9548e4a5a..d76f3cd6b 100644 --- a/apps/studio/src/lib/editor/engine/overlay/index.ts +++ b/apps/studio/src/lib/editor/engine/overlay/index.ts @@ -1,44 +1,49 @@ import { MeasurementImpl } from './measurement'; -import { ClickRect, HoverRect, InsertRect } from './rect'; import { EditTextInput } from './textEdit'; +import type { OverlayContainer } from './types'; +import type { RectDimensions } from './components'; +import type { WebviewTag } from 'electron/renderer'; +import { adaptRectToOverlay, getRelativeOffset } from './utils'; export class OverlayManager { - overlayContainer: HTMLElement | undefined; - hoverRect: HoverRect; - insertRect: InsertRect; - clickedRects: ClickRect[]; + overlayContainer: OverlayContainer | undefined; + overlayElement: HTMLElement | undefined; editTextInput: EditTextInput; measureEle: MeasurementImpl; scrollPosition: { x: number; y: number } = { x: 0, y: 0 }; constructor() { - this.hoverRect = new HoverRect(); - this.insertRect = new InsertRect(); this.editTextInput = new EditTextInput(); this.measureEle = new MeasurementImpl(); - this.clickedRects = []; this.bindMethods(); } - setOverlayContainer = (container: HTMLElement) => { - this.overlayContainer = container; - this.appendRectToPopover(this.hoverRect.element); - this.appendRectToPopover(this.insertRect.element); - this.appendRectToPopover(this.editTextInput.element); - this.appendRectToPopover(this.measureEle.element); + getDOMContainer = () => { + if (!this.overlayElement) { + throw new Error('Overlay element not initialized'); + } + return this.overlayElement; + }; + + setOverlayContainer = (container: OverlayContainer | HTMLElement) => { + if (container instanceof HTMLElement) { + this.overlayElement = container; + container.appendChild(this.editTextInput.element); + container.appendChild(this.measureEle.element); + } else { + this.overlayContainer = container; + } }; bindMethods = () => { this.setOverlayContainer = this.setOverlayContainer.bind(this); - - // Update - this.hideHoverRect = this.hideHoverRect.bind(this); - this.showHoverRect = this.showHoverRect.bind(this); + this.getDOMContainer = this.getDOMContainer.bind(this); + this.adaptRect = this.adaptRect.bind(this); this.updateHoverRect = this.updateHoverRect.bind(this); this.updateInsertRect = this.updateInsertRect.bind(this); + this.updateMeasurement = this.updateMeasurement.bind(this); this.updateEditTextInput = this.updateEditTextInput.bind(this); - - // Remove + this.updateTextInputSize = this.updateTextInputSize.bind(this); this.removeHoverRect = this.removeHoverRect.bind(this); this.removeClickedRects = this.removeClickedRects.bind(this); this.removeEditTextInput = this.removeEditTextInput.bind(this); @@ -46,80 +51,82 @@ export class OverlayManager { this.clear = this.clear.bind(this); }; - getRelativeOffset(element: HTMLElement, ancestor: HTMLElement) { - let top = 0, - left = 0; - while (element && element !== ancestor) { - const transform = window.getComputedStyle(element).transform; - const matrix = new DOMMatrix(transform); - - top += matrix.m42; - left += matrix.m41; - - top += element.offsetTop || 0; - left += element.offsetLeft || 0; - element = element.offsetParent as HTMLElement; - } - return { top, left }; - } - - adaptRectFromSourceElement(rect: DOMRect, webview: Electron.WebviewTag) { - const commonAncestor = this.overlayContainer?.parentElement as HTMLElement; - const sourceOffset = this.getRelativeOffset(webview, commonAncestor); - - const overlayOffset = this.overlayContainer - ? this.getRelativeOffset(this.overlayContainer, commonAncestor) - : { top: 0, left: 0 }; - - const adjustedRect = { - ...rect, - top: rect.top + sourceOffset.top - overlayOffset.top, - left: rect.left + sourceOffset.left - overlayOffset.left, - }; - return adjustedRect; - } - - appendRectToPopover = (rect: HTMLElement) => { - if (this.overlayContainer) { - this.overlayContainer.appendChild(rect); + adaptRect = (rect: DOMRect, webview: WebviewTag): RectDimensions => { + if (!this.overlayElement) { + throw new Error('Overlay element not initialized'); } + return adaptRectToOverlay(rect, webview, this.overlayElement); }; addClickRect = ( - rect: DOMRect, + rect: RectDimensions | DOMRect, style: Record | CSSStyleDeclaration, isComponent?: boolean, ) => { - const clickRect = new ClickRect(); - this.appendRectToPopover(clickRect.element); - this.clickedRects.push(clickRect); - clickRect.render( + if (!this.overlayContainer) { + return; + } + + this.overlayContainer.addClickRect( { width: rect.width, height: rect.height, top: rect.top, left: rect.left, - padding: style.padding, - margin: style.margin, + }, + { + margin: style.margin?.toString(), + padding: style.padding?.toString(), }, isComponent, ); }; - updateHoverRect = (rect: DOMRect, isComponent?: boolean) => { - this.hoverRect.render(rect, isComponent); + updateHoverRect = (rect: RectDimensions | DOMRect | null, isComponent?: boolean) => { + if (!this.overlayContainer) { + return; + } + + if (!rect) { + this.overlayContainer.updateHoverRect(null); + return; + } + + this.overlayContainer.updateHoverRect( + { + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + }, + isComponent, + ); }; - updateInsertRect = (rect: DOMRect) => { - this.insertRect.render(rect); + updateInsertRect = (rect: RectDimensions | DOMRect | null) => { + if (!this.overlayContainer) { + return; + } + + if (!rect) { + this.overlayContainer.updateInsertRect(null); + return; + } + + this.overlayContainer.updateInsertRect({ + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + }); }; - updateMeasurement = (fromRect: DOMRect, toRect: DOMRect) => { + updateMeasurement = (fromRect: RectDimensions | DOMRect, toRect: RectDimensions | DOMRect) => { this.measureEle.render(fromRect, toRect); }; updateEditTextInput = ( - rect: DOMRect, + rect: RectDimensions | DOMRect, content: string, styles: Record, onChange: (content: string) => void, @@ -130,31 +137,26 @@ export class OverlayManager { this.editTextInput.enable(); }; - updateTextInputSize = (rect: DOMRect) => { + updateTextInputSize = (rect: RectDimensions | DOMRect) => { this.editTextInput.updateSize(rect); }; - hideHoverRect = () => { - this.hoverRect.element.style.display = 'none'; - }; - - showHoverRect = () => { - this.hoverRect.element.style.display = 'block'; - }; - removeHoverRect = () => { - this.hoverRect.render({ width: 0, height: 0, top: 0, left: 0 }); + if (this.overlayContainer) { + this.overlayContainer.updateHoverRect(null); + } }; removeInsertRect = () => { - this.insertRect.render({ width: 0, height: 0, top: 0, left: 0 }); + if (this.overlayContainer) { + this.overlayContainer.updateInsertRect(null); + } }; removeClickedRects = () => { - this.clickedRects.forEach((clickRect) => { - clickRect.element.remove(); - }); - this.clickedRects = []; + if (this.overlayContainer) { + this.overlayContainer.removeClickRect(); + } }; removeEditTextInput = () => { @@ -168,8 +170,9 @@ export class OverlayManager { }; clear = () => { - this.removeHoverRect(); - this.removeClickedRects(); + if (this.overlayContainer) { + this.overlayContainer.clear(); + } this.removeEditTextInput(); this.removeMeasurement(); }; diff --git a/apps/studio/src/lib/editor/engine/overlay/types.ts b/apps/studio/src/lib/editor/engine/overlay/types.ts new file mode 100644 index 000000000..a43833909 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/types.ts @@ -0,0 +1,13 @@ +import type { RectDimensions } from './components'; + +export interface OverlayContainer { + updateHoverRect: (rect: RectDimensions | null, isComponent?: boolean) => void; + updateInsertRect: (rect: RectDimensions | null) => void; + addClickRect: ( + rect: RectDimensions, + styles?: { margin?: string; padding?: string }, + isComponent?: boolean, + ) => void; + removeClickRect: () => void; + clear: () => void; +} diff --git a/apps/studio/src/lib/editor/engine/overlay/utils.ts b/apps/studio/src/lib/editor/engine/overlay/utils.ts new file mode 100644 index 000000000..24843ba08 --- /dev/null +++ b/apps/studio/src/lib/editor/engine/overlay/utils.ts @@ -0,0 +1,64 @@ +import type { WebviewTag } from 'electron/renderer'; +import type { RectDimensions } from './components'; + +/** + * Calculates the cumulative offset between an element and its ancestor, + * taking into account CSS transforms and offset positions. + */ +export function getRelativeOffset(element: HTMLElement, ancestor: HTMLElement) { + let top = 0, + left = 0; + let currentElement = element; + + while (currentElement && currentElement !== ancestor) { + // Handle CSS transforms + const transform = window.getComputedStyle(currentElement).transform; + if (transform && transform !== 'none') { + const matrix = new DOMMatrix(transform); + top += matrix.m42; // translateY + left += matrix.m41; // translateX + } + + // Add offset positions + top += currentElement.offsetTop || 0; + left += currentElement.offsetLeft || 0; + + // Move up to parent + const offsetParent = currentElement.offsetParent as HTMLElement; + if (!offsetParent || offsetParent === ancestor) { + break; + } + currentElement = offsetParent; + } + + return { top, left }; +} + +/** + * Adapts a rectangle from a webview element to the overlay coordinate space. + * This ensures that overlay rectangles perfectly match the source elements, + * similar to design tools like Figma/Framer. + */ +export function adaptRectToOverlay( + rect: DOMRect, + webview: WebviewTag, + overlayContainer: HTMLElement, +): RectDimensions { + // Find common ancestor for coordinate space transformation + const commonAncestor = overlayContainer.parentElement as HTMLElement; + if (!commonAncestor) { + throw new Error('Overlay container must have a parent element'); + } + + // Calculate offsets relative to common ancestor + const sourceOffset = getRelativeOffset(webview, commonAncestor); + const overlayOffset = getRelativeOffset(overlayContainer, commonAncestor); + + // Transform coordinates to overlay space + return { + width: rect.width, + height: rect.height, + top: rect.top + sourceOffset.top - overlayOffset.top, + left: rect.left + sourceOffset.left - overlayOffset.left, + }; +} diff --git a/apps/studio/src/lib/editor/engine/text/index.ts b/apps/studio/src/lib/editor/engine/text/index.ts index 5b38aa1cc..0fa97fadd 100644 --- a/apps/studio/src/lib/editor/engine/text/index.ts +++ b/apps/studio/src/lib/editor/engine/text/index.ts @@ -28,10 +28,7 @@ export class TextEditingManager { this.shouldNotStartEditing = true; this.editorEngine.history.startTransaction(); - const adjustedRect = this.editorEngine.overlay.adaptRectFromSourceElement( - this.targetDomEl.rect, - webview, - ); + const adjustedRect = this.editorEngine.overlay.adaptRect(this.targetDomEl.rect, webview); const isComponent = this.targetDomEl.instanceId !== null; this.editorEngine.overlay.clear(); @@ -96,10 +93,7 @@ export class TextEditingManager { } handleEditedText(domEl: DomElement, newContent: string, webview: WebviewTag) { - const adjustedRect = this.editorEngine.overlay.adaptRectFromSourceElement( - domEl.rect, - webview, - ); + const adjustedRect = this.editorEngine.overlay.adaptRect(domEl.rect, webview); this.editorEngine.overlay.updateTextInputSize(adjustedRect); this.editorEngine.history.push({ diff --git a/apps/studio/src/routes/editor/WebviewArea/GestureScreen.tsx b/apps/studio/src/routes/editor/WebviewArea/GestureScreen.tsx index ffb0d80d9..033818305 100644 --- a/apps/studio/src/routes/editor/WebviewArea/GestureScreen.tsx +++ b/apps/studio/src/routes/editor/WebviewArea/GestureScreen.tsx @@ -42,10 +42,8 @@ const GestureScreen = observer(({ webviewRef, setHovered, isResizing }: GestureS function getRelativeMousePositionToOverlay( e: React.MouseEvent, ): ElementPosition { - if (!editorEngine.overlay.overlayContainer) { - throw new Error('overlay container not found'); - } - const rect = editorEngine.overlay.overlayContainer?.getBoundingClientRect(); + const overlayElement = editorEngine.overlay.getDOMContainer(); + const rect = overlayElement.getBoundingClientRect(); const { x, y } = getRelativeMousePosition(e, rect); return { x, y }; } diff --git a/apps/studio/src/routes/editor/WebviewArea/Overlay.tsx b/apps/studio/src/routes/editor/WebviewArea/Overlay.tsx index 86d9ad107..7b84fbc41 100644 --- a/apps/studio/src/routes/editor/WebviewArea/Overlay.tsx +++ b/apps/studio/src/routes/editor/WebviewArea/Overlay.tsx @@ -1,21 +1,69 @@ import { useEditorEngine } from '@/components/Context'; import { EditorMode } from '@/lib/models'; import { observer } from 'mobx-react-lite'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { HoverRect, InsertRect, ClickRect } from '@/lib/editor/engine/overlay/components'; +import type { RectDimensions } from '@/lib/editor/engine/overlay/components'; + +interface ClickRectState extends RectDimensions { + isComponent?: boolean; + margin?: string; + padding?: string; + id: string; +} const Overlay = observer(({ children }: { children: React.ReactNode }) => { const overlayContainerRef = useRef(null); const editorEngine = useEditorEngine(); + const [hoverRect, setHoverRect] = useState<{ + rect: RectDimensions; + isComponent?: boolean; + } | null>(null); + const [insertRect, setInsertRect] = useState(null); + const [clickRects, setClickRects] = useState([]); useEffect(() => { if (overlayContainerRef.current) { const overlayContainer = overlayContainerRef.current; + // Set both DOM element and container interface editorEngine.overlay.setOverlayContainer(overlayContainer); + editorEngine.overlay.setOverlayContainer({ + updateHoverRect: (rect: RectDimensions | null, isComponent?: boolean) => { + setHoverRect(rect ? { rect, isComponent } : null); + }, + updateInsertRect: (rect: RectDimensions | null) => { + setInsertRect(rect); + }, + addClickRect: ( + rect: RectDimensions, + styles?: { margin?: string; padding?: string }, + isComponent?: boolean, + ) => { + setClickRects((prev) => [ + ...prev, + { + ...rect, + margin: styles?.margin, + padding: styles?.padding, + isComponent, + id: Date.now().toString(), + }, + ]); + }, + removeClickRect: () => { + setClickRects((prev) => prev.slice(0, -1)); + }, + clear: () => { + setHoverRect(null); + setInsertRect(null); + setClickRects([]); + }, + }); return () => { editorEngine.overlay.clear(); }; } - }, [overlayContainerRef]); + }, [editorEngine.overlay]); return ( <> @@ -32,7 +80,24 @@ const Overlay = observer(({ children }: { children: React.ReactNode }) => { zIndex: 99, visibility: editorEngine.mode === EditorMode.INTERACT ? 'hidden' : 'visible', }} - /> + > + {hoverRect && ( + + )} + {insertRect && } + {clickRects.map((rect) => ( + + ))} + ); });