diff --git a/src/lib/directus-frame.ts b/src/lib/directus-frame.ts index 5a08ff8..c802d89 100644 --- a/src/lib/directus-frame.ts +++ b/src/lib/directus-frame.ts @@ -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'; /** @@ -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; @@ -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() { @@ -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 }); + } + } } diff --git a/src/lib/editable-element.ts b/src/lib/editable-element.ts index 0eec08e..c8e99df 100644 --- a/src/lib/editable-element.ts +++ b/src/lib/editable-element.ts @@ -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]!); @@ -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)); @@ -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); } diff --git a/src/lib/editable-store.ts b/src/lib/editable-store.ts index 437efb1..8397b1f 100644 --- a/src/lib/editable-store.ts +++ b/src/lib/editable-store.ts @@ -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); @@ -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); } @@ -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; + } + } } diff --git a/src/lib/overlay-element.ts b/src/lib/overlay-element.ts index b66ca5b..235011b 100644 --- a/src/lib/overlay-element.ts +++ b/src/lib/overlay-element.ts @@ -1,5 +1,6 @@ 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; @@ -7,11 +8,13 @@ export class OverlayElement { 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); @@ -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; @@ -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() { diff --git a/src/lib/overlay-manager.ts b/src/lib/overlay-manager.ts index b802b89..443a714 100644 --- a/src/lib/overlay-manager.ts +++ b/src/lib/overlay-manager.ts @@ -6,15 +6,20 @@ 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,')`; + private static readonly ICON_AI = `url('data:image/svg+xml,')`; private static readonly OVERLAY_ID = 'directus-visual-editing'; private static readonly STYLE_ID = 'directus-visual-editing-style'; @@ -22,10 +27,13 @@ export class OverlayManager { 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); @@ -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( @@ -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}); + } `), ); diff --git a/src/lib/types/directus.ts b/src/lib/types/directus.ts index e82bc3e..159c026 100644 --- a/src/lib/types/directus.ts +++ b/src/lib/types/directus.ts @@ -15,9 +15,25 @@ export type SavedData = { key: string; collection: EditConfig['collection']; item: EditConfig['item']; - payload: Record; + payload: Record; }; -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'; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index ff28a06..441b504 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -11,7 +11,6 @@ export type EditConfig = Omit & { fields?: EditConfi export type SendAction = DirectusReceiveAction; export type ReceiveAction = DirectusSendAction; - export type ReceiveData = { action: ReceiveAction | null; data: unknown }; export type SavedData = DirectusSavedData; diff --git a/test-website/simple-cms/nuxt/app/layouts/default.vue b/test-website/simple-cms/nuxt/app/layouts/default.vue index f56a198..6059601 100644 --- a/test-website/simple-cms/nuxt/app/layouts/default.vue +++ b/test-website/simple-cms/nuxt/app/layouts/default.vue @@ -160,11 +160,17 @@ provide('refreshSiteData', refresh);

Loading...

-
- - -
-
+ + +