diff --git a/app/common/constants.ts b/app/common/constants.ts index 4d9cbbd4..270f2e45 100644 --- a/app/common/constants.ts +++ b/app/common/constants.ts @@ -31,6 +31,8 @@ export enum WebviewChannels { MOVE_ELEMENT = 'move-element', EDIT_ELEMENT_TEXT = 'edit-element-text', CLEAN_AFTER_WRITE_TO_CODE = 'clean-after-write', + GROUP_ELEMENTS = 'group-elements', + UNGROUP_ELEMENTS = 'ungroup-elements', // From Webview ELEMENT_INSERTED = 'element-inserted', diff --git a/app/common/hotkeys.ts b/app/common/hotkeys.ts index 7f473c1d..566e6334 100644 --- a/app/common/hotkeys.ts +++ b/app/common/hotkeys.ts @@ -11,6 +11,8 @@ export class Hotkey { // Actions static readonly UNDO = new Hotkey('mod+z', 'Undo'); static readonly REDO = new Hotkey('mod+shift+z', 'Redo'); + static readonly GROUP = new Hotkey('mod+g', 'Group'); + static readonly UNGROUP = new Hotkey('mod+shift+g', 'Ungroup'); // Text static readonly INSERT_TEXT = new Hotkey('t', 'Insert Text'); diff --git a/app/common/models/actions/code.ts b/app/common/models/actions/code.ts index de5370a7..45395e45 100644 --- a/app/common/models/actions/code.ts +++ b/app/common/models/actions/code.ts @@ -1,4 +1,4 @@ -import { ActionElementLocation, MoveActionLocation } from '.'; +import { ActionElementLocation, ActionTarget, GroupActionTarget, MoveActionLocation } from '.'; import { InsertPos } from '..'; import { TemplateNode } from '../element/templateNode'; @@ -6,6 +6,8 @@ export enum CodeActionType { MOVE = 'move', INSERT = 'insert', REMOVE = 'remove', + GROUP = 'group', + UNGROUP = 'ungroup', } interface BaseCodeAction { @@ -51,4 +53,18 @@ export interface CodeStyle { styles: Record; } -export type CodeAction = CodeMove | CodeInsert | CodeRemove; +export interface BaseGroupAction extends BaseCodeAction { + location: ActionElementLocation; + container: CodeInsert; + targets: GroupActionTarget[]; +} + +export interface CodeGroup extends BaseGroupAction { + type: CodeActionType.GROUP; +} + +export interface CodeUngroup extends BaseGroupAction { + type: CodeActionType.UNGROUP; +} + +export type CodeAction = CodeMove | CodeInsert | CodeRemove | CodeGroup | CodeUngroup; diff --git a/app/common/models/actions/index.ts b/app/common/models/actions/index.ts index 521ac9eb..02614031 100644 --- a/app/common/models/actions/index.ts +++ b/app/common/models/actions/index.ts @@ -15,6 +15,10 @@ export interface StyleActionTarget extends ActionTarget { change: Change; } +export interface GroupActionTarget extends ActionTarget { + index: number; +} + export interface ActionElementLocation { position: InsertPos; targetSelector: string; @@ -23,7 +27,6 @@ export interface ActionElementLocation { export interface MoveActionLocation extends ActionElementLocation { originalIndex: number; - index: number; } export interface ActionElement { @@ -72,9 +75,25 @@ export interface EditTextAction { newContent: string; } +export interface BaseGroupAction { + targets: Array; + location: ActionElementLocation; + container: ActionElement; + webviewId: string; +} +export interface GroupElementsAction extends BaseGroupAction { + type: 'group-elements'; +} + +export interface UngroupElementsAction extends BaseGroupAction { + type: 'ungroup-elements'; +} + export type Action = | UpdateStyleAction | InsertElementAction | RemoveElementAction | MoveElementAction - | EditTextAction; + | EditTextAction + | GroupElementsAction + | UngroupElementsAction; diff --git a/app/common/models/code.ts b/app/common/models/code.ts index ea351919..2797f035 100644 --- a/app/common/models/code.ts +++ b/app/common/models/code.ts @@ -1,15 +1,19 @@ -import { CodeInsert, CodeMove, CodeRemove } from './actions/code'; +import { CodeGroup, CodeInsert, CodeMove, CodeRemove, CodeUngroup } from './actions/code'; import { TemplateNode } from './element/templateNode'; export interface CodeDiffRequest { selector: string; templateNode: TemplateNode; - insertedElements: CodeInsert[]; - removedElements: CodeRemove[]; - movedElements: CodeMove[]; attributes: Record; textContent?: string; overrideClasses?: boolean; + + // Structual changes + insertedElements: CodeInsert[]; + removedElements: CodeRemove[]; + movedElements: CodeMove[]; + groupElements: CodeGroup[]; + ungroupElements: CodeUngroup[]; } export interface CodeDiff { diff --git a/app/electron/main/code/diff/group.ts b/app/electron/main/code/diff/group.ts new file mode 100644 index 00000000..5a10c9df --- /dev/null +++ b/app/electron/main/code/diff/group.ts @@ -0,0 +1,67 @@ +import { NodePath } from '@babel/traverse'; +import * as t from '@babel/types'; +import { addKeyToElement, addUuidToElement, jsxFilter } from './helpers'; +import { createInsertedElement, insertAtIndex } from './insert'; +import { removeElementAtIndex } from './remove'; +import { CodeGroup, CodeUngroup } from '/common/models/actions/code'; + +export function groupElementsInNode(path: NodePath, element: CodeGroup): void { + // Get target elements + const children = path.node.children; + const jsxElements = children.filter(jsxFilter); + const targetElements = element.targets + .sort((a, b) => a.index - b.index) + .map((target) => { + const targetEl = jsxElements[target.index]; + addKeyToElement(targetEl); + addUuidToElement(targetEl, target.uuid); + return targetEl; + }); + + // Remove target elements from children + targetElements.forEach((targetElement) => { + removeElementAtIndex(jsxElements.indexOf(targetElement), jsxElements, children); + }); + + // Add target elements to container + const container = createInsertedElement(element.container); + container.children = targetElements; + + // Insert container at index + insertAtIndex(path, container, element.location.index); + + path.stop(); +} + +export function ungroupElementsInNode(path: NodePath, element: CodeUngroup): void { + // Find the container element + const children = path.node.children; + const jsxElements = children.filter(jsxFilter); + const containerIndex = element.location.index; + const container = jsxElements[containerIndex] as t.JSXElement; + + if (!t.isJSXElement(container)) { + throw new Error('Container element not found'); + } + + // Get the elements to ungroup + const elementsToUngroup = container.children.filter(jsxFilter) as Array< + t.JSXElement | t.JSXFragment + >; + + // Remove the container from the parent + removeElementAtIndex(containerIndex, jsxElements, children); + + // Insert the ungrouped elements back into the parent + element.targets.forEach((target, index) => { + const elementToInsert = elementsToUngroup[index]; + addKeyToElement(elementToInsert); + addUuidToElement(elementToInsert, target.uuid); + if (elementToInsert) { + const insertIndex = target.index + index; // Adjust index based on previous insertions + children.splice(insertIndex, 0, elementToInsert); + } + }); + + path.stop(); +} diff --git a/app/electron/main/code/diff/helpers.ts b/app/electron/main/code/diff/helpers.ts index 6bfc1a5c..b410b438 100644 --- a/app/electron/main/code/diff/helpers.ts +++ b/app/electron/main/code/diff/helpers.ts @@ -18,7 +18,7 @@ export function hashTemplateNode(node: TemplateNode): string { return `${node.path}:${node.startTag.start.line}:${node.startTag.start.column}`; } -export function addKeyToElement(element: t.JSXElement): void { +export function addKeyToElement(element: t.JSXElement | t.JSXFragment): void { if (t.isJSXElement(element)) { const keyExists = element.openingElement.attributes.findIndex( @@ -32,13 +32,14 @@ export function addKeyToElement(element: t.JSXElement): void { } } -export function addUuidToElement(element: t.JSXElement, uuid: string): void { +export function addUuidToElement(element: t.JSXElement | t.JSXFragment, uuid: string): void { if (t.isJSXElement(element)) { const keyExists = element.openingElement.attributes.findIndex( (attr) => t.isJSXAttribute(attr) && - attr.name.name === EditorAttributes.DATA_ONLOOK_UNIQUE_ID, + (attr.name.name === EditorAttributes.DATA_ONLOOK_UNIQUE_ID || + attr.name.name === EditorAttributes.DATA_ONLOOK_TEMP_ID), ) !== -1; if (!keyExists) { const keyAttribute = t.jsxAttribute( @@ -49,3 +50,7 @@ export function addUuidToElement(element: t.JSXElement, uuid: string): void { } } } + +export const jsxFilter = ( + child: t.JSXElement | t.JSXExpressionContainer | t.JSXFragment | t.JSXSpreadChild | t.JSXText, +) => t.isJSXElement(child) || t.isJSXFragment(child); diff --git a/app/electron/main/code/diff/insert.ts b/app/electron/main/code/diff/insert.ts index cafd86cd..78650705 100644 --- a/app/electron/main/code/diff/insert.ts +++ b/app/electron/main/code/diff/insert.ts @@ -1,7 +1,7 @@ import { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { parseJsxCodeBlock } from '../helpers'; -import { addKeyToElement, addUuidToElement } from './helpers'; +import { addKeyToElement, addUuidToElement, jsxFilter } from './helpers'; import { assertNever } from '/common/helpers'; import { InsertPos } from '/common/models'; import { CodeInsert } from '/common/models/actions/code'; @@ -28,7 +28,7 @@ export function insertElementToNode(path: NodePath, element: CodeI path.stop(); } -function createInsertedElement(insertedChild: CodeInsert): t.JSXElement { +export function createInsertedElement(insertedChild: CodeInsert): t.JSXElement { let element: t.JSXElement; if (insertedChild.codeBlock) { element = parseJsxCodeBlock(insertedChild.codeBlock) || createJSXElement(insertedChild); @@ -78,16 +78,13 @@ function createJSXElement(insertedChild: CodeInsert): t.JSXElement { return t.jsxElement(openingElement, closingElement, children, isSelfClosing); } -function insertAtIndex( +export function insertAtIndex( path: NodePath, newElement: t.JSXElement, index: number, ): void { - // Note: children includes non-JSXElement which our index does not account for. We need to find the JSXElement/JSXFragment-only index. if (index !== -1) { - const jsxElements = path.node.children.filter( - (child) => t.isJSXElement(child) || t.isJSXFragment(child), - ) as t.JSXElement[]; + const jsxElements = path.node.children.filter(jsxFilter); const targetIndex = Math.min(index, jsxElements.length); diff --git a/app/electron/main/code/diff/move.ts b/app/electron/main/code/diff/move.ts index d5742eb1..2e71bd2a 100644 --- a/app/electron/main/code/diff/move.ts +++ b/app/electron/main/code/diff/move.ts @@ -1,26 +1,20 @@ import { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; -import { addKeyToElement } from './helpers'; +import { addKeyToElement, jsxFilter } from './helpers'; import { CodeMove } from '/common/models/actions/code'; export function moveElementInNode(path: NodePath, element: CodeMove): void { - // Note: children includes non-JSXElement which our index does not account for. We need to find the JSXElement/JSXFragment-only index. const children = path.node.children; - - const jsxElements = children.filter( - (child) => t.isJSXElement(child) || t.isJSXFragment(child), - ) as t.JSXElement[]; + const jsxElements = children.filter(jsxFilter); const [elementToMove] = jsxElements.splice(element.location.originalIndex, 1); - if (!elementToMove) { - console.error('Element not found for move:', element); + console.error('Element not found for move'); return; } addKeyToElement(elementToMove as t.JSXElement); let targetIndex = Math.min(element.location.index, jsxElements.length); - if (element.location.index > element.location.originalIndex) { targetIndex -= 1; } diff --git a/app/electron/main/code/diff/remove.ts b/app/electron/main/code/diff/remove.ts index 51a09b49..6e29dab6 100644 --- a/app/electron/main/code/diff/remove.ts +++ b/app/electron/main/code/diff/remove.ts @@ -1,14 +1,13 @@ import { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; +import { jsxFilter } from './helpers'; import { assertNever } from '/common/helpers'; import { InsertPos } from '/common/models'; import { CodeAction } from '/common/models/actions/code'; export function removeElementFromNode(path: NodePath, element: CodeAction): void { const children = path.node.children; - const jsxElements = children.filter( - (child) => t.isJSXElement(child) || t.isJSXFragment(child), - ) as Array; + const jsxElements = children.filter(jsxFilter); switch (element.location.position) { case InsertPos.INDEX: @@ -27,7 +26,7 @@ export function removeElementFromNode(path: NodePath, element: Cod path.stop(); } -function removeElementAtIndex( +export function removeElementAtIndex( index: number, jsxElements: Array, children: t.Node[], diff --git a/app/electron/main/code/diff/transform.ts b/app/electron/main/code/diff/transform.ts index 508ea1c7..dd917e32 100644 --- a/app/electron/main/code/diff/transform.ts +++ b/app/electron/main/code/diff/transform.ts @@ -1,6 +1,7 @@ import traverse, { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import { getTemplateNode } from '../templateNode'; +import { groupElementsInNode, ungroupElementsInNode } from './group'; import { createHashedTemplateToCodeDiff, hashTemplateNode } from './helpers'; import { insertElementToNode } from './insert'; import { moveElementInNode } from './move'; @@ -39,6 +40,8 @@ export function transformAst( ...codeDiffRequest.insertedElements, ...codeDiffRequest.movedElements, ...codeDiffRequest.removedElements, + ...codeDiffRequest.groupElements, + ...codeDiffRequest.ungroupElements, ]; applyStructureChanges(path, structureChangeElements); } @@ -58,6 +61,12 @@ function applyStructureChanges(path: NodePath, elements: CodeActio case CodeActionType.REMOVE: removeElementFromNode(path, element); break; + case CodeActionType.GROUP: + groupElementsInNode(path, element); + break; + case CodeActionType.UNGROUP: + ungroupElementsInNode(path, element); + break; default: assertNever(element); } diff --git a/app/electron/preload/webview/api.ts b/app/electron/preload/webview/api.ts index 80c085f1..c690099c 100644 --- a/app/electron/preload/webview/api.ts +++ b/app/electron/preload/webview/api.ts @@ -2,7 +2,7 @@ import { contextBridge } from 'electron'; import { processDom } from './dom'; import { getElementAtLoc, getElementWithSelector } from './elements'; import { copyElementBySelector } from './elements/dom/copy'; -import { getActionElementLocation } from './elements/dom/helpers'; +import { getActionElementBySelector, getActionElementLocation } from './elements/dom/helpers'; import { getInsertLocation } from './elements/dom/insert'; import { getRemoveActionFromSelector } from './elements/dom/remove'; import { isElementInserted } from './elements/helpers'; @@ -21,6 +21,7 @@ export function setApi() { getComputedStyleBySelector: getComputedStyleBySelector, copyElementBySelector: copyElementBySelector, getActionElementLocation: getActionElementLocation, + getActionElementBySelector: getActionElementBySelector, // Theme getTheme: getTheme, diff --git a/app/electron/preload/webview/elements/dom/group.ts b/app/electron/preload/webview/elements/dom/group.ts new file mode 100644 index 00000000..2ebaf14c --- /dev/null +++ b/app/electron/preload/webview/elements/dom/group.ts @@ -0,0 +1,76 @@ +import { getDomElement } from '../helpers'; +import { createElement } from './insert'; +import { EditorAttributes } from '/common/constants'; +import { getUniqueSelector } from '/common/helpers'; +import { ActionElement, ActionElementLocation, GroupActionTarget } from '/common/models/actions'; +import { DomElement } from '/common/models/element'; + +export function groupElements( + targets: Array, + location: ActionElementLocation, + container: ActionElement, +): DomElement | null { + const parentEl: HTMLElement | null = document.querySelector(location.targetSelector); + if (!parentEl) { + console.error('Failed to find parent element', location.targetSelector); + return null; + } + + const containerEl = createElement(container); + parentEl.insertBefore(containerEl, parentEl.children[location.index]); + + targets + .map((target) => { + const el = document.querySelector(target.selector); + if (!el) { + console.error('Failed to find element', target.selector); + return null; + } + return el; + }) + .filter((el) => el !== null) + .sort((a, b) => { + return ( + Array.from(parentEl.children).indexOf(a) - Array.from(parentEl.children).indexOf(b) + ); + }) + .forEach((el) => { + containerEl.appendChild(el.cloneNode(true)); + (el as HTMLElement).style.display = 'none'; + }); + + return getDomElement(parentEl, true); +} + +export function ungroupElements( + targets: Array, + location: ActionElementLocation, + container: ActionElement, +): DomElement | null { + const parentEl: HTMLElement | null = document.querySelector(location.targetSelector); + if (!parentEl) { + console.error('Failed to find parent element', location.targetSelector); + return null; + } + + const containerEl: HTMLElement | null = document.querySelector(container.selector); + + if (!containerEl) { + console.error('Failed to find group element', container.selector); + return null; + } + + containerEl.style.display = 'none'; + const groupChildren = Array.from(containerEl.children); + + groupChildren.forEach((child) => { + const selector = getUniqueSelector(child as HTMLElement); + const target = targets.find((t) => t.selector === selector); + if (target) { + child.setAttribute(EditorAttributes.DATA_ONLOOK_INSERTED, 'true'); + parentEl.insertBefore(child, parentEl.children[target.index]); + } + }); + + return getDomElement(parentEl, true); +} diff --git a/app/electron/preload/webview/elements/dom/helpers.ts b/app/electron/preload/webview/elements/dom/helpers.ts index 5a462976..30c35e59 100644 --- a/app/electron/preload/webview/elements/dom/helpers.ts +++ b/app/electron/preload/webview/elements/dom/helpers.ts @@ -4,6 +4,16 @@ import { getUniqueSelector } from '/common/helpers'; import { InsertPos } from '/common/models'; import { ActionElement, ActionElementLocation } from '/common/models/actions'; +export function getActionElementBySelector(selector: string): ActionElement | null { + const el = document.querySelector(selector) as HTMLElement; + if (!el) { + console.error('Element not found for selector:', selector); + return null; + } + + return getActionElement(el); +} + export function getActionElement(el: HTMLElement): ActionElement { return { tagName: el.tagName.toLowerCase(), diff --git a/app/electron/preload/webview/elements/dom/insert.ts b/app/electron/preload/webview/elements/dom/insert.ts index 75f3a457..ecbffb57 100644 --- a/app/electron/preload/webview/elements/dom/insert.ts +++ b/app/electron/preload/webview/elements/dom/insert.ts @@ -76,7 +76,7 @@ export function insertElement( return domEl; } -function createElement(element: ActionElement) { +export function createElement(element: ActionElement) { const newEl = document.createElement(element.tagName); newEl.setAttribute(EditorAttributes.DATA_ONLOOK_INSERTED, 'true'); newEl.removeAttribute(EditorAttributes.DATA_ONLOOK_ID); diff --git a/app/electron/preload/webview/elements/helpers.ts b/app/electron/preload/webview/elements/helpers.ts index d45dd24c..861727cd 100644 --- a/app/electron/preload/webview/elements/helpers.ts +++ b/app/electron/preload/webview/elements/helpers.ts @@ -36,7 +36,6 @@ export function getOrAssignUuid(el: HTMLElement): string { return id; } - // Assign new ID id = uuid(); el.setAttribute(EditorAttributes.DATA_ONLOOK_UNIQUE_ID, id); return id; diff --git a/app/electron/preload/webview/events/index.ts b/app/electron/preload/webview/events/index.ts index b9c0c049..7df6482b 100644 --- a/app/electron/preload/webview/events/index.ts +++ b/app/electron/preload/webview/events/index.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron'; import { processDom } from '../dom'; +import { groupElements, ungroupElements } from '../elements/dom/group'; import { insertElement, removeElement } from '../elements/dom/insert'; import { moveElement } from '../elements/move'; import { clearTextEditedElements, editTextBySelector } from '../elements/text'; @@ -12,7 +13,7 @@ import { publishRemoveElement, } from './publish'; import { WebviewChannels } from '/common/constants'; -import { ActionElement, ActionElementLocation } from '/common/models/actions'; +import { ActionElement, ActionElementLocation, GroupActionTarget } from '/common/models/actions'; export function listenForEvents() { listenForWindowEvents(); @@ -75,6 +76,30 @@ function listenForEditEvents() { } }); + ipcRenderer.on(WebviewChannels.GROUP_ELEMENTS, (_, data) => { + const { targets, location, container } = data as { + targets: Array; + location: ActionElementLocation; + container: ActionElement; + }; + const domEl = groupElements(targets, location, container); + if (domEl) { + publishMoveElement(domEl); + } + }); + + ipcRenderer.on(WebviewChannels.UNGROUP_ELEMENTS, (_, data) => { + const { targets, location, container } = data as { + targets: Array; + location: ActionElementLocation; + container: ActionElement; + }; + const domEl = ungroupElements(targets, location, container); + if (domEl) { + publishMoveElement(domEl); + } + }); + ipcRenderer.on(WebviewChannels.CLEAN_AFTER_WRITE_TO_CODE, () => { clearTextEditedElements(); processDom(); diff --git a/app/src/components/ui/alert-dialog.tsx b/app/src/components/ui/alert-dialog.tsx index 7afab621..2df9294d 100644 --- a/app/src/components/ui/alert-dialog.tsx +++ b/app/src/components/ui/alert-dialog.tsx @@ -1,8 +1,8 @@ -import * as React from 'react'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import * as React from 'react'; -import { cn } from '@/lib/utils'; import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; const AlertDialog = AlertDialogPrimitive.Root; @@ -103,14 +103,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, + AlertDialogAction, + AlertDialogCancel, AlertDialogContent, - AlertDialogHeader, + AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, + AlertDialogTrigger, }; diff --git a/app/src/lib/editor/engine/action/index.ts b/app/src/lib/editor/engine/action/index.ts index b4e08f40..1b832da9 100644 --- a/app/src/lib/editor/engine/action/index.ts +++ b/app/src/lib/editor/engine/action/index.ts @@ -5,9 +5,11 @@ import { assertNever } from '/common/helpers'; import { Action, EditTextAction, + GroupElementsAction, InsertElementAction, MoveElementAction, RemoveElementAction, + UngroupElementsAction, UpdateStyleAction, } from '/common/models/actions'; @@ -56,6 +58,12 @@ export class ActionManager { case 'edit-text': this.editText(action); break; + case 'group-elements': + this.groupElements(action); + break; + case 'ungroup-elements': + this.ungroupElements(action); + break; default: assertNever(action); } @@ -134,4 +142,24 @@ export class ActionManager { }); }); } + + private groupElements({ targets, location, webviewId, container }: GroupElementsAction) { + const webview = this.editorEngine.webviews.getWebview(webviewId); + if (!webview) { + console.error('Failed to get webview'); + return; + } + const payload = JSON.parse(JSON.stringify({ targets, location, container })); + webview.send(WebviewChannels.GROUP_ELEMENTS, payload); + } + + private ungroupElements({ targets, location, webviewId, container }: UngroupElementsAction) { + const webview = this.editorEngine.webviews.getWebview(webviewId); + if (!webview) { + console.error('Failed to get webview'); + return; + } + const payload = JSON.parse(JSON.stringify({ targets, location, container })); + webview.send(WebviewChannels.UNGROUP_ELEMENTS, payload); + } } diff --git a/app/src/lib/editor/engine/code/group.ts b/app/src/lib/editor/engine/code/group.ts new file mode 100644 index 00000000..4685f482 --- /dev/null +++ b/app/src/lib/editor/engine/code/group.ts @@ -0,0 +1,31 @@ +import { getInsertedElement } from './insert'; +import { ActionElement, ActionElementLocation, GroupActionTarget } from '/common/models/actions'; +import { CodeActionType, CodeGroup, CodeUngroup } from '/common/models/actions/code'; + +export function getGroupElement( + targets: Array, + location: ActionElementLocation, + container: ActionElement, +): CodeGroup { + const containerInsert = getInsertedElement(container, location); + + return { + type: CodeActionType.GROUP, + location, + container: containerInsert, + targets, + uuid: container.uuid, + }; +} + +export function getUngroupElement( + targets: Array, + location: ActionElementLocation, + container: ActionElement, +): CodeUngroup { + const groupElement = getGroupElement(targets, location, container); + return { + ...groupElement, + type: CodeActionType.UNGROUP, + }; +} diff --git a/app/src/lib/editor/engine/code/helpers.ts b/app/src/lib/editor/engine/code/helpers.ts index 61835849..3da6942a 100644 --- a/app/src/lib/editor/engine/code/helpers.ts +++ b/app/src/lib/editor/engine/code/helpers.ts @@ -16,6 +16,8 @@ export async function getOrCreateCodeDiffRequest( insertedElements: [], movedElements: [], removedElements: [], + groupElements: [], + ungroupElements: [], attributes: {}, }; templateToCodeChange.set(templateNode, diffRequest); diff --git a/app/src/lib/editor/engine/code/index.ts b/app/src/lib/editor/engine/code/index.ts index b991fe41..6a054845 100644 --- a/app/src/lib/editor/engine/code/index.ts +++ b/app/src/lib/editor/engine/code/index.ts @@ -1,6 +1,7 @@ import { sendAnalytics } from '@/lib/utils'; import { makeAutoObservable } from 'mobx'; import { EditorEngine } from '..'; +import { getGroupElement, getUngroupElement } from './group'; import { getOrCreateCodeDiffRequest, getTailwindClassChangeFromStyle } from './helpers'; import { getInsertedElement } from './insert'; import { getRemovedElement } from './remove'; @@ -9,18 +10,22 @@ import { assertNever } from '/common/helpers'; import { Action, EditTextAction, + GroupElementsAction, InsertElementAction, MoveElementAction, RemoveElementAction, + UngroupElementsAction, UpdateStyleAction, } from '/common/models/actions'; import { CodeActionType, CodeEditText, + CodeGroup, CodeInsert, CodeMove, CodeRemove, CodeStyle, + CodeUngroup, } from '/common/models/actions/code'; import { CodeDiff, CodeDiffRequest } from '/common/models/code'; import { TemplateNode } from '/common/models/element/templateNode'; @@ -97,6 +102,12 @@ export class CodeManager { case 'edit-text': await this.writeEditText(action); break; + case 'group-elements': + this.writeGroup(action); + break; + case 'ungroup-elements': + this.writeUngroup(action); + break; default: assertNever(action); } @@ -150,7 +161,6 @@ export class CodeManager { uuid: target.uuid, }); } - const requests = await this.getCodeDiffRequests({ movedEls }); const res = await this.getAndWriteCodeDiff(requests); if (res) { @@ -161,18 +171,36 @@ export class CodeManager { private async writeEditText({ targets, newContent }: EditTextAction) { const textEditEls: CodeEditText[] = []; - for (const target of targets) { textEditEls.push({ selector: target.selector, content: newContent, }); } - const requestMap = await this.getCodeDiffRequests({ textEditEls }); this.getAndWriteCodeDiff(requestMap); } + private async writeGroup(action: GroupElementsAction) { + const groupEl = getGroupElement(action.targets, action.location, action.container); + const requests = await this.getCodeDiffRequests({ groupEls: [groupEl] }); + const res = await this.getAndWriteCodeDiff(requests); + if (res) { + requests.forEach((request) => this.filesToCleanQueue.add(request.templateNode.path)); + this.debounceKeyCleanup(); + } + } + + private async writeUngroup(action: UngroupElementsAction) { + const ungroupEl = getUngroupElement(action.targets, action.location, action.container); + const requests = await this.getCodeDiffRequests({ ungroupEls: [ungroupEl] }); + const res = await this.getAndWriteCodeDiff(requests); + if (res) { + requests.forEach((request) => this.filesToCleanQueue.add(request.templateNode.path)); + this.debounceKeyCleanup(); + } + } + private async getAndWriteCodeDiff(requests: CodeDiffRequest[]) { const codeDiffs = await this.getCodeDiff(requests); const res = await window.api.invoke(MainChannels.WRITE_CODE_BLOCKS, codeDiffs); @@ -197,12 +225,16 @@ export class CodeManager { removedEls, movedEls, textEditEls, + groupEls, + ungroupEls, }: { styleChanges?: CodeStyle[]; insertedEls?: CodeInsert[]; removedEls?: CodeRemove[]; movedEls?: CodeMove[]; textEditEls?: CodeEditText[]; + groupEls?: CodeGroup[]; + ungroupEls?: CodeUngroup[]; }): Promise { const templateToRequest = new Map(); await this.processStyleChanges(styleChanges || [], templateToRequest); @@ -210,6 +242,8 @@ export class CodeManager { await this.processMovedElements(movedEls || [], templateToRequest); await this.processTextEditElements(textEditEls || [], templateToRequest); await this.processRemovedElements(removedEls || [], templateToRequest); + await this.processGroupElements(groupEls || [], templateToRequest); + await this.processUngroupElements(ungroupEls || [], templateToRequest); return Array.from(templateToRequest.values()); } @@ -233,7 +267,7 @@ export class CodeManager { templateToCodeChange: Map, ): Promise { for (const change of styleChanges) { - const templateNode = await this.getTemplateNodeForSelector(change.selector); + const templateNode = this.editorEngine.ast.getAnyTemplateNode(change.selector); if (!templateNode) { continue; } @@ -252,7 +286,7 @@ export class CodeManager { templateToCodeChange: Map, ): Promise { for (const insertedEl of insertedEls) { - const targetTemplateNode = await this.getTemplateNodeForSelector( + const targetTemplateNode = this.editorEngine.ast.getAnyTemplateNode( insertedEl.location.targetSelector, ); if (!targetTemplateNode) { @@ -273,7 +307,7 @@ export class CodeManager { templateToCodeChange: Map, ): Promise { for (const removedEl of removedEls) { - const targetTemplateNode = await this.getTemplateNodeForSelector( + const targetTemplateNode = this.editorEngine.ast.getAnyTemplateNode( removedEl.location.targetSelector, ); if (!targetTemplateNode) { @@ -294,7 +328,7 @@ export class CodeManager { templateToCodeChange: Map, ): Promise { for (const movedEl of movedEls) { - const parentTemplateNode = await this.getTemplateNodeForSelector( + const parentTemplateNode = this.editorEngine.ast.getAnyTemplateNode( movedEl.location.targetSelector, ); if (!parentTemplateNode) { @@ -306,7 +340,7 @@ export class CodeManager { movedEl.location.targetSelector, templateToCodeChange, ); - const childTemplateNode = await this.getTemplateNodeForSelector(movedEl.selector); + const childTemplateNode = this.editorEngine.ast.getAnyTemplateNode(movedEl.selector); if (!childTemplateNode) { continue; } @@ -320,7 +354,7 @@ export class CodeManager { templateToCodeChange: Map, ) { for (const textEl of textEditEls) { - const templateNode = await this.getTemplateNodeForSelector(textEl.selector); + const templateNode = this.editorEngine.ast.getAnyTemplateNode(textEl.selector); if (!templateNode) { continue; } @@ -334,7 +368,45 @@ export class CodeManager { } } - private async getTemplateNodeForSelector(selector: string): Promise { - return this.editorEngine.ast.getAnyTemplateNode(selector); + private async processGroupElements( + groupEls: CodeGroup[], + templateToCodeChange: Map, + ) { + for (const groupEl of groupEls) { + const templateNode = this.editorEngine.ast.getAnyTemplateNode( + groupEl.location.targetSelector, + ); + if (!templateNode) { + continue; + } + + const request = await getOrCreateCodeDiffRequest( + templateNode, + groupEl.location.targetSelector, + templateToCodeChange, + ); + request.groupElements.push(groupEl); + } + } + + private async processUngroupElements( + ungroupEls: CodeUngroup[], + templateToCodeChange: Map, + ) { + for (const ungroupEl of ungroupEls) { + const templateNode = this.editorEngine.ast.getAnyTemplateNode( + ungroupEl.location.targetSelector, + ); + if (!templateNode) { + continue; + } + + const request = await getOrCreateCodeDiffRequest( + templateNode, + ungroupEl.location.targetSelector, + templateToCodeChange, + ); + request.ungroupElements.push(ungroupEl); + } } } diff --git a/app/src/lib/editor/engine/group/index.ts b/app/src/lib/editor/engine/group/index.ts new file mode 100644 index 00000000..a501a5c1 --- /dev/null +++ b/app/src/lib/editor/engine/group/index.ts @@ -0,0 +1,219 @@ +import { WebviewTag } from 'electron'; +import { nanoid } from 'nanoid'; +import { EditorEngine } from '..'; +import { EditorAttributes } from '/common/constants'; +import { escapeSelector } from '/common/helpers'; +import { InsertPos } from '/common/models'; +import { + ActionElement, + ActionElementLocation, + GroupActionTarget, + GroupElementsAction, + UngroupElementsAction, +} from '/common/models/actions'; +import { WebViewElement } from '/common/models/element'; + +export class GroupManager { + constructor(private editorEngine: EditorEngine) {} + + async groupSelectedElements() { + const selectedEls = this.editorEngine.elements.selected; + if (!this.canGroupElements(selectedEls)) { + console.error('Cannot group elements'); + return; + } + + const groupAction = await this.getGroupAction(selectedEls); + if (!groupAction) { + console.error('Failed to get group action'); + return; + } + + this.editorEngine.action.run(groupAction); + } + + async ungroupSelectedElement() { + const selectedEls = this.editorEngine.elements.selected; + if (selectedEls.length !== 1) { + console.error('Can only ungroup one element at a time'); + return; + } + + const ungroupAction = await this.getUngroupAction(selectedEls[0]); + if (!ungroupAction) { + console.error('Failed to get ungroup action'); + return; + } + + this.editorEngine.action.run(ungroupAction); + } + + canGroupElements(elements: WebViewElement[]) { + if (elements.length === 0) { + return false; + } + if (elements.length === 1) { + return true; + } + + const sameWebview = elements.every((el) => el.webviewId === elements[0].webviewId); + + if (!sameWebview) { + return false; + } + + const parentSelector = elements[0].parent?.selector; + if (!parentSelector) { + return false; + } + + const sameParent = elements.every((el) => el.parent?.selector === parentSelector); + if (!sameParent) { + return false; + } + + return true; + } + + async getGroupAction(selectedEls: WebViewElement[]): Promise { + const webview = this.editorEngine.webviews.getWebview(selectedEls[0].webviewId); + if (!webview) { + console.error('Failed to get webview'); + return null; + } + + const targets = await this.getGroupTargets(selectedEls, webview); + if (targets.length === 0) { + console.error('No group targets found'); + return null; + } + + const parentSelector = selectedEls[0].parent!.selector; + const container = await this.getContainerElement(parentSelector, webview); + + return { + type: 'group-elements', + targets: targets, + location: { + position: InsertPos.INDEX, + targetSelector: parentSelector, + index: Math.min(...targets.map((t) => t.index)), + }, + webviewId: webview.id, + container, + }; + } + + async getUngroupAction(selectedEl: WebViewElement): Promise { + const webview = this.editorEngine.webviews.getWebview(selectedEl.webviewId); + if (!webview) { + console.error('Failed to get webview'); + return null; + } + + const parentSelector = selectedEl.parent?.selector; + if (!parentSelector) { + console.error('Failed to get parent selector'); + return null; + } + + // Container is the selectedEl + const container: ActionElement | null = await webview.executeJavaScript( + `window.api?.getActionElementBySelector('${escapeSelector(selectedEl.selector)}', true)`, + ); + if (!container) { + console.error('Failed to get container element'); + return null; + } + + // Where container will be removed + const location: ActionElementLocation | null = await webview.executeJavaScript( + `window.api?.getActionElementLocation('${escapeSelector(selectedEl.selector)}')`, + ); + + if (!location) { + console.error('Failed to get location'); + return null; + } + + // Children to be spread where container was + const targets: GroupActionTarget[] = container.children.map((child, index) => { + const newIndex = location.index + index; + return { + webviewId: selectedEl.webviewId, + selector: child.selector, + uuid: child.uuid, + index: newIndex, + }; + }); + + return { + type: 'ungroup-elements', + targets, + location, + webviewId: webview.id, + container, + }; + } + + async getGroupTargets( + selectedEls: WebViewElement[], + webview: WebviewTag, + ): Promise { + const targets: GroupActionTarget[] = []; + + for (const el of selectedEls) { + const originalIndex: number | undefined = (await webview.executeJavaScript( + `window.api?.getElementIndex('${escapeSelector(el.selector)}')`, + )) as number | undefined; + + if (originalIndex === undefined) { + console.error('Failed to get element location'); + continue; + } + + targets.push({ + ...el, + index: originalIndex, + }); + } + return targets; + } + + async getContainerElement(parentSelector: string, webview: WebviewTag): Promise { + const parentDomEl = await webview.executeJavaScript( + `window.api?.getElementWithSelector('${escapeSelector(parentSelector)}', true)`, + ); + + const styles: Record = { + // Layout + display: parentDomEl.styles.display, + + // Flex + flexDirection: parentDomEl.styles.flexDirection, + justifyContent: parentDomEl.styles.justifyContent, + alignItems: parentDomEl.styles.alignItems, + gap: parentDomEl.styles.gap, + + // Grid + gridTemplateColumns: parentDomEl.styles.gridTemplateColumns, + gridTemplateRows: parentDomEl.styles.gridTemplateRows, + }; + + const uuid = nanoid(); + const selector = `[${EditorAttributes.DATA_ONLOOK_UNIQUE_ID}="${uuid}"]`; + const container: ActionElement = { + selector, + uuid, + styles, + tagName: 'div', + children: [], + attributes: { + [EditorAttributes.DATA_ONLOOK_UNIQUE_ID]: uuid, + [EditorAttributes.DATA_ONLOOK_INSERTED]: 'true', + }, + }; + + return container; + } +} diff --git a/app/src/lib/editor/engine/history/index.ts b/app/src/lib/editor/engine/history/index.ts index 5d75c6fc..8681d25a 100644 --- a/app/src/lib/editor/engine/history/index.ts +++ b/app/src/lib/editor/engine/history/index.ts @@ -50,6 +50,16 @@ function undoAction(action: Action): Action { originalContent: action.newContent, newContent: action.originalContent, }; + case 'group-elements': + return { + ...action, + type: 'ungroup-elements', + }; + case 'ungroup-elements': + return { + ...action, + type: 'group-elements', + }; default: assertNever(action); } diff --git a/app/src/lib/editor/engine/index.ts b/app/src/lib/editor/engine/index.ts index 163b7880..72874703 100644 --- a/app/src/lib/editor/engine/index.ts +++ b/app/src/lib/editor/engine/index.ts @@ -9,6 +9,7 @@ import { CodeManager } from './code'; import { CopyManager } from './copy'; import { DomManager } from './dom'; import { ElementManager } from './element'; +import { GroupManager } from './group'; import { HistoryManager } from './history'; import { InsertManager } from './insert'; import { MoveManager } from './move'; @@ -18,7 +19,6 @@ import { StyleManager } from './style'; import { TextEditingManager } from './text'; import { WebviewManager } from './webview'; import { MainChannels } from '/common/constants'; -import { escapeSelector } from '/common/helpers'; export class EditorEngine { private editorMode: EditorMode = EditorMode.DESIGN; @@ -37,6 +37,7 @@ export class EditorEngine { private moveManager: MoveManager = new MoveManager(this); private styleManager: StyleManager = new StyleManager(this); private copyManager: CopyManager = new CopyManager(this); + private groupManager: GroupManager = new GroupManager(this); constructor(private projectsManager: ProjectsManager) { makeAutoObservable(this); @@ -91,6 +92,9 @@ export class EditorEngine { get copy() { return this.copyManager; } + get group() { + return this.groupManager; + } set mode(mode: EditorMode) { this.editorMode = mode; } diff --git a/app/src/routes/editor/Canvas/Hotkeys/index.tsx b/app/src/routes/editor/Canvas/Hotkeys/index.tsx index 3d29d779..5ffed8a2 100644 --- a/app/src/routes/editor/Canvas/Hotkeys/index.tsx +++ b/app/src/routes/editor/Canvas/Hotkeys/index.tsx @@ -39,6 +39,10 @@ const HotkeysArea = ({ children, scale, setScale }: HotkeysAreaProps) => { useHotkeys(Hotkey.REDO.command, () => editorEngine.action.redo()); useHotkeys(Hotkey.ENTER.command, () => editorEngine.text.editSelectedElement()); + // TODO: Add toast on false returned + useHotkeys(Hotkey.GROUP.command, () => editorEngine.group.groupSelectedElements()); + useHotkeys(Hotkey.UNGROUP.command, () => editorEngine.group.ungroupSelectedElement()); + // Copy useHotkeys(Hotkey.COPY.command, () => editorEngine.copy.copy()); useHotkeys(Hotkey.PASTE.command, () => editorEngine.copy.paste()); diff --git a/app/src/routes/editor/EditPanel/inputs/single/TailwindInput.tsx b/app/src/routes/editor/EditPanel/inputs/single/TailwindInput.tsx index 8a64d6a9..d9babd2b 100644 --- a/app/src/routes/editor/EditPanel/inputs/single/TailwindInput.tsx +++ b/app/src/routes/editor/EditPanel/inputs/single/TailwindInput.tsx @@ -54,6 +54,8 @@ const TailwindInput = observer(() => { insertedElements: [], movedElements: [], removedElements: [], + groupElements: [], + ungroupElements: [], overrideClasses: true, }; const codeDiffs = await editorEngine.code.getCodeDiff([request]); diff --git a/app/src/routes/projects/ProjectsTab/Create/New/Run.tsx b/app/src/routes/projects/ProjectsTab/Create/New/Run.tsx index 765b67ad..a4e6c7f3 100644 --- a/app/src/routes/projects/ProjectsTab/Create/New/Run.tsx +++ b/app/src/routes/projects/ProjectsTab/Create/New/Run.tsx @@ -19,7 +19,7 @@ export const NewRunProject = ({ }) => { const [isRunning, setIsRunning] = useState(false); const [hasCopied, setHasCopied] = useState(false); - + const platformCommand = process.platform === 'win32' ? 'cd /d' : 'cd'; const codeContent = `${platformCommand} ${projectData.folderPath} && npm run dev`;