From 5bfc732cddfb7eba15541ba4638bcbd8ba2c9447 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Wed, 21 Jan 2026 20:25:46 -0500 Subject: [PATCH 01/18] wip: add context to communicate with ai chat --- src/lib/directus-frame.ts | 14 +++++++-- src/lib/editable-element.ts | 37 +++++++++++++++++++---- src/lib/editable-store.ts | 17 +++++++++++ src/lib/overlay-element.ts | 25 ++++++++++++---- src/lib/overlay-manager.ts | 58 ++++++++++++++++++++++++++++++------- src/lib/types/directus.ts | 24 +++++++++++++-- src/lib/types/index.ts | 6 ++++ 7 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/lib/directus-frame.ts b/src/lib/directus-frame.ts index 5a08ff8..bbb9e20 100644 --- a/src/lib/directus-frame.ts +++ b/src/lib/directus-frame.ts @@ -1,5 +1,5 @@ import { EditableStore } from './editable-store.ts'; -import type { SendAction, ReceiveData, SavedData } from './types/index.ts'; +import type { SendAction, ReceiveData, SavedData, HighlightElementData } from './types/index.ts'; /** * *Singleton* class to handle communication with Directus in parent frame. @@ -44,13 +44,16 @@ 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 === 'showEditableElements') this.receiveShowEditableElements(data); if (action === 'saved') this.receiveSaved(data); + if (action === 'highlight-element') this.receiveHighlightElement(data); } receiveConfirm() { @@ -88,4 +91,11 @@ export class DirectusFrame { window.location.reload(); } + + private receiveHighlightElement(data: unknown) { + if (!data || typeof data !== 'object') return; + const { key } = data as HighlightElementData; + if (key !== null && typeof key !== 'string') return; + EditableStore.highlightElement(key); + } } diff --git a/src/lib/editable-element.ts b/src/lib/editable-element.ts index 0eec08e..8a909d2 100644 --- a/src/lib/editable-element.ts +++ b/src/lib/editable-element.ts @@ -2,7 +2,7 @@ import observeRect from '@reach/observe-rect'; import { DirectusFrame } from './directus-frame.ts'; import { EditableStore } from './editable-store.ts'; import { OverlayElement } from './overlay-element.ts'; -import type { EditConfig, EditConfigStrict, EditableElementOptions } from './types/index.ts'; +import type { EditConfig, EditConfigStrict, EditableElementOptions, Rect } from './types/index.ts'; export class EditableElement { private static readonly DATASET = 'directus'; @@ -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,35 @@ 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(); + + const textContent = this.element.textContent?.trim().slice(0, 50) || ''; + const displayValue = textContent || `${this.editConfig.collection} #${this.editConfig.item}`; + + const rect: Rect = { + top: this.rect.top, + left: this.rect.left, + width: this.rect.width, + height: this.rect.height, + }; + + new DirectusFrame().send('add-to-context', { + key: this.key, + editConfig: this.editConfig, + displayValue, + 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..173ab1b 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); @@ -64,4 +65,20 @@ export class EditableStore { item.overlayElement.toggleHighlight(show); }); } + + static highlightElement(key: string | null) { + if (this.highlightedKey !== null) { + EditableStore.getItemByKey(this.highlightedKey)?.overlayElement.toggleAiContext(false); + } + + this.highlightedKey = key; + + if (key !== null) { + EditableStore.getItemByKey(key)?.overlayElement.toggleAiContext(true); + } + } + + static setElementInAiContext(key: string, inContext: boolean) { + EditableStore.getItemByKey(key)?.overlayElement.toggleAiContext(inContext); + } } diff --git a/src/lib/overlay-element.ts b/src/lib/overlay-element.ts index b66ca5b..061dff5 100644 --- a/src/lib/overlay-element.ts +++ b/src/lib/overlay-element.ts @@ -7,11 +7,13 @@ export class OverlayElement { private container: HTMLElement; readonly editButton: HTMLButtonElement; + readonly aiButton: HTMLButtonElement; constructor() { this.container = this.createContainer(); this.element = this.createElement(); this.editButton = this.createEditButton(); + this.aiButton = this.createAiButton(); this.createRectElement(); OverlayManager.getGlobalOverlay().appendChild(this.container); @@ -43,11 +45,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 +87,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); + } + + toggleAiContext(show: boolean) { + this.element.classList.toggle(OverlayManager.RECT_AI_CONTEXT_CLASS_NAME, show); } disable() { diff --git a/src/lib/overlay-manager.ts b/src/lib/overlay-manager.ts index b802b89..e5bca5d 100644 --- a/src/lib/overlay-manager.ts +++ b/src/lib/overlay-manager.ts @@ -12,9 +12,13 @@ export class OverlayManager { 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_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_CONTEXT_BORDER_COLOR = '--directus-visual-editing--ai-context--border-color'; // 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'; @@ -25,7 +29,10 @@ export class OverlayManager { 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 readonly RECT_AI_CONTEXT_CLASS_NAME = 'directus-visual-editing-rect-ai-context'; static getGlobalOverlay(): HTMLElement { const existingOverlay = document.getElementById(OverlayManager.OVERLAY_ID); @@ -91,38 +98,67 @@ 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} { 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); 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_BUTTON_CLASS_NAME}:hover { + opacity: 1; + } + .${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME} { + left: calc(-1 * ${borderSpacing} + ${borderWidth} / 2); + background-color: var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff); + background-image: var(${OverlayManager.CSS_VAR_BUTTON_ICON_BG_IMAGE}, ${OverlayManager.ICON_EDIT}); + } + .${OverlayManager.RECT_BUTTON_CLASS_NAME}.${OverlayManager.RECT_AI_BUTTON_CLASS_NAME} { + left: calc(-1 * ${borderSpacing} + ${borderWidth} / 2 + var(${OverlayManager.CSS_VAR_BUTTON_WIDTH}, ${buttonWidth}px) + 8px); + background-color: var(${OverlayManager.CSS_VAR_AI_BUTTON_BG_COLOR}, var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff)); + background-image: var(${OverlayManager.CSS_VAR_AI_BUTTON_ICON_BG_IMAGE}, ${OverlayManager.ICON_AI}); + } + .${OverlayManager.RECT_CLASS_NAME}.${OverlayManager.RECT_HOVER_CLASS_NAME}:not(.${OverlayManager.RECT_PARENT_HOVER_CLASS_NAME}) .${OverlayManager.RECT_BUTTON_CLASS_NAME}, + .${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_BUTTON_CLASS_NAME} { + opacity: 1; + } .${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; + background-color: color-mix(in srgb, var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff) 85%, white); + } + .${OverlayManager.RECT_CLASS_NAME}.${OverlayManager.RECT_HOVER_CLASS_NAME}:not(.${OverlayManager.RECT_PARENT_HOVER_CLASS_NAME}) .${OverlayManager.RECT_AI_BUTTON_CLASS_NAME}, + .${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_AI_BUTTON_CLASS_NAME} { + background-color: color-mix(in srgb, var(${OverlayManager.CSS_VAR_AI_BUTTON_BG_COLOR}, var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff)) 85%, white); } - .${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover ~ .${OverlayManager.RECT_INNER_CLASS_NAME}, + .${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover ~ .${OverlayManager.RECT_INNER_CLASS_NAME}, .${OverlayManager.RECT_HIGHLIGHT_CLASS_NAME}:hover .${OverlayManager.RECT_INNER_CLASS_NAME} { opacity: 1; } + .${OverlayManager.RECT_CLASS_NAME}:has(.${OverlayManager.RECT_BUTTON_CLASS_NAME}:hover) .${OverlayManager.RECT_BUTTON_CLASS_NAME} { + opacity: 1; + } + .${OverlayManager.RECT_CLASS_NAME}:has(.${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME}:hover) .${OverlayManager.RECT_AI_BUTTON_CLASS_NAME} { + background-color: color-mix(in srgb, var(${OverlayManager.CSS_VAR_AI_BUTTON_BG_COLOR}, var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff)) 85%, white); + } + .${OverlayManager.RECT_CLASS_NAME}:has(.${OverlayManager.RECT_AI_BUTTON_CLASS_NAME}:hover) .${OverlayManager.RECT_EDIT_BUTTON_CLASS_NAME} { + background-color: color-mix(in srgb, var(${OverlayManager.CSS_VAR_BUTTON_BG_COLOR}, #6644ff) 85%, white); + } + .${OverlayManager.RECT_AI_CONTEXT_CLASS_NAME} .${OverlayManager.RECT_INNER_CLASS_NAME} { + border-color: var(${OverlayManager.CSS_VAR_AI_CONTEXT_BORDER_COLOR}, var(${OverlayManager.CSS_VAR_BORDER_COLOR}, #6644ff)); + opacity: 1; + } + .${OverlayManager.RECT_AI_CONTEXT_CLASS_NAME} .${OverlayManager.RECT_AI_BUTTON_CLASS_NAME} { + opacity: 1; + } `), ); diff --git a/src/lib/types/directus.ts b/src/lib/types/directus.ts index e82bc3e..0cb04f2 100644 --- a/src/lib/types/directus.ts +++ b/src/lib/types/directus.ts @@ -15,9 +15,27 @@ export type SavedData = { key: string; collection: EditConfig['collection']; item: EditConfig['item']; - payload: Record; + payload: Record; }; -export type ReceiveAction = 'connect' | 'edit' | 'navigation'; +export type Rect = { + top: number; + left: number; + width: number; + height: number; +}; + +export type AddToContextData = { + key: string; + editConfig: EditConfig; + displayValue: string; + rect?: Rect; +}; + +export type HighlightElementData = { + key: string | null; +}; + +export type ReceiveAction = 'connect' | 'edit' | 'navigation' | 'add-to-context'; -export type SendAction = 'confirm' | 'showEditableElements' | 'saved'; +export type SendAction = 'confirm' | 'showEditableElements' | 'saved' | 'highlight-element'; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index ff28a06..30b7f55 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -3,6 +3,9 @@ import type { ReceiveAction as DirectusReceiveAction, SendAction as DirectusSendAction, SavedData as DirectusSavedData, + AddToContextData as DirectusAddToContextData, + HighlightElementData as DirectusHighlightElementData, + Rect as DirectusRect, } from './directus.ts'; export type EditConfigStrict = DirectusEditConfig; @@ -15,6 +18,9 @@ export type ReceiveAction = DirectusSendAction; export type ReceiveData = { action: ReceiveAction | null; data: unknown }; export type SavedData = DirectusSavedData; +export type AddToContextData = DirectusAddToContextData; +export type HighlightElementData = DirectusHighlightElementData; +export type Rect = DirectusRect; export type EditableElementOptions = { customClass?: string | undefined; From 72786ad0d12391c8c4b48a91bdf8c39b3ea69a64 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Wed, 21 Jan 2026 20:26:02 -0500 Subject: [PATCH 02/18] pin dependencies in test site --- .../simple-cms/nuxt/app/layouts/default.vue | 16 +- test-website/simple-cms/nuxt/nuxt.config.ts | 2 +- test-website/simple-cms/nuxt/package.json | 44 +- test-website/simple-cms/nuxt/pnpm-lock.yaml | 15495 +++++++--------- .../simple-cms/nuxt/shared/types/schema.ts | 155 +- 5 files changed, 7196 insertions(+), 8516 deletions(-) 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...

-
- - -
-
+ + +