diff --git a/pages/aiModePage.css b/pages/aiModePage.css index 9ce8a08..bb18352 100644 --- a/pages/aiModePage.css +++ b/pages/aiModePage.css @@ -75,8 +75,7 @@ body { max-width: 1400px; background: var(--color-bg-frame); border-radius: 8px; - box-shadow: - 0 0 20px var(--color-shadow-dark), + box-shadow: 0 0 20px var(--color-shadow-dark), 0 25px 30px var(--color-shadow-darker); overflow: hidden; position: relative; @@ -122,8 +121,7 @@ body { background: var(--color-bg-white-86); border-radius: 14px; padding: 16px; - box-shadow: - 0 0.375px 1.5px var(--color-shadow-light), + box-shadow: 0 0.375px 1.5px var(--color-shadow-light), 0 3px 12px var(--color-shadow-medium); display: flex; flex-direction: column; @@ -255,8 +253,7 @@ body { background: var(--color-bg-white-86); border-radius: 14px; padding: 20px; - box-shadow: - 0 0.375px 1.5px var(--color-shadow-light), + box-shadow: 0 0.375px 1.5px var(--color-shadow-light), 0 3px 12px var(--color-shadow-medium); border: 2px solid var(--color-border-gray); } @@ -345,3 +342,6 @@ body { .gap-4 { gap: 4px; } +.font-small { + font-size: 14px; +} diff --git a/src/components/MozMentionInput.ts b/src/components/MozMentionInput.ts new file mode 100644 index 0000000..7c9a61a --- /dev/null +++ b/src/components/MozMentionInput.ts @@ -0,0 +1,762 @@ +import { LitElement, html, css } from 'lit' + +type MentionOptionT = { type: string; value: string; image?: string } +type MentionGroupT = { label: string; options: MentionOptionT[] } + +function ellipsis(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.slice(0, maxLength - 1) + '…' +} + +class MozMentionChip extends LitElement { + option: MentionOptionT = { type: '', value: '' } + + static properties = { + option: { type: Object }, + } + + render() { + return html` + + ${this.option.type === 'tab' && this.option.image + ? html`${this.option.value}` + : ''} + ${this.option.type === 'user' ? '@' : ''} + ${ellipsis(this.option.value, 20)} + + ` + } + + static styles = css` + :host { + display: inline; + white-space: nowrap; + margin: 0; + padding: 0; + vertical-align: baseline; + font-size: 16px; + } + + .mention { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 4px; + font-weight: 500; + padding: 4px; + line-height: 1; + margin: 0 -2px; + } + + .mention.user { + background: #f9c5fa; + color: #3a3a3a; + } + + .mention.tab { + background: #f9c5fa; + color: #3a3a3a; + } + ` +} + +customElements.define('mention-chip', MozMentionChip) + +/** create the chip */ +function createMentionChip(option: MentionOptionT): HTMLSpanElement { + const chip = document.createElement('mention-chip') as MozMentionChip + chip.option = option + chip.setAttribute('data-type', option.type) + chip.setAttribute('data-value', option.value) + return chip +} + +/** create a text node (empty string yields a real node for cursor placement) */ +function textNode(string = ''): Text { + return document.createTextNode(string) +} + +/** safely get current Range (if any) */ +function currentRange(): Range | null { + const selection = window.getSelection() + return selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null +} + +/** place caret at node/offset */ +function setCaret(node: Node, offset: number) { + const range = document.createRange() + range.setStart(node, offset) + range.collapse(true) + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +const isTextNode = (node: Node | null): node is Text => + !!node && node.nodeType === Node.TEXT_NODE + +const isElementNode = (node: Node | null): node is HTMLElement => + !!node && node.nodeType === Node.ELEMENT_NODE + +const isMentionElement = (node: Node | null): node is HTMLElement => + !!node && isElementNode(node) && node.nodeName === 'MENTION-CHIP' + +const removeDomNode = (node: Node | null): void => { + node?.parentNode?.removeChild(node) +} + +/** + * Check if there's a mention trigger (@) in the text, this is later used to + * offset the Range for insertion so the mention chip replaces the trigger+text. + * + * @param text + * @param caretOffset + * @returns RegExpMatchArray | null + */ +function matchAtTriggerInText( + text: string, + caretOffset: number, +): RegExpMatchArray | null { + // @ then word chars right at the end of the string + const mentionPattern = /@(\w*)$/ + const before = text.slice(0, caretOffset) + return before.match(mentionPattern) +} + +type FlatOptionsT = { group: number; index: number; option: MentionOptionT } + +export class MozMentionInput extends LitElement { + /** public api */ + placeholder = 'Type some text with @mentions...' + mentionOptions: MentionOptionT[] = [] + mentionGroups: MentionGroupT[] = [] + + /** internal state */ + private _showMentions = false + private _onKeyDown!: (e: KeyboardEvent) => void + private _selectedIndex = -1 + private _filteredMentionGroups: MentionGroupT[] = [] + private _flatOptions: FlatOptionsT[] = [] + + static properties = { + placeholder: { type: String }, + mentionOptions: { type: Array }, + mentionGroups: { type: Array }, + _filteredMentionGroups: { type: Array, state: true }, + _selectedIndex: { type: Number, state: true }, + _showMentions: { type: Boolean, state: true }, + } + + private get editableSection(): HTMLDivElement | null { + return this.renderRoot.querySelector('.mention-input') + } + + private get container(): HTMLDivElement | null { + return this.renderRoot.querySelector( + '.mention-input-container', + ) + } + + constructor() { + super() + this._filteredMentionGroups = [...this.mentionGroups] + } + + private _rebuildFlatOptions() { + this._flatOptions = [] + this._filteredMentionGroups.forEach((group, groupIndex) => { + group.options.forEach((option, optionIndex) => { + this._flatOptions.push({ + group: groupIndex, + index: optionIndex, + option, + }) + }) + }) + if (!this._flatOptions.length) { + this._selectedIndex = -1 + } else if ( + this._selectedIndex < 0 || + this._selectedIndex >= this._flatOptions.length + ) { + this._selectedIndex = 0 + } + } + + protected willUpdate(changed: Map) { + if (changed.has('mentionGroups')) { + const groups = (this.mentionGroups ?? []) as MentionGroupT[] + this._filteredMentionGroups = groups.map((g) => ({ + label: g.label, + options: [...g.options], + })) + this._rebuildFlatOptions() + } + } + + firstUpdated() { + const element = this.editableSection + if (!element) return + + // keep a stable listener ref for add/remove + this._onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e) + element.addEventListener('keydown', this._onKeyDown) + + const container = this.container + container?.addEventListener('focusout', this._onFocusOut as any) + } + + disconnectedCallback(): void { + const el = this.editableSection + if (el && this._onKeyDown) { + el.removeEventListener('keydown', this._onKeyDown) + } + const container = this.container + container?.removeEventListener('focusout', this._onFocusOut as any) + super.disconnectedCallback() + } + + private _onFocusOut = (e: FocusEvent) => { + // Where is focus going? + const next = e.relatedTarget as Node | null + + // Still inside this component (shadow or light)? + const inside = + !!next && (this.shadowRoot?.contains(next) || this.contains(next)) + + if (!inside) { + this._showMentions = false + this._selectedIndex = -1 + this.requestUpdate() + } + } + + /** Keyboard Handlers */ + + handleBackspace( + event: KeyboardEvent, + anchorNode: Node, + anchorOffset: number, + ) { + // If caret is on a chip element, remove that chip. + if (isMentionElement(anchorNode)) { + event.preventDefault() + removeDomNode(anchorNode) + return + } + + // Case 2: caret is inside a text node at offset 0 — look behind + if (isTextNode(anchorNode) && anchorOffset === 0) { + const prevSibling = anchorNode.previousSibling + if (isMentionElement(prevSibling)) { + event.preventDefault() + removeDomNode(prevSibling) + } + return + } + + // Case 3: caret is inside a text node at offset > 0 — look behind the caret + if (isTextNode(anchorNode) && anchorOffset > 0) { + const textBeforeCaret = anchorNode.textContent?.slice(0, anchorOffset) + const emptyPattern = /^$/ + if (!textBeforeCaret?.match(emptyPattern)) { + return + } + + const prevSibling = anchorNode.previousSibling + if (!isMentionElement(prevSibling)) { + return + } + + event.preventDefault() + removeDomNode(prevSibling) + + // Place caret at the start of the current text node + setCaret(anchorNode, 0) + } + } + + handleDelete(event: KeyboardEvent, anchorNode: Node, anchorOffset: number) { + // If caret is at end of a text node, remove next chip if present. + if (!isTextNode(anchorNode)) return + const textContent = anchorNode.textContent ?? '' + if (anchorOffset !== textContent.length) return + + const nextSibling = anchorNode.nextSibling + if (!isMentionElement(nextSibling)) return + + event.preventDefault() + removeDomNode(nextSibling) + } + + handleArrowDown(e: KeyboardEvent) { + e.preventDefault() + if (!this._flatOptions.length) return + this._selectedIndex = (this._selectedIndex + 1) % this._flatOptions.length + } + + handleArrowUp(e: KeyboardEvent) { + e.preventDefault() + if (!this._flatOptions.length) return + this._selectedIndex = + (this._selectedIndex - 1 + this._flatOptions.length) % + this._flatOptions.length + } + + handleEnter(e: KeyboardEvent) { + e.preventDefault() + if (!this._showMentions) return this.handleSubmit() + const row = this._flatOptions[this._selectedIndex] + if (row) this.selectMention(row.option) + } + + handleArrowLeft( + event: KeyboardEvent, + anchorNode: Node, + anchorOffset: number, + ) { + this._showMentions = false + if (!isTextNode(anchorNode)) return + + // Caret is at the start of a text node + if (anchorOffset === 0) { + const prevSibling = anchorNode.previousSibling + + if (isMentionElement(prevSibling)) { + event.preventDefault() + + // If there's a text node before the chip, place caret there + const secondSibling = prevSibling.previousSibling + if ( + secondSibling && + isTextNode(secondSibling) && + secondSibling.textContent !== null + ) { + // const beforeText = prevSibling.previousSibling + setCaret(secondSibling, secondSibling.textContent.length) + } else { + // Otherwise, insert a safe empty text node before the chip + const text = textNode('') + prevSibling.parentNode?.insertBefore(text, prevSibling) + setCaret(text, 0) + } + } + } + } + + handleArrowRight( + event: KeyboardEvent, + anchorNode: Node, + anchorOffset: number, + ) { + this._showMentions = false + if (!isTextNode(anchorNode)) return + + const textContent = anchorNode.textContent ?? '' + // Caret is at the end of a text node + if (anchorOffset === textContent.length - 1) { + const nextSibling = anchorNode.nextSibling + + // If the *next* node is a chip, skip over it + if (isMentionElement(nextSibling)) { + event.preventDefault() + + // Place caret in a safe text node after the chip + if (nextSibling.nextSibling && isTextNode(nextSibling.nextSibling)) { + setCaret(nextSibling.nextSibling, 0) + } else { + // Create a placeholder text node if nothing follows + const text = textNode('') + nextSibling.parentNode?.insertBefore(text, nextSibling.nextSibling) + setCaret(text, 0) + } + } + } + } + + handleEscape(event: KeyboardEvent) { + event.preventDefault() + this._showMentions = false + this._selectedIndex = -1 + } + + private handleKeyDown(event: KeyboardEvent) { + const editableSection = this.editableSection + if (!editableSection) return + + const selection = window.getSelection() + const anchorNode = selection?.anchorNode + const anchorOffset = selection?.anchorOffset ?? 0 + if (!anchorNode) return + + const keyMap: Record void> = { + Backspace: () => this.handleBackspace(event, anchorNode, anchorOffset), + Delete: () => this.handleDelete(event, anchorNode, anchorOffset), + ArrowDown: () => this.handleArrowDown(event), + ArrowUp: () => this.handleArrowUp(event), + ArrowLeft: () => this.handleArrowLeft(event, anchorNode, anchorOffset), + ArrowRight: () => this.handleArrowRight(event, anchorNode, anchorOffset), + Enter: () => this.handleEnter(event), + Escape: () => this.handleEscape(event), + } + + keyMap[event.key]?.() + } + + private getMentionFilterText(): { filterText: string; show: boolean } { + // toggle mention menu based on "@" + const selectRange = currentRange() + let filterText = '' + let show = false + + // Check if caret is in a text node and there's a mention trigger + if (selectRange && selectRange.startContainer.nodeType === Node.TEXT_NODE) { + const match = matchAtTriggerInText( + selectRange.startContainer.textContent ?? '', + selectRange.startOffset, + ) + if (match) { + show = true + filterText = match[1] // the captured group after @ + } + } + + return { filterText, show } + } + + private handleInput = () => { + const { filterText, show } = this.getMentionFilterText() + + if (show) { + this.dispatchEvent( + new CustomEvent('mention-input:filter', { + detail: { value: filterText }, + }), + ) + } + + this._showMentions = show + this._rebuildFlatOptions() // keep in lockstep + } + + private handleMentionClick = (e: Event) => { + // place caret right after the chip + const target = e.currentTarget as HTMLElement + e.preventDefault() + e.stopPropagation() + + // Check if the mention target is trying be placed in between two text nodes + if (target.nextSibling) { + setCaret(target.nextSibling, 0) + return + } + // otherwise, insert an empty text node after the chip and place caret there + const text = textNode('') + target.parentNode?.insertBefore(text, target.nextSibling) + setCaret(text, 0) + } + + private selectMention = (option: MentionOptionT) => { + const editableSection = this.editableSection + const selectRange = currentRange() + + if (!editableSection || !selectRange) return + + const container = selectRange.startContainer + const offset = selectRange.startOffset + + // we only handle insertion when caret lives in a text node + if (container.nodeType !== Node.TEXT_NODE) return + + const text = container.textContent ?? '' + const matchRegArray = matchAtTriggerInText(text, offset) + + if (!matchRegArray) return + + const start = offset - matchRegArray[0].length + const end = offset + + // splice the text node into [before][after], insert chip in between + const before = text.slice(0, start) + const after = text.slice(end) + + const parent = container.parentNode + if (!parent) return + + // replace the original text node with: beforeText, chip, space+afterText + const beforeNode = before ? textNode(before) : null + const chip = createMentionChip(option) + // allow clicking chip to move caret + chip.addEventListener('click', this.handleMentionClick) + + const afterNode = textNode(after) + + // Replace original text node with beforeNode, chip, and afterNode + parent.replaceChild(afterNode, container) + parent.insertBefore(chip, afterNode) + if (beforeNode) parent.insertBefore(beforeNode, chip) + + // Place caret at the start of the afterNode + setCaret(afterNode, 0) + + editableSection.focus() + this._showMentions = false + this._selectedIndex = -1 + } + + private buildSubmissionString(): string { + const root = this.editableSection! + let result = '' + + const traverse = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + result += node.textContent ?? '' + return + } + + if (isMentionElement(node)) { + const type = node.getAttribute('data-type') ?? 'user' + const value = node.getAttribute('data-value') ?? '' + // inline canonical syntax + result += `@${type}:${value}` + return + } + + node.childNodes.forEach(traverse) + } + + root.childNodes.forEach(traverse) + return result.trim() + } + + private handleSubmit = () => { + const submission = this.buildSubmissionString() + // bubble a submit event with the built string + this.dispatchEvent( + new CustomEvent('mention-input:submit', { + detail: { value: submission }, + }), + ) + } + + render() { + return html` +
+ Developer note: This is a prototype of the @mentions input. + Except for some CSS and TS this should be compatible to have a good plug + and play start to a real @mentions implementation on MC. +
+
+
+ + ${this._showMentions + ? html` +
+ ${(() => { + let flat = 0 // single source of truth for row index + return this._filteredMentionGroups.map( + (group) => html` +
+
${group.label}
+ ${group.options.map((opt) => { + const rowIndex = flat++ + const selected = rowIndex === this._selectedIndex + return html` +
{ + this._selectedIndex = rowIndex + }} + @mousedown=${(e: MouseEvent) => { + e.preventDefault() // keep CE focus & selection + const idx = Number( + (e.currentTarget as HTMLElement).dataset + .flatIndex, + ) + const row = this._flatOptions[idx] + if (row) this.selectMention(row.option) + }} + > + ${opt.type === 'tab' && opt.image + ? html` + ${opt.value} + ` + : ''} + ${opt.type === 'user' ? '@' : ''}${ellipsis( + opt.value, + 70, + )} +
+ ` + })} +
+ + `, + ) + })()} +
+ ` + : null} +
+
+ +
+
+ ` + } + + static styles = css` + :host { + /* button colors */ + --color-button-bg: #dcbde6; + --color-button-bg-hover: #d8b5e1; + --color-button-text: #343434; + --color-button-clear-bg-hover: #e3e3e3; + --color-button-clear-bg: transparent; + --color-button-clear-text: #000000; + position: relative; + display: block; + } + + .dev-note { + font-size: 14px; + color: #242424; + margin-bottom: 24px; + user-select: none; + } + + .primary-button { + background: #de45fc; + background: linear-gradient( + 90deg, + rgba(222, 69, 252, 1) 0%, + rgba(252, 69, 90, 1) 100% + ); + color: #fff; + border: none; + padding: 12px; + border-radius: 18px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s ease; + } + + .primary-button:hover { + background-color: var(--color-button-bg-hover); + } + + .mention-input-container { + border: 1px solid #ccc; + padding: 12px; + background-color: #fff; + border-radius: 12px; + } + + .mention-hr { + border: solid 0.5px #e0e0e0; + } + + .mention-actions { + display: flex; + justify-content: flex-end; + padding-top: 8px; + } + + .mention-input-container:has(.mention-input:focus) { + border-color: #007acc; + box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2); + } + + .mention-input { + min-height: 20px; + border-radius: 12px; + outline: none; + cursor: text; + white-space: pre-wrap; + word-break: break-word; + font-size: 16px; + line-height: 1.8; + padding: 8px 12px; + } + + .mention-input:empty::before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; + } + + .mentions-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #ffffff; + border-radius: 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + margin-top: 4px; + } + + .mention-option { + padding: 8px 12px; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + color: #323232; + } + + .mention-option.selected { + background: #007acc; + color: white; + } + + .group-label { + padding: 6px 12px; + font-weight: 800; + color: #666; + font-size: 12px; + } + .group-sep { + height: 1px; + background: #eee; + margin: 4px 0; + } + .group:last-of-type + .group-sep { + display: none; + } + ` +} + +export default MozMentionInput diff --git a/src/elements.ts b/src/elements.ts index 7a2a807..0085c9b 100644 --- a/src/elements.ts +++ b/src/elements.ts @@ -14,6 +14,7 @@ import MozSemanticSearch from './features/MozSemanticSearch' import MozTabs from './features/MozTabs' import MozTabsDebug from './features/MozTabsDebug' import MozAssistantChat from './features/assistant/MozAssistantChat' +import MozMentionInput from './components/MozMentionInput' customElements.define('moz-ai-mode', MozAIMode) customElements.define('moz-attribute-comparison', MozAttributeComparison) @@ -34,3 +35,4 @@ customElements.define('moz-semantic-search', MozSemanticSearch) customElements.define('moz-tabs-debug', MozTabsDebug) customElements.define('moz-tabs', MozTabs) customElements.define('moz-assistant-chat', MozAssistantChat) +customElements.define('moz-mention-input', MozMentionInput) diff --git a/src/features/aimode/MozAIModePage.ts b/src/features/aimode/MozAIModePage.ts index 25bbfee..8aec240 100644 --- a/src/features/aimode/MozAIModePage.ts +++ b/src/features/aimode/MozAIModePage.ts @@ -1,4 +1,4 @@ -import { LitElement, html } from 'lit' +import { LitElement, PropertyValues, html } from 'lit' import aiModeLogo from '../../../assets/ai-mode-logo.png' import { getOpenAIChatResponseWithModel, @@ -6,6 +6,41 @@ import { } from '../../services/openai' import type { mlBrowserT } from '../../../types' +const firefox = + 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1024px-Firefox_logo%2C_2019.svg.png?20250401130810' +const nightly = + 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b4/Firefox_Nightly_logo%2C_2019.svg/1971px-Firefox_Nightly_logo%2C_2019.svg.png' +const reddit = + 'https://cdn3.iconfinder.com/data/icons/2018-social-media-logotypes/1000/2018_social_media_popular_app_logo_reddit-512.png' + +const mentionGroups = [ + { + label: 'Tabs', + options: [ + { + type: 'tab', + value: 'https://blog.nightly.mozilla.org/', + image: nightly, + }, + { type: 'tab', value: 'https://www.firefox.com/en-US/', image: firefox }, + { + type: 'tab', + value: 'https://www.reddit.com/r/bicycling/', + image: reddit, + }, + ], + }, + { + label: 'Groups', + options: [ + { type: 'file', value: 'Group 1' }, + { type: 'file', value: 'Group 2' }, + { type: 'file', value: 'Group 3' }, + { type: 'file', value: 'Group 4' }, + ], + }, +] + class MozAIModePage extends LitElement { query: string = '' hasOpenAIKey: boolean = false @@ -13,6 +48,7 @@ class MozAIModePage extends LitElement { showSearchFallback: boolean = false isProcessing: boolean = false private keyStatusCleanup?: () => void + mockOptions = mentionGroups static get properties() { return { @@ -21,6 +57,7 @@ class MozAIModePage extends LitElement { aiResponse: { type: String }, showSearchFallback: { type: Boolean }, isProcessing: { type: Boolean }, + mockOptions: { type: Array }, } } @@ -35,6 +72,15 @@ class MozAIModePage extends LitElement { this.closeSidebar() } + protected firstUpdated(_changedProperties: PropertyValues): void { + const mozMentionInput = document.querySelector('moz-mention-input') + mozMentionInput?.addEventListener('mention-input:filter', (e: any) => { + // This is where you would handle the filter event and then we can update the options given to the + // mention input component and not have to worry about filerting in the component itself. + console.log('Received mention-input:filter event:', e.detail) + }) + } + disconnectedCallback() { super.disconnectedCallback() this.keyStatusCleanup?.() @@ -179,7 +225,9 @@ Examples: } async handleSearchGoogle() { - const searchUrl = `https://www.google.com/search?client=firefox-b-1-d&q=${encodeURIComponent(this.query)}` + const searchUrl = `https://www.google.com/search?client=firefox-b-1-d&q=${encodeURIComponent( + this.query, + )}` window.open(searchUrl, '_blank') try { @@ -198,6 +246,34 @@ Examples: } } + mockOptionsUpdate() { + const mentionGroups2 = [ + { + label: 'I work', + options: [ + { + type: 'tab', + value: 'https://blog.nightly.mozilla.org/', + image: nightly, + }, + { + type: 'tab', + value: 'https://www.firefox.com/en-US/', + image: firefox, + }, + { + type: 'tab', + value: 'https://www.reddit.com/r/bicycling/', + image: reddit, + }, + { type: 'user', value: 'Trevor' }, + { type: 'user', value: 'Merlin' }, + ], + }, + ] + this.mockOptions = mentionGroups2 + } + render() { return html`
@@ -215,6 +291,23 @@ Examples: />
+
+

+ This is an example of updateing the options on the fly. We + will look for the dispatched value to update the options in + live time. +

+ + +
+