Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/lib/directus-frame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EditableStore } from './editable-store.ts';
import type { HighlightElementData, ConfirmData } from './types/directus.ts';
import type { SendAction, ReceiveData, SavedData } from './types/index.ts';

/**
Expand All @@ -10,6 +11,7 @@ export class DirectusFrame {

private origin: string | null = null;
private confirmed = false;
private aiEnabled = false;

constructor() {
if (DirectusFrame.SINGLETON) return DirectusFrame.SINGLETON;
Expand Down Expand Up @@ -44,13 +46,25 @@ export class DirectusFrame {
}

receive(event: MessageEvent) {
if (!this.origin || !this.sameOrigin(event.origin, this.origin)) return;
if (!this.origin || !this.sameOrigin(event.origin, this.origin)) {
return;
}

const { action, data }: ReceiveData = event.data;

if (action === 'confirm') this.confirmed = true;
if (action === 'confirm') this.receiveConfirmAction(data);
if (action === 'showEditableElements') this.receiveShowEditableElements(data);
if (action === 'saved') this.receiveSaved(data);
if (action === 'highlightElement') this.receiveHighlightElement(data);
}

private receiveConfirmAction(data: unknown) {
this.confirmed = true;
this.aiEnabled = !!(data as ConfirmData)?.aiEnabled;
}

isAiEnabled() {
return this.aiEnabled;
}

receiveConfirm() {
Expand Down Expand Up @@ -88,4 +102,21 @@ export class DirectusFrame {

window.location.reload();
}

private receiveHighlightElement(data: unknown) {
if (!data || typeof data !== 'object') {
EditableStore.highlightElement(null);
return;
}

const { key, collection, item, fields } = data as HighlightElementData;

if (key === null) {
EditableStore.highlightElement(null);
} else if (collection && item !== undefined) {
EditableStore.highlightElement(fields ? { collection, item, fields } : { collection, item });
} else if (typeof key === 'string') {
EditableStore.highlightElement({ key });
}
}
}
19 changes: 15 additions & 4 deletions src/lib/editable-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ export class EditableElement {
disabled = false;
onSaved: EditableElementOptions['onSaved'] = undefined;

private boundMouseenter: (e: MouseEvent) => void;
private boundMouseleave: (e: MouseEvent) => void;

constructor(element: HTMLElement) {
this.element = element;
this.element.addEventListener('mouseover', this.onMouseenter.bind(this));
this.element.addEventListener('mouseleave', this.onMouseleave.bind(this));
this.boundMouseenter = this.onMouseenter.bind(this);
this.boundMouseleave = this.onMouseleave.bind(this);
this.element.addEventListener('mouseenter', this.boundMouseenter);
this.element.addEventListener('mouseleave', this.boundMouseleave);

this.key = crypto.randomUUID();
this.editConfig = EditableElement.editAttrToObject(this.element.dataset[EditableElement.DATASET]!);
Expand All @@ -33,6 +38,7 @@ export class EditableElement {
this.overlayElement = new OverlayElement();
this.overlayElement.updateRect(this.rect);
this.overlayElement.editButton.addEventListener('click', this.onClickEdit.bind(this));
this.overlayElement.aiButton?.addEventListener('click', this.onClickAddToContext.bind(this));

// @ts-expect-error
this.rectObserver = observeRect(this.element, this.onObserveRect.bind(this));
Expand Down Expand Up @@ -105,14 +111,19 @@ export class EditableElement {
}

removeHoverListener() {
this.element.removeEventListener('mouseenter', this.onMouseenter.bind(this));
this.element.removeEventListener('mouseleave', this.onMouseleave.bind(this));
this.element.removeEventListener('mouseenter', this.boundMouseenter);
this.element.removeEventListener('mouseleave', this.boundMouseleave);
}

private onClickEdit() {
new DirectusFrame().send('edit', { key: this.key, editConfig: this.editConfig, rect: this.rect });
}

private onClickAddToContext(event: MouseEvent) {
event.stopPropagation();
new DirectusFrame().send('addToContext', { key: this.key, editConfig: this.editConfig, rect: this.rect });
}

private onMouseenter(event: MouseEvent) {
this.toggleItemHover(true, event);
}
Expand Down
43 changes: 43 additions & 0 deletions src/lib/editable-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EditableElement } from './editable-element.ts';
export class EditableStore {
private static items: EditableElement[] = [];
static highlightOverlayElements = false;
private static highlightedKey: string | null = null;

static getItem(element: Element) {
return EditableStore.items.find((item) => item.element === element);
Expand All @@ -12,6 +13,20 @@ export class EditableStore {
return EditableStore.items.find((item) => item.key === key);
}

static getItemByEditConfig(collection: string, item: string | number, fields?: string[]) {
return EditableStore.items.find((i) => {
if (i.editConfig.collection !== collection) return false;
if (String(i.editConfig.item) !== String(item)) return false;

const itemFields = i.editConfig.fields ?? [];
const targetFields = fields ?? [];

if (itemFields.length !== targetFields.length) return false;

return itemFields.every((f) => targetFields.includes(f));
});
}

static getHoveredItems() {
return EditableStore.items.filter((item) => item.hover);
}
Expand Down Expand Up @@ -64,4 +79,32 @@ export class EditableStore {
item.overlayElement.toggleHighlight(show);
});
}

static highlightElement(
identifier: { key: string } | { collection: string; item: string | number; fields?: string[] } | null,
) {
if (this.highlightedKey !== null) {
EditableStore.getItemByKey(this.highlightedKey)?.overlayElement.toggleHighlightActive(false);
}

if (identifier === null) {
this.highlightedKey = null;
return;
}

let element: EditableElement | undefined;

if ('key' in identifier) {
element = EditableStore.getItemByKey(identifier.key);
} else {
element = EditableStore.getItemByEditConfig(identifier.collection, identifier.item, identifier.fields);
}

if (element) {
this.highlightedKey = element.key;
element.overlayElement.toggleHighlightActive(true);
} else {
this.highlightedKey = null;
}
}
}
26 changes: 20 additions & 6 deletions src/lib/overlay-element.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { EditableStore } from './editable-store.ts';
import { OverlayManager } from './overlay-manager.ts';
import { DirectusFrame } from './directus-frame.ts';

export class OverlayElement {
private hasNoDimensions: boolean = false;
private element: HTMLElement;
private container: HTMLElement;

readonly editButton: HTMLButtonElement;
readonly aiButton: HTMLButtonElement | null;

constructor() {
this.container = this.createContainer();
this.element = this.createElement();
this.editButton = this.createEditButton();
this.aiButton = new DirectusFrame().isAiEnabled() ? this.createAiButton() : null;
this.createRectElement();

OverlayManager.getGlobalOverlay().appendChild(this.container);
Expand Down Expand Up @@ -43,11 +46,21 @@ export class OverlayElement {
private createEditButton() {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.classList.add(OverlayManager.RECT_BUTTON_CLASS_NAME);
editButton.classList.add(OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME);
this.element.appendChild(editButton);
return editButton;
}

private createAiButton() {
const aiButton = document.createElement('button');
aiButton.type = 'button';
aiButton.classList.add(OverlayManager.RECT_BUTTON_CLASS_NAME);
aiButton.classList.add(OverlayManager.RECT_AI_BUTTON_CLASS_NAME);
this.element.appendChild(aiButton);
return aiButton;
}

updateRect(rect: DOMRect) {
const hasDimensions = rect.width !== 0 && rect.height !== 0;

Expand Down Expand Up @@ -75,18 +88,19 @@ export class OverlayElement {
}

toggleHover(hover: boolean) {
if (hover) this.element.classList.add(OverlayManager.RECT_HOVER_CLASS_NAME);
else this.element.classList.remove(OverlayManager.RECT_HOVER_CLASS_NAME);
this.element.classList.toggle(OverlayManager.RECT_HOVER_CLASS_NAME, hover);
}

toggleParentHover(hover: boolean) {
if (hover) this.element.classList.add(OverlayManager.RECT_PARENT_HOVER_CLASS_NAME);
else this.element.classList.remove(OverlayManager.RECT_PARENT_HOVER_CLASS_NAME);
this.element.classList.toggle(OverlayManager.RECT_PARENT_HOVER_CLASS_NAME, hover);
}

toggleHighlight(show: boolean) {
if (show) this.element.classList.add(OverlayManager.RECT_HIGHLIGHT_CLASS_NAME);
else this.element.classList.remove(OverlayManager.RECT_HIGHLIGHT_CLASS_NAME);
this.element.classList.toggle(OverlayManager.RECT_HIGHLIGHT_CLASS_NAME, show);
}

toggleHighlightActive(show: boolean) {
this.element.classList.toggle(OverlayManager.RECT_HIGHLIGHT_ACTIVE_CLASS_NAME, show);
}

disable() {
Expand Down
71 changes: 49 additions & 22 deletions src/lib/overlay-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,34 @@ export class OverlayManager {
private static readonly CSS_VAR_BORDER_RADIUS = '--directus-visual-editing--rect--border-radius';
private static readonly CSS_VAR_HOVER_OPACITY = '--directus-visual-editing--rect-hover--opacity';
private static readonly CSS_VAR_HIGHLIGHT_OPACITY = '--directus-visual-editing--rect-highlight--opacity';
private static readonly CSS_VAR_ACTIONS_GAP = '--directus-visual-editing--actions--gap';
private static readonly CSS_VAR_BUTTON_WIDTH = '--directus-visual-editing--edit-btn--width';
private static readonly CSS_VAR_BUTTON_HEIGHT = '--directus-visual-editing--edit-btn--height';
private static readonly CSS_VAR_BUTTON_RADIUS = '--directus-visual-editing--edit-btn--radius';
private static readonly CSS_VAR_BUTTON_BG_COLOR = '--directus-visual-editing--edit-btn--bg-color';
private static readonly CSS_VAR_BUTTON_ICON_BG_IMAGE = '--directus-visual-editing--edit-btn--icon-bg-image';
private static readonly CSS_VAR_BUTTON_ICON_BG_SIZE = '--directus-visual-editing--edit-btn--icon-bg-size';
private static readonly CSS_VAR_EDIT_BUTTON_BG_COLOR = '--directus-visual-editing--edit-btn--bg-color';
private static readonly CSS_VAR_EDIT_BUTTON_ICON_BG_IMAGE = '--directus-visual-editing--edit-btn--icon-bg-image';
private static readonly CSS_VAR_EDIT_BUTTON_ICON_BG_SIZE = '--directus-visual-editing--edit-btn--icon-bg-size';
private static readonly CSS_VAR_AI_BUTTON_BG_COLOR = '--directus-visual-editing--ai-btn--bg-color';
private static readonly CSS_VAR_AI_BUTTON_ICON_BG_IMAGE = '--directus-visual-editing--ai-btn--icon-bg-image';
private static readonly CSS_VAR_AI_BUTTON_ICON_BG_SIZE = '--directus-visual-editing--ai-btn--icon-bg-size';

// For icons use https://fonts.google.com/icons?icon.set=Material+Icons&icon.color=%23ffffff
private static readonly ICON_EDIT = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="%23ffffff"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>')`;
private static readonly ICON_AI = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"><path fill="%23ffffff" d="M10 14.175L11 12l2.175-1L11 10l-1-2.175L9 10l-2.175 1L9 12l1 2.175ZM10 19l-2.5-5.5L2 11l5.5-2.5L10 3l2.5 5.5L18 11l-5.5 2.5L10 19Zm8 2l-1.25-2.75L14 17l2.75-1.25L18 13l1.25 2.75L22 17l-2.75 1.25L18 21Zm-8-10Z"/></svg>')`;

private static readonly OVERLAY_ID = 'directus-visual-editing';
private static readonly STYLE_ID = 'directus-visual-editing-style';

static readonly CONTAINER_RECT_CLASS_NAME = 'directus-visual-editing-overlay';
static readonly RECT_CLASS_NAME = 'directus-visual-editing-rect';
static readonly RECT_HIGHLIGHT_CLASS_NAME = 'directus-visual-editing-rect-highlight';
static readonly RECT_HIGHLIGHT_ACTIVE_CLASS_NAME = 'directus-visual-editing-rect-highlight-active';
static readonly RECT_PARENT_HOVER_CLASS_NAME = 'directus-visual-editing-rect-parent-hover';
static readonly RECT_HOVER_CLASS_NAME = 'directus-visual-editing-rect-hover';
static readonly RECT_INNER_CLASS_NAME = 'directus-visual-editing-rect-inner';
static readonly RECT_BUTTON_CLASS_NAME = 'directus-visual-editing-button';
static readonly RECT_EDIT_BUTTON_CLASS_NAME = 'directus-visual-editing-edit-button';
static readonly RECT_AI_BUTTON_CLASS_NAME = 'directus-visual-editing-ai-button';

static getGlobalOverlay(): HTMLElement {
const existingOverlay = document.getElementById(OverlayManager.OVERLAY_ID);
Expand All @@ -45,8 +53,13 @@ export class OverlayManager {
const style = document.createElement('style');
style.id = OverlayManager.STYLE_ID;

const buttonWidth = 28;
const borderSpacing = `var(${OverlayManager.CSS_VAR_BORDER_SPACING}, ${Math.round(buttonWidth * 0.333)}px)`;
const buttonSize = 28;
const buttonWidth = `var(${OverlayManager.CSS_VAR_BUTTON_WIDTH}, ${buttonSize}px)`;
const buttonGap = 4;
const editButtonBgColor = `var(${OverlayManager.CSS_VAR_EDIT_BUTTON_BG_COLOR}, #6644ff)`;
const aiButtonBgColor = `var(${OverlayManager.CSS_VAR_AI_BUTTON_BG_COLOR}, ${editButtonBgColor})`;
const buttonBgColorMix = '#2e3C43 25%';
const borderSpacing = `var(${OverlayManager.CSS_VAR_BORDER_SPACING}, ${Math.round(buttonSize * 0.333)}px)`;
const borderWidth = `var(${OverlayManager.CSS_VAR_BORDER_WIDTH}, 2px)`;

style.appendChild(
Expand Down Expand Up @@ -91,38 +104,52 @@ export class OverlayManager {
.${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME} .${OverlayManager.RECT_INNER_CLASS_NAME} {
opacity: var(${OverlayManager.CSS_VAR_HIGHLIGHT_OPACITY}, 0.333);
}
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:visited,
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:active,
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover,
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:focus,
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME} {
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:visited,
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:active,
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover,
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:focus,
.${OverlayManager.RECT_BUTTON_CLASS_NAME} {
all: initial;
pointer-events: all;
cursor: pointer;
position: absolute;
z-index: 1;
top: calc(-1 * ${borderSpacing} + ${borderWidth} / 2);
left: calc(-1 * ${borderSpacing} + ${borderWidth} / 2);
transform: translate(-50%, -50%);
width: var(${OverlayManager.CSS_VAR_BUTTON_WIDTH}, ${buttonWidth}px);
height: var(${OverlayManager.CSS_VAR_BUTTON_HEIGHT}, ${buttonWidth}px);
width: ${buttonWidth};
height: var(${OverlayManager.CSS_VAR_BUTTON_HEIGHT}, ${buttonSize}px);
border-radius: var(${OverlayManager.CSS_VAR_BUTTON_RADIUS}, 50%);
background-color: var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff);
background-image: var(${OverlayManager.CSS_VAR_BUTTON_ICON_BG_IMAGE}, ${OverlayManager.ICON_EDIT});
background-size: var(${OverlayManager.CSS_VAR_BUTTON_ICON_BG_SIZE}, 66.6%);
background-position: center;
background-repeat: no-repeat;
opacity: 0;
}
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover,
.${OverlayManager.RECT_CLASS_NAME}.${OverlayManager.RECT_HOVER_CLASS_NAME}:not(.${OverlayManager.RECT_PARENT_HOVER_CLASS_NAME}) .${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME},
.${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME} {
opacity: 1;
.${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME} {
left: calc(-1 * ${borderSpacing} + ${borderWidth} / 2);
background-color: ${editButtonBgColor};
background-image: var(${OverlayManager.CSS_VAR_EDIT_BUTTON_ICON_BG_IMAGE}, ${OverlayManager.ICON_EDIT});
background-size: var(${OverlayManager.CSS_VAR_EDIT_BUTTON_ICON_BG_SIZE}, 66.6%);
}
.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover ~ .${OverlayManager.RECT_INNER_CLASS_NAME},
.${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_INNER_CLASS_NAME} {
.${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_AI_BUTTON_CLASS_NAME} {
left: calc(-1 * ${borderSpacing} + ${borderWidth} / 2 + ${buttonWidth} + var(${OverlayManager.CSS_VAR_ACTIONS_GAP}, ${buttonGap}px));
background-color: ${aiButtonBgColor};
background-image: var(${OverlayManager.CSS_VAR_AI_BUTTON_ICON_BG_IMAGE}, ${OverlayManager.ICON_AI});
background-size: var(${OverlayManager.CSS_VAR_AI_BUTTON_ICON_BG_SIZE}, 66.6%);
}
.${OverlayManager.RECT_CLASS_NAME}.${OverlayManager.RECT_HOVER_CLASS_NAME}:not(.${OverlayManager.RECT_PARENT_HOVER_CLASS_NAME}) .${OverlayManager.RECT_BUTTON_CLASS_NAME},
.${OverlayManager.RECT_HIGHLIGHT_ACTIVE_CLASS_NAME} .${OverlayManager.RECT_INNER_CLASS_NAME},
.${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_BUTTON_CLASS_NAME},
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover,
.${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover ~ .${OverlayManager.RECT_INNER_CLASS_NAME},
.${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_INNER_CLASS_NAME},
.${OverlayManager.RECT_CLASS_NAME}:has(.${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover) .${OverlayManager.RECT_BUTTON_CLASS_NAME} {
opacity: 1;
}
.${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover {
background-color: color-mix(in srgb, ${editButtonBgColor}, ${buttonBgColorMix});
}
.${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_AI_BUTTON_CLASS_NAME}:hover {
background-color: color-mix(in srgb, ${aiButtonBgColor}, ${buttonBgColorMix});
}
`),
);

Expand Down
22 changes: 19 additions & 3 deletions src/lib/types/directus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,25 @@ export type SavedData = {
key: string;
collection: EditConfig['collection'];
item: EditConfig['item'];
payload: Record<string, any>;
payload: Record<string, unknown>;
};

export type ReceiveAction = 'connect' | 'edit' | 'navigation';
export type AddToContextData = {
key: string;
editConfig: EditConfig;
rect?: DOMRect;
};

export type HighlightElementData = {
key?: string | null;
collection?: string;
item?: string | number;
fields?: string[];
};

export type ConfirmData = {
aiEnabled: boolean;
};

export type SendAction = 'confirm' | 'showEditableElements' | 'saved';
export type ReceiveAction = 'connect' | 'edit' | 'navigation' | 'addToContext';
export type SendAction = 'confirm' | 'showEditableElements' | 'saved' | 'highlightElement';
Loading