Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow grouping divs #546

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions app/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions app/common/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 9 additions & 1 deletion app/common/models/actions/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum CodeActionType {
MOVE = 'move',
INSERT = 'insert',
REMOVE = 'remove',
GROUP = 'group',
}

interface BaseCodeAction {
Expand Down Expand Up @@ -51,4 +52,11 @@ export interface CodeStyle {
styles: Record<string, string>;
}

export type CodeAction = CodeMove | CodeInsert | CodeRemove;
export interface CodeGroup extends BaseCodeAction {
type: CodeActionType.GROUP;
location: ActionElementLocation;
container: CodeInsert;
targets: ActionElementLocation[];
}

export type CodeAction = CodeMove | CodeInsert | CodeRemove | CodeGroup;
23 changes: 21 additions & 2 deletions app/common/models/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface StyleActionTarget extends ActionTarget {
change: Change<string>;
}

export interface GroupActionTarget extends ActionTarget {
index: number;
}

export interface ActionElementLocation {
position: InsertPos;
targetSelector: string;
Expand All @@ -23,7 +27,6 @@ export interface ActionElementLocation {

export interface MoveActionLocation extends ActionElementLocation {
originalIndex: number;
index: number;
}

export interface ActionElement {
Expand Down Expand Up @@ -72,9 +75,25 @@ export interface EditTextAction {
newContent: string;
}

export interface BaseGroupAction {
targets: Array<GroupActionTarget>;
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;
3 changes: 2 additions & 1 deletion app/common/models/code.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CodeInsert, CodeMove, CodeRemove } from './actions/code';
import { CodeGroup, CodeInsert, CodeMove, CodeRemove } from './actions/code';
import { TemplateNode } from './element/templateNode';

export interface CodeDiffRequest {
Expand All @@ -7,6 +7,7 @@ export interface CodeDiffRequest {
insertedElements: CodeInsert[];
removedElements: CodeRemove[];
movedElements: CodeMove[];
groupElements: CodeGroup[];
attributes: Record<string, string>;
textContent?: string;
overrideClasses?: boolean;
Expand Down
71 changes: 71 additions & 0 deletions app/electron/main/code/diff/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NodePath } from '@babel/traverse';
import * as t from '@babel/types';
import { jsxFilter } from './helpers';
import { createInsertedElement, insertAtIndex } from './insert';
import { removeElementAtIndex } from './remove';
import { CodeGroup } from '/common/models/actions/code';

export function groupElementsInNode(path: NodePath<t.JSXElement>, element: CodeGroup): void {
// Get target elements
const children = path.node.children;
const jsxElements = children.filter(jsxFilter);
const targetIndices = element.targets.map((target) => target.index).sort();
const targetElements = getElementsAtIndices(targetIndices, jsxElements);

// 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();
}

function getElementsAtIndices(indices: number[], jsxElements: Array<t.JSXElement | t.JSXFragment>) {
return indices.map((index) => jsxElements[index]);
}

/**
*
* groupElementsInNode {
type: 'group',
location: {
position: 'index',
targetSelector: '[data-onlook-unique-id="3d5eaa83-7c7c-426d-99c6-7bc4de23cd69"]',
index: 0
},
container: {
type: 'insert',
tagName: 'div',
children: [],
attributes: {
className: 'flex static flex-row justify-center items-end gap-40 grid-cols-none grid-rows-none'
},
location: {
position: 'index',
targetSelector: '[data-onlook-unique-id="3d5eaa83-7c7c-426d-99c6-7bc4de23cd69"]',
index: 0
},
uuid: 'a7q1LTcRt90ooEXuE22Jx'
},
targets: [
{
position: 'index',
targetSelector: '[data-onlook-unique-id="3d5eaa83-7c7c-426d-99c6-7bc4de23cd69"]',
index: 0
},
{
position: 'index',
targetSelector: '[data-onlook-unique-id="3d5eaa83-7c7c-426d-99c6-7bc4de23cd69"]',
index: 1
}
],
uuid: 'a7q1LTcRt90ooEXuE22Jx'
}
*/
4 changes: 4 additions & 0 deletions app/electron/main/code/diff/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,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);
10 changes: 4 additions & 6 deletions app/electron/main/code/diff/insert.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -28,7 +28,7 @@ export function insertElementToNode(path: NodePath<t.JSXElement>, 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);
Expand Down Expand Up @@ -78,16 +78,14 @@ function createJSXElement(insertedChild: CodeInsert): t.JSXElement {
return t.jsxElement(openingElement, closingElement, children, isSelfClosing);
}

function insertAtIndex(
export function insertAtIndex(
path: NodePath<t.JSXElement>,
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);

Expand Down
6 changes: 2 additions & 4 deletions app/electron/main/code/diff/move.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
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<t.JSXElement>, 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);

Expand Down
7 changes: 3 additions & 4 deletions app/electron/main/code/diff/remove.ts
Original file line number Diff line number Diff line change
@@ -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<t.JSXElement>, element: CodeAction): void {
const children = path.node.children;
const jsxElements = children.filter(
(child) => t.isJSXElement(child) || t.isJSXFragment(child),
) as Array<t.JSXElement | t.JSXFragment>;
const jsxElements = children.filter(jsxFilter);

switch (element.location.position) {
case InsertPos.INDEX:
Expand All @@ -27,7 +26,7 @@ export function removeElementFromNode(path: NodePath<t.JSXElement>, element: Cod
path.stop();
}

function removeElementAtIndex(
export function removeElementAtIndex(
index: number,
jsxElements: Array<t.JSXElement | t.JSXFragment>,
children: t.Node[],
Expand Down
5 changes: 5 additions & 0 deletions app/electron/main/code/diff/transform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import traverse, { NodePath } from '@babel/traverse';
import * as t from '@babel/types';
import { getTemplateNode } from '../templateNode';
import { groupElementsInNode } from './group';
import { createHashedTemplateToCodeDiff, hashTemplateNode } from './helpers';
import { insertElementToNode } from './insert';
import { moveElementInNode } from './move';
Expand Down Expand Up @@ -39,6 +40,7 @@ export function transformAst(
...codeDiffRequest.insertedElements,
...codeDiffRequest.movedElements,
...codeDiffRequest.removedElements,
...codeDiffRequest.groupElements,
];
applyStructureChanges(path, structureChangeElements);
}
Expand All @@ -58,6 +60,9 @@ function applyStructureChanges(path: NodePath<t.JSXElement>, elements: CodeActio
case CodeActionType.REMOVE:
removeElementFromNode(path, element);
break;
case CodeActionType.GROUP:
groupElementsInNode(path, element);
break;
default:
assertNever(element);
}
Expand Down
3 changes: 2 additions & 1 deletion app/electron/preload/webview/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +21,7 @@ export function setApi() {
getComputedStyleBySelector: getComputedStyleBySelector,
copyElementBySelector: copyElementBySelector,
getActionElementLocation: getActionElementLocation,
getActionElementBySelector: getActionElementBySelector,

// Theme
getTheme: getTheme,
Expand Down
75 changes: 75 additions & 0 deletions app/electron/preload/webview/elements/dom/group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { getDomElement } from '../helpers';
import { createElement } from './insert';
import { getUniqueSelector } from '/common/helpers';
import { ActionElement, ActionElementLocation, GroupActionTarget } from '/common/models/actions';
import { DomElement } from '/common/models/element';

export function groupElements(
targets: Array<GroupActionTarget>,
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 groupEl = createElement(container);
parentEl.insertBefore(groupEl, 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) => {
groupEl.appendChild(el.cloneNode(true));
(el as HTMLElement).style.display = 'none';
});

return getDomElement(groupEl, true);
}

export function ungroupElements(
targets: Array<GroupActionTarget>,
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 groupElement = document.querySelector(container.selector);

if (!groupElement) {
console.error('Failed to find group element', container.selector);
return null;
}

parentEl.removeChild(groupElement);

const groupChildren = Array.from(groupElement.children);

groupChildren.forEach((child) => {
const selector = getUniqueSelector(child as HTMLElement);
const target = targets.find((t) => t.selector === selector);
if (target) {
parentEl.insertBefore(child, parentEl.children[target.index]);
}
});

return getDomElement(parentEl, true);
}
10 changes: 10 additions & 0 deletions app/electron/preload/webview/elements/dom/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading