diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 29a1e2d1..31b73207 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,47 +1,47 @@ -name: Build - -on: - push: - branches: [main] - paths-ignore: - - "**.md" - - "**.spec.js" - - ".idea" - - ".vscode" - - ".dockerignore" - - "Dockerfile" - - ".gitignore" - - ".github/**" - - "!.github/workflows/build.yml" - -jobs: - build: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - - name: Install Dependencies - run: npm install - - - name: Build Release Files - run: npm run build - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - name: release_on_${{ matrix. os }} - path: release/ - retention-days: 5 \ No newline at end of file +# name: Build + +# on: +# push: +# branches: [main] +# paths-ignore: +# - "**.md" +# - "**.spec.js" +# - ".idea" +# - ".vscode" +# - ".dockerignore" +# - "Dockerfile" +# - ".gitignore" +# - ".github/**" +# - "!.github/workflows/build.yml" + +# jobs: +# build: +# runs-on: ${{ matrix.os }} + +# strategy: +# matrix: +# os: [macos-latest, ubuntu-latest, windows-latest] + +# steps: +# - name: Checkout Code +# uses: actions/checkout@v3 + +# - name: Setup Node.js +# uses: actions/setup-node@v3 +# with: +# node-version: 18 + +# - name: Install Dependencies +# run: npm install + +# - name: Build Release Files +# run: npm run build +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +# - name: Upload Artifact +# uses: actions/upload-artifact@v3 +# with: +# name: release_on_${{ matrix. os }} +# path: release/ +# retention-days: 5 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00702449..5b45ae26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,81 +1,81 @@ -name: CI +# name: CI -on: - pull_request_target: - branches: - - main +# on: +# pull_request_target: +# branches: +# - main -permissions: - pull-requests: write +# permissions: +# pull-requests: write -jobs: - job1: - name: Check Not Allowed File Changes - runs-on: ubuntu-latest - outputs: - markdown_change: ${{ steps.filter_markdown.outputs.change }} - markdown_files: ${{ steps.filter_markdown.outputs.change_files }} - steps: +# jobs: +# job1: +# name: Check Not Allowed File Changes +# runs-on: ubuntu-latest +# outputs: +# markdown_change: ${{ steps.filter_markdown.outputs.change }} +# markdown_files: ${{ steps.filter_markdown.outputs.change_files }} +# steps: - - name: Check Not Allowed File Changes - uses: dorny/paths-filter@v2 - id: filter_not_allowed - with: - list-files: json - filters: | - change: - - 'package-lock.json' - - 'yarn.lock' - - 'pnpm-lock.yaml' +# - name: Check Not Allowed File Changes +# uses: dorny/paths-filter@v2 +# id: filter_not_allowed +# with: +# list-files: json +# filters: | +# change: +# - 'package-lock.json' +# - 'yarn.lock' +# - 'pnpm-lock.yaml' - # ref: https://github.com/github/docs/blob/main/.github/workflows/triage-unallowed-contributions.yml - - name: Comment About Changes We Can't Accept - if: ${{ steps.filter_not_allowed.outputs.change == 'true' }} - uses: actions/github-script@v6 - with: - script: | - let workflowFailMessage = "It looks like you've modified some files that we can't accept as contributions." - try { - const badFilesArr = [ - 'package-lock.json', - 'yarn.lock', - 'pnpm-lock.yaml', - ] - const badFiles = badFilesArr.join('\n- ') - const reviewMessage = `👋 Hey there spelunker. It looks like you've modified some files that we can't accept as contributions. The complete list of files we can't accept are:\n- ${badFiles}\n\nYou'll need to revert all of the files you changed in that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit) or \`git checkout origin/main \`. Once you get those files reverted, we can continue with the review process. :octocat:\n\nMore discussion:\n- https://github.com/onlook-dev/browser/issues/` - createdComment = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.number, - body: reviewMessage, - }) - workflowFailMessage = `${workflowFailMessage} Please see ${createdComment.data.html_url} for details.` - } catch(err) { - console.log("Error creating comment.", err) - } - core.setFailed(workflowFailMessage) +# # ref: https://github.com/github/docs/blob/main/.github/workflows/triage-unallowed-contributions.yml +# - name: Comment About Changes We Can't Accept +# if: ${{ steps.filter_not_allowed.outputs.change == 'true' }} +# uses: actions/github-script@v6 +# with: +# script: | +# let workflowFailMessage = "It looks like you've modified some files that we can't accept as contributions." +# try { +# const badFilesArr = [ +# 'package-lock.json', +# 'yarn.lock', +# 'pnpm-lock.yaml', +# ] +# const badFiles = badFilesArr.join('\n- ') +# const reviewMessage = `👋 Hey there spelunker. It looks like you've modified some files that we can't accept as contributions. The complete list of files we can't accept are:\n- ${badFiles}\n\nYou'll need to revert all of the files you changed in that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit) or \`git checkout origin/main \`. Once you get those files reverted, we can continue with the review process. :octocat:\n\nMore discussion:\n- https://github.com/onlook-dev/browser/issues/` +# createdComment = await github.rest.issues.createComment({ +# owner: context.repo.owner, +# repo: context.repo.repo, +# issue_number: context.payload.number, +# body: reviewMessage, +# }) +# workflowFailMessage = `${workflowFailMessage} Please see ${createdComment.data.html_url} for details.` +# } catch(err) { +# console.log("Error creating comment.", err) +# } +# core.setFailed(workflowFailMessage) - - name: Check Not Linted Markdown - if: ${{ always() }} - uses: dorny/paths-filter@v2 - id: filter_markdown - with: - list-files: shell - filters: | - change: - - added|modified: '*.md' +# - name: Check Not Linted Markdown +# if: ${{ always() }} +# uses: dorny/paths-filter@v2 +# id: filter_markdown +# with: +# list-files: shell +# filters: | +# change: +# - added|modified: '*.md' - job2: - name: Lint Markdown - runs-on: ubuntu-latest - needs: job1 - if: ${{ always() && needs.job1.outputs.markdown_change == 'true' }} - steps: - - name: Checkout Code - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} +# job2: +# name: Lint Markdown +# runs-on: ubuntu-latest +# needs: job1 +# if: ${{ always() && needs.job1.outputs.markdown_change == 'true' }} +# steps: +# - name: Checkout Code +# uses: actions/checkout@v3 +# with: +# ref: ${{ github.event.pull_request.head.sha }} - - name: Lint markdown - run: npx markdownlint-cli ${{ needs.job1.outputs.markdown_files }} --ignore node_modules \ No newline at end of file +# - name: Lint markdown +# run: npx markdownlint-cli ${{ needs.job1.outputs.markdown_files }} --ignore node_modules \ No newline at end of file diff --git a/electron/preload/common/constants.ts b/common/constants.ts similarity index 100% rename from electron/preload/common/constants.ts rename to common/constants.ts diff --git a/common/models.ts b/common/models.ts new file mode 100644 index 00000000..fd5d7beb --- /dev/null +++ b/common/models.ts @@ -0,0 +1,5 @@ +export interface ElementMetadata { + selector: string; + rect: DOMRect; + computedStyle: CSSStyleDeclaration; +} \ No newline at end of file diff --git a/electron/preload/webview/elements/index.ts b/electron/preload/webview/elements/index.ts index d58fc0a8..e16133e6 100644 --- a/electron/preload/webview/elements/index.ts +++ b/electron/preload/webview/elements/index.ts @@ -1,13 +1,12 @@ -import { EditorAttributes } from "../../common/constants"; +import { EditorAttributes } from "../../../../common/constants"; +import { ElementMetadata } from "../../../../common/models"; import { finder } from "./finder"; -export interface ElementMetadata { - selector: string; - rect: DOMRect; - computedStyle: CSSStyleDeclaration; -} - export const handleMouseEvent = (e: MouseEvent): Object => { + if (!e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + } const el = deepElementFromPoint(e.clientX, e.clientY) if (!el) return { coordinates: { x: e.clientX, y: e.clientY } } diff --git a/electron/preload/webview/eventBridge.ts b/electron/preload/webview/eventBridge.ts index 1b04e469..400a6ba0 100644 --- a/electron/preload/webview/eventBridge.ts +++ b/electron/preload/webview/eventBridge.ts @@ -5,27 +5,19 @@ export class EventBridge { constructor() { } init() { - ipcRenderer.sendToHost("key", {}); - this.setForwardingToHost(); this.setListenToHostEvents(); } - eventHandlerMap: { [key: string]: (e: any) => Object } = { + eventHandlerMap: Record Object> = { 'mouseover': handleMouseEvent, + 'click': handleMouseEvent, + 'dblclick': handleMouseEvent, 'wheel': (e: WheelEvent) => { - return { - coordinates: { x: e.deltaX, y: e.deltaY }, - innerHeight: document.body.scrollHeight, - innerWidth: window.innerWidth, - } + return {} }, 'scroll': (e: Event) => { - return { - coordinates: { x: window.scrollX, y: window.scrollY }, - innerHeight: document.body.scrollHeight, - innerWidth: window.innerWidth, - } + return {} }, 'dom-ready': () => { const { body } = document; @@ -48,7 +40,6 @@ export class EventBridge { } setForwardingToHost() { - ipcRenderer.sendToHost("key", {}); Object.entries(this.eventHandlerMap).forEach(([key, handler]) => { document.body.addEventListener(key, (e) => { const data = JSON.stringify(handler(e)); diff --git a/src/lib/editor/eventHandler.ts b/src/lib/editor/eventHandler.ts new file mode 100644 index 00000000..4a617197 --- /dev/null +++ b/src/lib/editor/eventHandler.ts @@ -0,0 +1,59 @@ +import { ElementMetadata } from 'common/models'; +import { OverlayManager } from './overlay'; + +export class WebviewEventHandler { + eventCallbackMap: Record void> + + constructor(overlayManager: OverlayManager) { + this.handleIpcMessage = this.handleIpcMessage.bind(this); + this.handleConsoleMessage = this.handleConsoleMessage.bind(this); + + this.eventCallbackMap = { + 'mouseover': (e: Electron.IpcMessageEvent) => { + if (!e.args || e.args.length === 0) { + console.error('No args found for mouseover event'); + return; + } + + const sourceWebview = e.target as Electron.WebviewTag; + const elementMetadata: ElementMetadata = JSON.parse(e.args[0]); + const adjustedRect = overlayManager.adaptRectFromSourceElement(elementMetadata.rect, sourceWebview); + overlayManager.updateHoverRect(adjustedRect); + }, + 'click': (e: Electron.IpcMessageEvent) => { + if (!e.args || e.args.length === 0) { + console.error('No args found for mouseover event'); + return; + } + + const sourceWebview = e.target as Electron.WebviewTag; + const elementMetadata: ElementMetadata = JSON.parse(e.args[0]); + const adjustedRect = overlayManager.adaptRectFromSourceElement(elementMetadata.rect, sourceWebview); + overlayManager.removeClickedRects(); + overlayManager.addClickRect(adjustedRect, elementMetadata.computedStyle); + }, + 'wheel': (e: Electron.IpcMessageEvent) => { + if (!e.args || e.args.length === 0) { + console.error('No args found for mouseover event'); + return; + } + }, + }; + + } + + handleIpcMessage(e: Electron.IpcMessageEvent) { + console.log('ipc-message', e); + const eventHandler = this.eventCallbackMap[e.channel] + if (!eventHandler) { + console.error(`No event handler found for ${e.channel}`); + return; + } + eventHandler(e); + } + + handleConsoleMessage(e: Electron.ConsoleMessageEvent) { + console.log(`%c ${e.message}`, 'background: #000; color: #AAFF00'); + } +} + diff --git a/src/lib/editor/messageBridge.ts b/src/lib/editor/messageBridge.ts new file mode 100644 index 00000000..23127d53 --- /dev/null +++ b/src/lib/editor/messageBridge.ts @@ -0,0 +1,37 @@ +import { WebviewMetadata } from '@/lib/models'; +import { WebviewEventHandler } from './eventHandler'; + +interface WebviewContext { + handlerRemovers: (() => void)[]; +} + +export class WebviewMessageBridge { + webviewMap: Map = new Map(); + eventHandlerMap: Record void>; + + constructor(webviewEventHandler: WebviewEventHandler) { + this.eventHandlerMap = { + 'ipc-message': webviewEventHandler.handleIpcMessage, + 'console-message': webviewEventHandler.handleConsoleMessage, + } + } + + registerWebView(webview: Electron.WebviewTag, metadata: WebviewMetadata) { + const handlerRemovers: (() => void)[] = []; + Object.entries(this.eventHandlerMap).forEach(([event, handler]) => { + webview.addEventListener(event, handler as any); + handlerRemovers.push(() => { + webview.removeEventListener(event, handler as any); + }); + }); + this.webviewMap.set(metadata.id, { handlerRemovers }); + } + + deregisterWebView(webview: Electron.WebviewTag) { + const context = this.webviewMap.get(webview.id); + if (!context) + return; + context.handlerRemovers.forEach((removeHandler) => removeHandler()); + this.webviewMap.delete(webview.id); + } +} \ No newline at end of file diff --git a/src/lib/editor/overlay/index.ts b/src/lib/editor/overlay/index.ts new file mode 100644 index 00000000..bd89e739 --- /dev/null +++ b/src/lib/editor/overlay/index.ts @@ -0,0 +1,134 @@ +import { ClickRect, EditRect, HoverRect, ParentRect } from "./rect"; + +export class OverlayManager { + overlayContainer: HTMLElement | undefined; + hoverRect: HoverRect + clickedRects: ClickRect[] + parentRect: ParentRect + editRect: EditRect + + constructor() { + this.hoverRect = new HoverRect(); + this.parentRect = new ParentRect(); + this.editRect = new EditRect(); + this.clickedRects = []; + + this.initializeRects() + this.bindMethods() + } + + initializeRects = () => { + this.appendRectToPopover(this.hoverRect.element) + this.appendRectToPopover(this.parentRect.element) + this.appendRectToPopover(this.editRect.element) + } + + bindMethods = () => { + this.setOverlayContainer = this.setOverlayContainer.bind(this) + this.updateHoverRect = this.updateHoverRect.bind(this) + this.updateParentRect = this.updateParentRect.bind(this) + this.updateEditRect = this.updateEditRect.bind(this) + this.hideHoverRect = this.hideHoverRect.bind(this) + this.showHoverRect = this.showHoverRect.bind(this) + this.removeHoverRect = this.removeHoverRect.bind(this) + this.removeEditRect = this.removeEditRect.bind(this) + this.removeClickedRects = this.removeClickedRects.bind(this) + this.clear = this.clear.bind(this) + } + + // Helper function to calculate the relative offset to a common ancestor + getRelativeOffset(element: HTMLElement, ancestor: HTMLElement) { + let top = 0, left = 0; + while (element && element !== ancestor) { + top += element.offsetTop || 0; + left += element.offsetLeft || 0; + element = element.offsetParent as HTMLElement; + } + return { top, left }; + } + + adaptRectFromSourceElement(rect: DOMRect, sourceWebview: Electron.WebviewTag) { + const commonAncestor = this.overlayContainer?.parentElement as HTMLElement; + const sourceOffset = this.getRelativeOffset(sourceWebview, 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; + }; + + setOverlayContainer = (container: HTMLElement) => { + this.overlayContainer = container; + this.appendRectToPopover(this.hoverRect.element); + this.appendRectToPopover(this.parentRect.element); + this.appendRectToPopover(this.editRect.element); + }; + + appendRectToPopover = (rect: HTMLElement) => { + if (this.overlayContainer) { + this.overlayContainer.appendChild(rect); + } + }; + + clear = () => { + this.removeParentRect() + this.removeHoverRect() + this.removeClickedRects() + this.removeEditRect() + } + + addClickRect = (rect: DOMRect, computerStyle: CSSStyleDeclaration) => { + const clickRect = new ClickRect() + this.appendRectToPopover(clickRect.element) + this.clickedRects.push(clickRect) + const margin = computerStyle.margin + const padding = computerStyle.padding + clickRect.render({ width: rect.width, height: rect.height, top: rect.top, left: rect.left, padding, margin }); + } + + updateParentRect = (el: HTMLElement) => { + if (!el) return + const rect = el.getBoundingClientRect() + this.parentRect.render(rect) + } + + updateHoverRect = (rect: DOMRect) => { + this.hoverRect.render(rect) + } + + updateEditRect = (el: HTMLElement) => { + if (!el) return + const rect = el.getBoundingClientRect() + this.editRect.render(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 }) + } + + removeEditRect = () => { + this.editRect.render({ width: 0, height: 0, top: 0, left: 0 }) + } + + removeClickedRects = () => { + this.clickedRects.forEach(clickRect => { + clickRect.element.remove() + }) + this.clickedRects = [] + } + + removeParentRect = () => { + this.parentRect.render({ width: 0, height: 0, top: 0, left: 0 }) + } +} diff --git a/src/lib/editor/overlay/rect.ts b/src/lib/editor/overlay/rect.ts new file mode 100644 index 00000000..6c3c0e1d --- /dev/null +++ b/src/lib/editor/overlay/rect.ts @@ -0,0 +1,277 @@ +import { nanoid } from 'nanoid'; +import { EditorAttributes } from '../../../../common/constants'; + +interface RectDimensions { + width: number; + height: number; + top: number; + left: number; +} + +interface Rect { + element: HTMLElement; + svgNamespace: string; + svgElement: Element; + rectElement: Element; + render: (rectDimensions: RectDimensions) => void; +} + +export class RectImpl implements Rect { + element: HTMLElement; + svgNamespace: string = 'http://www.w3.org/2000/svg' + svgElement: Element; + rectElement: Element; + + constructor() { + this.element = document.createElement('div') + this.svgElement = document.createElementNS(this.svgNamespace, 'svg') + this.svgElement.setAttribute('overflow', 'visible') + this.rectElement = document.createElementNS(this.svgNamespace, 'rect') + this.rectElement.setAttribute('fill', 'none') + this.rectElement.setAttribute('stroke', '#FF0E48') + this.rectElement.setAttribute('stroke-width', '2') + this.rectElement.setAttribute('stroke-linecap', 'round') + this.rectElement.setAttribute('stroke-linejoin', 'round') + this.svgElement.appendChild(this.rectElement) + + this.element.style.position = 'absolute' + this.element.style.pointerEvents = 'none' // Ensure it doesn't interfere with other interactions + this.element.style.zIndex = '999' + this.element.setAttribute(EditorAttributes.DATA_ONLOOK_IGNORE, 'true'); + this.element.setAttribute('id', EditorAttributes.ONLOOK_RECT_ID) + this.element.appendChild(this.svgElement) + + } + + render({ width, height, top, left }: RectDimensions) { + this.svgElement.setAttribute('width', width.toString()) + this.svgElement.setAttribute('height', height.toString()) + this.svgElement.setAttribute('viewBox', `0 0 ${width} ${height}`) + this.rectElement.setAttribute('width', width.toString()) + this.rectElement.setAttribute('height', height.toString()) + this.element.style.top = `${top}px` + this.element.style.left = `${left}px` + } +} + +export class HoverRect extends RectImpl { + constructor() { + super() + this.rectElement.setAttribute('stroke-width', '1') + } + + render(rectDimensions: RectDimensions) { + super.render(rectDimensions) + } +} + +export class ClickRect extends RectImpl { + constructor() { + super() + this.rectElement.setAttribute('stroke-width', '2') + } + + parseCssBoxValues(boxValue: string) { + const values = boxValue.split(' ').map(parseFloat); + if (values.length === 1) { + return { top: values[0], right: values[0], bottom: values[0], left: values[0] }; + } else if (values.length === 2) { + return { top: values[0], right: values[1], bottom: values[0], left: values[1] }; + } else if (values.length === 3) { + return { top: values[0], right: values[1], bottom: values[2], left: values[1] }; + } else { + return { top: values[0], right: values[1], bottom: values[2], left: values[3] }; + } + } + + createStripePattern(color = '#FF0E48') { + // Define a larger pattern for spaced-out stripes + const pattern = document.createElementNS(this.svgNamespace, 'pattern'); + const patternId = 'pattern-' + nanoid(); + pattern.setAttribute('id', patternId); + pattern.setAttribute('patternUnits', 'userSpaceOnUse'); + pattern.setAttribute('width', '20'); // Increased pattern width for spacing + pattern.setAttribute('height', '20'); // Increased pattern height for spacing + + // Create a background rectangle for the pattern + const background = document.createElementNS(this.svgNamespace, 'rect'); + background.setAttribute('width', '20'); // Match pattern width + background.setAttribute('height', '20'); // Match pattern height + background.setAttribute('fill', color); // Background color + background.setAttribute('fill-opacity', '0.1'); // Low opacity + + // Create multiple diagonal lines for the pattern to ensure connectivity + // Adjust the number of lines and their positions if you modify the pattern size + const createLine = (x1: string, y1: string, x2: string, y2: string) => { + const line = document.createElementNS(this.svgNamespace, 'line'); + line.setAttribute('x1', x1); + line.setAttribute('y1', y1); + line.setAttribute('x2', x2); + line.setAttribute('y2', y2); + line.setAttribute('stroke', color); // Stripe color + line.setAttribute('stroke-width', '0.3'); // Adjusted for visibility in larger pattern + line.setAttribute('stroke-linecap', 'square'); + return line; + }; + + // Add the background rectangle to the pattern first + pattern.appendChild(background); + + // Add lines to the pattern for a seamless connection across repeats + // The lines are drawn from corner to corner + pattern.appendChild(createLine('0', '20', '20', '0')); // Main diagonal line + + // Add the pattern to the SVG + this.svgElement.appendChild(pattern); + + return patternId; + } + + + updateMargin(margin: string, { width, height }: { width: number; height: number; }) { + const { top: mTop, right: mRight, bottom: mBottom, left: mLeft } = this.parseCssBoxValues(margin); + // Adjust position and size based on margins + const mWidth = width + mLeft + mRight; + const mHeight = height + mTop + mBottom; + const mX = -mLeft; + const mY = -mTop; + + const patternId = this.createStripePattern('#FF00FF'); + + // Create and style the margin rectangle + const marginRect = document.createElementNS(this.svgNamespace, 'rect'); + marginRect.setAttribute('x', mX.toString()); + marginRect.setAttribute('y', mY.toString()); + marginRect.setAttribute('width', mWidth.toString()); + marginRect.setAttribute('height', mHeight.toString()); + marginRect.setAttribute('fill', `url(#${patternId})`); // Use the pattern + marginRect.setAttribute('stroke', 'none'); + + // Create a mask element + const mask = document.createElementNS(this.svgNamespace, 'mask'); + const maskId = 'mask-' + nanoid(); // Unique ID for the mask + mask.setAttribute('id', maskId); + + // Create a white rectangle for the mask that matches the element size + // This rectangle allows the content beneath to show through where it overlaps with the marginRect + const maskRect = document.createElementNS(this.svgNamespace, 'rect'); + maskRect.setAttribute('x', mX.toString()); + maskRect.setAttribute('y', mY.toString()); + maskRect.setAttribute('width', mWidth.toString()); + maskRect.setAttribute('height', mHeight.toString()); + maskRect.setAttribute('fill', 'white'); // White areas of a mask are fully visible + + // Create the cutoutRect for the mask, which will block out the center + const cutoutRect = document.createElementNS(this.svgNamespace, 'rect'); + cutoutRect.setAttribute('x', '0'); + cutoutRect.setAttribute('y', '0'); + cutoutRect.setAttribute('width', width.toString()); + cutoutRect.setAttribute('height', height.toString()); + cutoutRect.setAttribute('fill', 'black'); // Black areas of a mask are fully transparent + + // Append the maskRect and cutoutRect to the mask + mask.appendChild(maskRect); + mask.appendChild(cutoutRect); + + // Add the mask to the SVG + this.svgElement.appendChild(mask); + + // Apply the mask to the marginRect + marginRect.setAttribute('mask', `url(#${maskId})`); + + // Add the marginRect to the SVG, which is now masked + this.svgElement.appendChild(marginRect); + } + + updatePadding(padding: string, { width, height }: { width: number; height: number; }) { + const { top: pTop, right: pRight, bottom: pBottom, left: pLeft } = this.parseCssBoxValues(padding); + // Adjust position and size based on paddings + const pWidth = width - pLeft - pRight; + const pHeight = height - pTop - pBottom; + const pX = pLeft; + const pY = pTop; + + const patternId = this.createStripePattern('green'); + + // Create and style the padding rectangle + const fullRect = document.createElementNS(this.svgNamespace, 'rect'); + fullRect.setAttribute('x', '0'); + fullRect.setAttribute('y', '0'); + fullRect.setAttribute('width', width.toString()); + fullRect.setAttribute('height', height.toString()); + fullRect.setAttribute('fill', `url(#${patternId})`); // Use the pattern + fullRect.setAttribute('stroke', 'none'); + + // // Create a mask element + const mask = document.createElementNS(this.svgNamespace, 'mask'); + const maskId = 'mask-' + nanoid(); // Unique ID for the mask + mask.setAttribute('id', maskId); + + // // Create a white rectangle for the mask that matches the element size + // // This rectangle allows the content beneath to show through where it overlaps with the marginRect + const maskRect = document.createElementNS(this.svgNamespace, 'rect'); + maskRect.setAttribute('x', '0'); + maskRect.setAttribute('y', '0'); + maskRect.setAttribute('width', width.toString()); + maskRect.setAttribute('height', height.toString()); + maskRect.setAttribute('fill', 'white'); + + // // Create the cutoutRect for the mask, which will block out the center + const cutoutRect = document.createElementNS(this.svgNamespace, 'rect'); + cutoutRect.setAttribute('x', pX.toString()); + cutoutRect.setAttribute('y', pY.toString()); + cutoutRect.setAttribute('width', pWidth.toString()); + cutoutRect.setAttribute('height', pHeight.toString()); + cutoutRect.setAttribute('fill', 'black'); // Black areas of a mask are fully transparent + + // // Append the maskRect and cutoutRect to the mask + mask.appendChild(maskRect); + mask.appendChild(cutoutRect); + + // // Add the mask to the SVG + this.svgElement.appendChild(mask); + + // // Apply the mask to the marginRect + fullRect.setAttribute('mask', `url(#${maskId})`); + + // Add the marginRect to the SVG, which is now masked + this.svgElement.appendChild(fullRect); + } + + render({ width, height, top, left, margin, padding }: { width: number, height: number, top: number, left: number, margin: string, padding: string }) { + // Sometimes a selected element can be removed. We handle this gracefully. + try { + this.updateMargin(margin, { width, height, }); + this.updatePadding(padding, { width, height, }); + + // Render the base rect (the element itself) on top + super.render({ width, height, top, left, }); + } catch (error) { + console.warn(error); + } + } +} + +export class ParentRect extends RectImpl { + constructor() { + super() + this.rectElement.setAttribute('stroke-width', '1') + this.rectElement.setAttribute('stroke-dasharray', '5') + } + + render(rect: RectDimensions) { + super.render(rect) + } +} + +export class EditRect extends RectImpl { + constructor() { + super() + this.rectElement.setAttribute('stroke', '#00FF94') + this.rectElement.setAttribute('stroke-width', '2') + } + + render(rect: RectDimensions) { + super.render(rect) + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index ad0a7c06..00000000 --- a/src/lib/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function handleIpcMessage(e: Electron.IpcMessageEvent) { - console.log("Ipc Message:", e.channel, e.args) -}; - -export function handleConsoleMessage(e: Electron.ConsoleMessageEvent) { - console.log(`%c ${e.message}`, 'background: #000; color: #AAFF00'); -} diff --git a/src/routes/project/Canvas.tsx b/src/routes/project/Canvas.tsx index 3090de39..fc7369fc 100644 --- a/src/routes/project/Canvas.tsx +++ b/src/routes/project/Canvas.tsx @@ -11,7 +11,7 @@ function Canvas({ children }: Canvas) { const containerRef = useRef(null); const overlayRef = useRef(null); - const zoomSensitivity = 0.002; + const zoomSensitivity = 0.003; const panSensitivity = 0.4; const handleWheel = (event: WheelEvent) => { diff --git a/src/routes/project/EditorTopBar.tsx b/src/routes/project/ProjectTopBar.tsx similarity index 100% rename from src/routes/project/EditorTopBar.tsx rename to src/routes/project/ProjectTopBar.tsx diff --git a/src/routes/project/EditorPanel.tsx b/src/routes/project/SidePanel.tsx similarity index 61% rename from src/routes/project/EditorPanel.tsx rename to src/routes/project/SidePanel.tsx index 2dc4d5f8..f16b83ac 100644 --- a/src/routes/project/EditorPanel.tsx +++ b/src/routes/project/SidePanel.tsx @@ -1,8 +1,7 @@ -import React from 'react'; const EditorPanel = () => { return ( -
+
); }; diff --git a/src/routes/project/index.tsx b/src/routes/project/index.tsx index c5c77415..ec5c4a05 100644 --- a/src/routes/project/index.tsx +++ b/src/routes/project/index.tsx @@ -1,7 +1,7 @@ import Canvas from './Canvas'; -import EditorPanel from './EditorPanel'; -import EditorTopBar from './EditorTopBar'; +import EditorPanel from './SidePanel'; +import EditorTopBar from './ProjectTopBar'; import WebviewArea from './webview/WebviewArea'; function ProjectEditor() { diff --git a/src/routes/project/webview/Overlay.tsx b/src/routes/project/webview/Overlay.tsx new file mode 100644 index 00000000..5594d8a9 --- /dev/null +++ b/src/routes/project/webview/Overlay.tsx @@ -0,0 +1,25 @@ +import { OverlayManager } from "@/lib/editor/overlay"; +import { useEffect, useRef } from "react"; + +function Overlay({ children, overlayManager }: { children: React.ReactNode, overlayManager: OverlayManager }) { + const overlayContainerRef = useRef(null); + + useEffect(() => { + if (overlayContainerRef.current) { + const overlayContainer = overlayContainerRef.current; + if (!overlayManager) return; + overlayManager.setOverlayContainer(overlayContainer); + return () => { + overlayManager.clear(); + }; + } + }, [overlayManager, overlayContainerRef]); + return ( + <> + {children} +
+ + ) +} + +export default Overlay; \ No newline at end of file diff --git a/src/routes/project/webview/Webview.tsx b/src/routes/project/webview/Webview.tsx index 73faff5f..bd157c84 100644 --- a/src/routes/project/webview/Webview.tsx +++ b/src/routes/project/webview/Webview.tsx @@ -1,47 +1,27 @@ import { Label } from '@/components/ui/label'; -import { handleConsoleMessage, handleIpcMessage } from '@/lib'; import { MainChannel } from '@/lib/constants'; +import { WebviewMessageBridge } from '@/lib/editor/messageBridge'; import { WebviewMetadata } from '@/lib/models'; import { useEffect, useRef, useState } from 'react'; - -function Webview({ metadata }: { metadata: WebviewMetadata }) { - const ref = useRef(null); +function Webview({ webviewMessageBridge, metadata }: { webviewMessageBridge: WebviewMessageBridge, metadata: WebviewMetadata }) { + const webviewRef = useRef(null); const [webviewPreloadPath, setWebviewPreloadPath] = useState(''); - const eventHandlerMap = { - 'ipc-message': handleIpcMessage, - 'console-message': handleConsoleMessage, - } - - function setWebviewHandlers(): (() => void)[] { - const handlerRemovers: (() => void)[] = []; - const webview = ref.current as Electron.WebviewTag | null; - if (!webview) - return handlerRemovers; - - Object.entries(eventHandlerMap).forEach(([event, handler]) => { - webview.addEventListener(event, handler as any); - handlerRemovers.push(() => { - webview.removeEventListener(event, handler as any); - }); + function fetchPreloadPath() { + window.Main.invoke(MainChannel.WEBVIEW_PRELOAD_PATH).then((preloadPath: any) => { + setWebviewPreloadPath(preloadPath); }); - - return handlerRemovers; } useEffect(() => { - window.Main.invoke(MainChannel.WEBVIEW_PRELOAD_PATH).then((preloadPath: any) => { - setWebviewPreloadPath(preloadPath); - }); + fetchPreloadPath(); + const webview = webviewRef?.current; + if (!webview) return; - const handlerRemovers = setWebviewHandlers(); - return () => { - handlerRemovers.forEach((handlerRemover) => { - handlerRemover(); - }); - }; - }, [ref, webviewPreloadPath]); + webviewMessageBridge.registerWebView(webview, metadata); + return () => webviewMessageBridge.deregisterWebView(webview); + }, [webviewRef, webviewPreloadPath]); if (webviewPreloadPath) return ( @@ -49,14 +29,13 @@ function Webview({ metadata }: { metadata: WebviewMetadata }) {
- ); } diff --git a/src/routes/project/webview/WebviewArea.tsx b/src/routes/project/webview/WebviewArea.tsx index a9c71250..81088471 100644 --- a/src/routes/project/webview/WebviewArea.tsx +++ b/src/routes/project/webview/WebviewArea.tsx @@ -1,8 +1,15 @@ +import { WebviewEventHandler } from '@/lib/editor/eventHandler'; +import { WebviewMessageBridge } from '@/lib/editor/messageBridge'; +import { OverlayManager } from '@/lib/editor/overlay'; import { WebviewMetadata } from '@/lib/models'; import { nanoid } from 'nanoid'; +import Overlay from './Overlay'; import Webview from './Webview'; function WebviewArea() { + const overlayManager = new OverlayManager(); + const webviewEventHandler = new WebviewEventHandler(overlayManager); + const webviewMessageBridge = new WebviewMessageBridge(webviewEventHandler); const webviews: WebviewMetadata[] = [ { id: nanoid(), @@ -12,11 +19,13 @@ function WebviewArea() { ]; return ( -
- {webviews.map((metadata, index) => ( - - ))} -
+ +
+ {webviews.map((metadata, index) => ( + + ))} +
+
); } diff --git a/tsconfig.json b/tsconfig.json index 9472797c..0e47c683 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ }, "include": [ "src", - "electron" + "electron", + "common" ], "references": [ {