diff --git a/head.html b/head.html index 76959fe1b..1c735b527 100644 --- a/head.html +++ b/head.html @@ -1,6 +1,6 @@ + - diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.css b/hlx_statics/blocks/ai-assistant/ai-assistant.css index 8820c3e3e..9d590fb01 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.css +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.css @@ -39,6 +39,7 @@ */ .ai-assistant-panel .chat-button { --background-color: #ffffff; + --transition-delay: calc(var(--opening-transition-duration) * 0.9); position: relative; width: var(--button-size); @@ -54,9 +55,11 @@ var(--ai-border-linear-gradient) border-box; align-self: flex-end; pointer-events: auto; + transform: scale(1); visibility: visible; - transition: visibility 0s linear - calc(var(--opening-transition-duration) * 0.9); + transition: + visibility 0s linear var(--transition-delay), + transform 0.15s cubic-bezier(0, 0, 0.4, 1) var(--transition-delay); &:hover { --background-color: #f0f0f0; @@ -65,9 +68,36 @@ &.hidden { visibility: hidden; transition: visibility 0s linear; + transform: scale(0.9); } } +.ai-assistant-panel .chat-button .chat-button-badge { + position: absolute; + top: -10px; + right: -10px; + background: var(--ai-border-linear-gradient); + color: #fff; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.3px; + padding: 2px 6px; + border-radius: 8px; + border: 2px solid #fff; + pointer-events: none; + line-height: 1.4; + opacity: 1; + transform: scale(1); + transition: + opacity 0.15s cubic-bezier(0, 0, 0.4, 1), + transform 0.15s cubic-bezier(0, 0, 0.4, 1); +} + +.ai-assistant-panel .chat-button .chat-button-badge.hidden { + opacity: 0; + transform: scale(0.6); +} + /* * MARK: Chat Window */ @@ -422,6 +452,7 @@ justify-content: center; padding: 24px; box-sizing: border-box; + z-index: 11; } .ai-assistant-panel .chat-window .chat-window-dialog-card { diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js b/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js index 1bfa0d076..ed7c7cb5f 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js @@ -396,7 +396,7 @@ export class AiApiClient { callbacks = {}, }) { const defaultSystemPrompt = ` - Use markdown formatting for the response. + Use markdown formatting and codeblocks for the response. `; /** @type {RequestBody} */ diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-bubble.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-bubble.js index b16486dcf..edf349817 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-bubble.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-bubble.js @@ -1,5 +1,7 @@ // @ts-check import { createTag } from "../../scripts/lib-adobeio.js"; +import decoratePreformattedCode from "../../components/code.js"; +import { ensurePrismLoaded } from "../../scripts/prism-loader.js"; import { aiApiClient } from "./ai-assistant_api-client.js"; import { chatHistory } from "./ai-assistant_chat-history.js"; import { createAiAvatar } from "./ai-assistant_chat-ui.js"; @@ -74,6 +76,8 @@ export class ChatBubble { bubble.dataset.messageId = this.id; // If we have an ID already (e.g., restored from history), append the actions contentElement.appendChild(this._actionsContainer); + // Restored-history bubbles never call completeBubble(), so decorate here. + this.#_decorateCodeBlocks(contentElement); } // Otherwise, the actions will be appended when completeBubble is called } @@ -301,7 +305,7 @@ export class ChatBubble { } /** - * Adds the missing actions to an AI bubble + * Final processing that needs to be done after streaming finishes. */ completeBubble() { if (this.source !== "ai" || !this._actionsContainer) return; @@ -310,6 +314,54 @@ export class ChatBubble { const contentElement = this.element.querySelector(".chat-bubble-content"); contentElement?.appendChild(this._actionsContainer); + if (contentElement) { + this.#_decorateCodeBlocks(contentElement); + } + } + + /** + * @param {Element} contentElement - The `.chat-bubble-content` element + */ + #_decorateCodeBlocks(contentElement) { + const preBlocks = contentElement.querySelectorAll("pre"); + if (!preBlocks.length) return; + + let decoratedAny = false; + preBlocks.forEach((pre) => { + // decoratePreformattedCode dereferences a child unconditionally. + if (!pre.querySelector("code")) return; + // The chat panel is narrow, so use the icon-only copy button. + pre.classList.add("copy-condensed"); + decoratePreformattedCode(pre); + decoratedAny = true; + }); + + if (!decoratedAny) return; + + ensurePrismLoaded().then(() => { + // @ts-expect-error - Prism is not on the Window type + window.Prism?.highlightAllUnder?.(contentElement); + }); + } + + /** + * Recomputes Prism's line-number row heights for every decorated code block + * inside a container. + * This is required to correctly align line numbers after restoring history. + * @param {Element | null | undefined} container + */ + static resizeCodeBlockLineNumbers(container) { + const preBlocks = container?.querySelectorAll("pre.line-numbers"); + if (!preBlocks?.length) return; + + ensurePrismLoaded().then(() => { + // @ts-expect-error - Prism is not on the Window type + const resize = window.Prism?.plugins?.lineNumbers?.resize; + if (typeof resize !== "function") return; + preBlocks.forEach((pre) => { + resize(pre); + }); + }); } /** diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js index 91d42651c..e59fe4f23 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js @@ -7,11 +7,11 @@ import { CHAT_BUTTON_LABEL_OPEN, ELEMENTS, GENERIC_ERROR_MESSAGE, + INITIAL_SUGGESTED_QUESTIONS, SEND_ICON_SRC, STOP_ICON_SRC, } from "./ai-assistant_constants.js"; import { - getCollectionsQuestions, hideSuggestedQuestions, parseAiSuggestedQuestions, showSuggestedQuestions, @@ -51,6 +51,7 @@ export const onUserScroll = (event) => { */ const sendInitialMessages = ({ delay = 250 } = {}) => { hideSuggestedQuestions(); + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); window.setTimeout(() => { sendMessage({ content: "Hello, welcome to Adobe Developer Website!", @@ -81,6 +82,26 @@ export const openChatWindow = () => { ELEMENTS.CHAT_BUTTON.ariaLabel = CHAT_BUTTON_LABEL_MINIMIZE; ELEMENTS.CHAT_WINDOW.classList.add("show"); ELEMENTS.CHAT_BUTTON.classList.add("hidden"); + ELEMENTS.CHAT_BUTTON_BADGE?.classList.add("hidden"); + + // Code blocks in restored-history bubbles are decorated while the window is + // hidden (scaled to 0), so Prism's line-number rows collapse onto line 1. + // Recompute them once the open transition settles and the window has real + // layout. transitionend is the precise signal; the timeout is a fallback in + // case the transition is skipped (e.g. reduced motion / 0s duration). + const chatWindow = ELEMENTS.CHAT_WINDOW; + let fixedLineNumbers = false; + const fixLineNumbers = (event) => { + // transitionend bubbles, so ignore descendant transitions and only react to + // the window's own transform settling (or the timeout, which has no event). + if (event && (event.target !== chatWindow || event.propertyName !== "transform")) return; + if (fixedLineNumbers) return; + fixedLineNumbers = true; + chatWindow.removeEventListener("transitionend", fixLineNumbers); + ChatBubble.resizeCodeBlockLineNumbers(ELEMENTS.CHAT_WINDOW_CONTENT); + }; + chatWindow.addEventListener("transitionend", fixLineNumbers); + window.setTimeout(fixLineNumbers, 400); // Initial messages if (chatHistory.isEmpty()) { @@ -96,6 +117,44 @@ export const minimizeChatWindow = () => { ELEMENTS.CHAT_BUTTON.ariaLabel = CHAT_BUTTON_LABEL_OPEN; ELEMENTS.CHAT_BUTTON?.classList.remove("hidden"); ELEMENTS.CHAT_WINDOW?.classList.remove("show"); + + restoreBadgeAfterClose(); +}; + +/** + * Restores the beta badge after the chat window's collapse (transform) transition + * completes. + */ +const restoreBadgeAfterClose = () => { + const chatWindow = ELEMENTS.CHAT_WINDOW; + if (!chatWindow) return; + + // With reduced motion the window collapses instantly (no transform transition), + // so `transitionend` may never fire — restore the badge right away instead. + const prefersReducedMotion = window.matchMedia?.( + "(prefers-reduced-motion: reduce)", + )?.matches; + if (prefersReducedMotion) { + if (!chatWindow.classList.contains("show")) { + ELEMENTS.CHAT_BUTTON_BADGE?.classList.remove("hidden"); + } + return; + } + + /** @param {TransitionEvent} event */ + const onTransitionEnd = (event) => { + // The window animates both `transform` and `visibility`; only react to the + // transform transition, which is the visible collapse. + if (event.target !== chatWindow || event.propertyName !== "transform") { + return; + } + chatWindow.removeEventListener("transitionend", onTransitionEnd); + if (!chatWindow.classList.contains("show")) { + ELEMENTS.CHAT_BUTTON_BADGE?.classList.remove("hidden"); + } + }; + + chatWindow.addEventListener("transitionend", onTransitionEnd); }; export const clearConversation = () => { @@ -122,7 +181,7 @@ export const toggleChatWindow = () => { * Falls back to static questions on any error or parse failure. */ export const fetchAiSuggestedQuestions = async () => { - const query = `Please suggest 2 follow-up questions based on our conversation to make the users.`; + const query = `Please suggest 2 follow-up questions based on our conversation to make the users happy.`; const systemPrompt = ` Structured questions format: ---question--- @@ -142,14 +201,14 @@ export const fetchAiSuggestedQuestions = async () => { if (parsed.length > 0) { updateSuggestedQuestions(parsed); } else { - updateSuggestedQuestions(await getCollectionsQuestions()); + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); } } catch (error) { console.warn( "[AI Assistant] Failed to fetch AI suggested questions:", error, ); - updateSuggestedQuestions(await getCollectionsQuestions()); + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); } }; @@ -291,7 +350,7 @@ export const handleUserQuery = async ( targetBubble.hideThinking(); responseContent = "_Response stopped by user._"; targetBubble.updateContent(responseContent); - updateSuggestedQuestions(await getCollectionsQuestions()); + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); window.setTimeout( () => showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }), @@ -322,7 +381,7 @@ export const handleUserQuery = async ( // TODO: Log error somehow somewhere? console.error("[AI Assistant] Error:", error); showErrorMessage(); - getCollectionsQuestions().then(updateSuggestedQuestions); + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); window.setTimeout( () => showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }), @@ -415,9 +474,11 @@ export const restoreChatHistory = async () => { } const lastMessage = chatHistory.getAll().pop(); if (lastMessage?.source === "ai") { - updateSuggestedQuestions(await getCollectionsQuestions()); + updateSuggestedQuestions( + chatHistory.getSuggestedQuestions() ?? INITIAL_SUGGESTED_QUESTIONS, + ); showSuggestedQuestions(); } else { hideSuggestedQuestions(); } -}; \ No newline at end of file +}; diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js index de70cf4c3..f0b686d72 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js @@ -16,43 +16,81 @@ * @property {{type: 'THUMBS_UP_DOWN'; score: 0|1}} [feedback] */ +/** + * @typedef {Object} SuggestedQuestion + * @property {string} label + * @property {string} question + * @property {string|null} [id] + */ + +/** + * A single conversation: its messages plus the suggested questions currently + * associated with it. Bundling them keeps suggestions tied to the conversation, + * which makes supporting multiple switchable conversations straightforward later. + * @typedef {Object} Conversation + * @property {ChatMessage[]} messages + * @property {SuggestedQuestion[]|null} suggestedQuestions + */ + export class ChatHistory { static STORAGE_KEY = "ai-assistant-chat-history"; - /** @type {ChatMessage[]|null} */ + /** @type {Conversation|null} */ _cache = null; /** - * Gets all messages from history - * @returns {ChatMessage[]} + * Loads the conversation from cache or sessionStorage. + * @returns {Conversation} + * @private */ - getAll() { - if (this._cache) return [...this._cache]; // Return copy to prevent mutations + _getConversation() { + if (this._cache) return this._cache; try { const stored = sessionStorage.getItem(ChatHistory.STORAGE_KEY); if (!stored) { - this._cache = []; - return []; + this._cache = this._emptyConversation(); + return this._cache; } const parsed = JSON.parse(stored); - this._cache = this._sanitizeMessages(parsed); - return [...this._cache]; + this._cache = { + messages: this._sanitizeMessages(parsed?.messages ?? []), + suggestedQuestions: parsed?.suggestedQuestions ?? null, + }; + return this._cache; } catch (error) { console.error("Error retrieving chat history:", error); - this._cache = []; - return []; + this._cache = this._emptyConversation(); + return this._cache; } } + /** + * @returns {Conversation} + * @private + */ + _emptyConversation() { + return { messages: [], suggestedQuestions: null }; + } + + /** + * Gets all messages from history + * @returns {ChatMessage[]} + */ + getAll() { + return [...this._getConversation().messages]; // Copy to prevent mutations + } + /** * Adds a new message to history * @param {ChatMessage} message */ add(message) { - const history = this.getAll(); - history.push(message); - this._save(history); + const conversation = this._getConversation(); + this._save({ + ...conversation, + messages: [...conversation.messages, message], + }); } /** @@ -60,13 +98,14 @@ export class ChatHistory { * @param {Partial} updates - Properties to merge into last message */ updateLast(updates) { - const history = this.getAll(); - if (history.length > 0) { - history[history.length - 1] = { - ...history[history.length - 1], + const conversation = this._getConversation(); + const messages = [...conversation.messages]; + if (messages.length > 0) { + messages[messages.length - 1] = { + ...messages[messages.length - 1], ...updates, }; - this._save(history); + this._save({ ...conversation, messages }); } } @@ -77,11 +116,12 @@ export class ChatHistory { * @returns {boolean} True if the message was found and updated */ updateById(id, updates) { - const history = this.getAll(); - const index = history.findIndex((m) => m.id === id); + const conversation = this._getConversation(); + const messages = [...conversation.messages]; + const index = messages.findIndex((m) => m.id === id); if (index === -1) return false; - history[index] = { ...history[index], ...updates }; - this._save(history); + messages[index] = { ...messages[index], ...updates }; + this._save({ ...conversation, messages }); return true; } @@ -94,6 +134,23 @@ export class ChatHistory { return this.getAll().find((m) => m.id === id); } + /** + * Gets the suggested questions associated with the current conversation. + * @returns {SuggestedQuestion[]|null} + */ + getSuggestedQuestions() { + return this._getConversation().suggestedQuestions ?? null; + } + + /** + * Sets the suggested questions associated with the current conversation. + * @param {SuggestedQuestion[]} questions + */ + setSuggestedQuestions(questions) { + const conversation = this._getConversation(); + this._save({ ...conversation, suggestedQuestions: questions }); + } + /** * Gets messages formatted for AI context * @param {Object} [options] @@ -114,7 +171,7 @@ export class ChatHistory { clear() { try { sessionStorage.removeItem(ChatHistory.STORAGE_KEY); - this._cache = []; + this._cache = this._emptyConversation(); } catch (error) { console.error("Error clearing chat history:", error); } @@ -137,13 +194,16 @@ export class ChatHistory { } /** - * Saves history to sessionStorage - * @param {ChatMessage[]} history - The chat history array to save + * Saves the conversation to sessionStorage + * @param {Conversation} conversation - The conversation to save * @private */ - _save(history) { + _save(conversation) { try { - const serializable = this._sanitizeMessages(history); + const serializable = { + messages: this._sanitizeMessages(conversation.messages), + suggestedQuestions: conversation.suggestedQuestions ?? null, + }; sessionStorage.setItem( ChatHistory.STORAGE_KEY, JSON.stringify(serializable), diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-ui.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-ui.js index 9ca2b7e8e..853daeef2 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-ui.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-ui.js @@ -158,7 +158,16 @@ export const createChatButton = () => { "daa-ll": "DevsiteAI Assistant:Open", }); chatButton.innerHTML = ``; + + const badge = createTag("span", { + class: "chat-button-badge", + "aria-hidden": "true", + }); + badge.textContent = "BETA"; + chatButton.appendChild(badge); + ELEMENTS.CHAT_BUTTON = chatButton; + ELEMENTS.CHAT_BUTTON_BADGE = badge; return chatButton; }; diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_constants.js b/hlx_statics/blocks/ai-assistant/ai-assistant_constants.js index fb6090b1d..b37d7350f 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_constants.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_constants.js @@ -10,6 +10,7 @@ export const CHAT_WINDOW_LABEL_ID = "ai-assistant-label"; */ export const ELEMENTS = { CHAT_BUTTON: null, + CHAT_BUTTON_BADGE: null, CHAT_WINDOW_CLOSE_BUTTON: null, CHAT_WINDOW_CLEAR_BUTTON: null, CHAT_SEND_BUTTON: null, @@ -21,14 +22,30 @@ export const ELEMENTS = { /** * @type {Array<{id?: string | null; label: string; question: string;}>} */ -export const FALLBACK_SUGGESTED_QUESTIONS = [ +export const INITIAL_SUGGESTED_QUESTIONS = [ { - label: "Express Add-ons", - question: "What can I do with Express Add-ons?", + label: "Adobe APIs and SDKs", + question: "Tell me about what Adobe APIs and SDKs are available", }, { - label: "App Builder application", - question: "How do I build an App Builder application?", + label: "How do I get credentials?", + question: "How do I get credentials?", + }, + { + label: "Adobe Developer App Builder", + question: "What is Adobe Developer App Builder?", + }, + { + label: "Firefly Services", + question: "What are Firefly Services and how can I use them?", + }, + { + label: "Adobe Express", + question: "How do I develop Adobe Express addons?", + }, + { + label: "Adobe for Creativity", + question: "How can I use Adobe for Creativity in Claude?", }, ]; export const GENERIC_ERROR_MESSAGE = diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js b/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js index b9603e947..3f8757693 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js @@ -1,10 +1,10 @@ // @ts-check import { createTag } from "../../scripts/lib-adobeio.js"; -import { aiApiClient } from "./ai-assistant_api-client.js"; import { handleUserQuery } from "./ai-assistant_chat-controller.js"; +import { chatHistory } from "./ai-assistant_chat-history.js"; import { ELEMENTS, - FALLBACK_SUGGESTED_QUESTIONS, + INITIAL_SUGGESTED_QUESTIONS, } from "./ai-assistant_constants.js"; /** @@ -37,6 +37,10 @@ export const parseAiSuggestedQuestions = (responseText) => { * @param {Array<{label: string, question: string, id?: string|null}>|null} questions - Questions to show, or null for skeleton */ export const updateSuggestedQuestions = (questions) => { + if (questions !== null) { + chatHistory.setSuggestedQuestions(questions); + } + const wrapper = ELEMENTS.CHAT_SUGGESTED_QUESTIONS; if (!wrapper) return; const list = wrapper.querySelector(".chat-suggested-questions-list"); @@ -73,23 +77,6 @@ export const updateSuggestedQuestions = (questions) => { }); }; -/** - * Fetches collections and returns them as suggestion question objects. - * Falls back to SUGGESTED_QUESTIONS if the API returns no results. - * @returns {Promise>} - */ -export const getCollectionsQuestions = async () => { - const rawCollections = await aiApiClient.getCollections(); - const questions = rawCollections - .filter((c) => c.id !== "__all-collections__" && !c.referencedCollectionIds) - .map((c) => ({ - id: c.id, - label: c.name, - question: `What can I learn about ${c.name}?`, - })); - return questions.length > 0 ? questions : FALLBACK_SUGGESTED_QUESTIONS; -}; - /** * Creates the suggested questions section with topic buttons. * @returns {HTMLElement} The suggested questions wrapper element @@ -106,7 +93,10 @@ export const createSuggestedQuestionsSection = () => { wrapper.appendChild(list); ELEMENTS.CHAT_SUGGESTED_QUESTIONS = wrapper; - getCollectionsQuestions().then(updateSuggestedQuestions); + // Restore if we have an existing convo + updateSuggestedQuestions( + chatHistory.getSuggestedQuestions() ?? INITIAL_SUGGESTED_QUESTIONS, + ); return wrapper; }; diff --git a/hlx_statics/blocks/codeblock/codeblock.css b/hlx_statics/blocks/codeblock/codeblock.css index 9d9333f87..55612eaae 100644 --- a/hlx_statics/blocks/codeblock/codeblock.css +++ b/hlx_statics/blocks/codeblock/codeblock.css @@ -29,6 +29,7 @@ main div.codeblock-wrapper div.codeblock .inline-code { main div.codeblock-wrapper div.codeblock .control-bar { display: flex; + align-items: center; justify-content: space-between; } @@ -37,6 +38,7 @@ main div.codeblock-wrapper div.codeblock .control-bar { main div.codeblock-wrapper div.codeblock .control-bar, main div.codeblock-wrapper div.codeblock .control-bar .tabs-list { display: flex; + align-items: center; gap: 16px; max-width: 100%; overflow-x: auto; @@ -59,6 +61,9 @@ main div.codeblock-wrapper div.codeblock .control-bar select { white-space: unset; cursor: pointer; background-color: initial; +} + +main div.codeblock-wrapper div.codeblock .control-bar .tabs-list button { border-bottom: 3px solid transparent; } @@ -84,18 +89,30 @@ main div.codeblock-wrapper div.codeblock .control-bar select::picker(select) { main div.codeblock-wrapper div.codeblock .control-bar select { border: none; + display: flex; + align-items: center; +} + +main div.codeblock-wrapper div.codeblock .control-bar select > button { + all: unset; + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: rgb(209, 209, 209); } main div.codeblock-wrapper div.codeblock .control-bar select selectedcontent { - margin: auto; + display: flex; + align-items: center; } main div.codeblock-wrapper div.codeblock .control-bar select::picker-icon { content: "⌄"; - margin: auto; - padding-bottom: 6px; + display: flex; + align-items: center; font-weight: bold; - transform: scaleX(1.5); + transform: scaleX(1.5) translateY(-1px); } main div.codeblock-wrapper div.codeblock .control-bar select::picker(select) { @@ -123,6 +140,39 @@ main div.codeblock-wrapper div.codeblock .hidden { display: none; } +main div.codeblock-wrapper div.codeblock .right-controls { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + margin-right: 4px; + flex-shrink: 0; +} + +main div.codeblock-wrapper div.codeblock .collapse-toggle { + flex: 0 0 auto; + margin-left: 8px; + padding-top: 2px; + font-size: 14px; + background: none; + border: none; + cursor: pointer; + color: rgb(209, 209, 209); + display: flex; + align-items: center; + border-radius: 4px; + transition: color 0.15s, background-color 0.15s; +} + +main div.codeblock-wrapper div.codeblock .collapse-toggle:hover { + color: rgb(255, 255, 255); + background-color: rgba(255, 255, 255, 0.1); +} + +main div.codeblock-wrapper div.codeblock.collapsed .tabs-panel { + display: none; +} + main div.codeblock-wrapper div.codeblock pre[class*=language-].no-line-numbers .line-highlight { transform: translateY(-1.6em); } diff --git a/hlx_statics/blocks/codeblock/codeblock.js b/hlx_statics/blocks/codeblock/codeblock.js index 1dd1d5fae..02c2c48b6 100644 --- a/hlx_statics/blocks/codeblock/codeblock.js +++ b/hlx_statics/blocks/codeblock/codeblock.js @@ -84,11 +84,15 @@ export default function decorate(block) { } }); + const rightControls = document.createElement('div'); + rightControls.className = 'right-controls'; + controlBar.append(rightControls); + const select = document.createElement('select'); select.id = selectId; select.classList.toggle('hidden', !areTabsGrouped); select.addEventListener('change', handleSelectChange); - controlBar.append(select); + rightControls.append(select); // set up customizable select (as opposed to classic which can't be styled) as described in https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Customizable_select const selectButton = document.createElement('button'); @@ -113,6 +117,20 @@ export default function decorate(block) { decoratePreformattedCode(panel); }); + const collapseToggle = document.createElement('button'); + collapseToggle.className = 'collapse-toggle'; + collapseToggle.setAttribute('type', 'button'); + collapseToggle.setAttribute('aria-label', 'Collapse code'); + collapseToggle.setAttribute('aria-expanded', 'true'); + collapseToggle.textContent = 'Hide'; + collapseToggle.addEventListener('click', () => { + const isCollapsed = block.classList.toggle('collapsed'); + collapseToggle.setAttribute('aria-expanded', String(!isCollapsed)); + collapseToggle.setAttribute('aria-label', isCollapsed ? 'Expand code' : 'Collapse code'); + collapseToggle.textContent = isCollapsed ? 'Show' : 'Hide'; + }); + rightControls.append(collapseToggle); + // initialize by simulating a click on the first tab const firstTab = block.querySelector('[role=tab]'); if (firstTab) { diff --git a/hlx_statics/blocks/discoverblock/discoverblock.css b/hlx_statics/blocks/discoverblock/discoverblock.css index bb0f352f9..762ff0078 100644 --- a/hlx_statics/blocks/discoverblock/discoverblock.css +++ b/hlx_statics/blocks/discoverblock/discoverblock.css @@ -23,31 +23,40 @@ main div.discoverblock-wrapper div.discoverblock { margin-right: 32px; } +main div.discoverblock-wrapper div.discoverblock.has-image { + display: flex; + align-items: flex-start; + gap: 26px; +} + +main div.discoverblock-wrapper div.discoverblock.has-image > img.discover-image { + /* width/height come from inline styles set by discoverblock.js */ + align-self: flex-start; +} + +main div.discoverblock-wrapper div.discoverblock.has-image > div { + flex: 1 1 auto; + min-width: 0; +} + /* Wrapper display */ main div.discoverblock-wrapper { display: inline-flex; flex-direction: column; width: 320px; - padding-top:0px; + min-width: 280px; + padding-top: 0px; + vertical-align: top; } -/* Responsive design for mobile and tablet */ @media (max-width: 768px) { main div.discoverblock-wrapper { width: 100% !important; + min-width: 0; } } -main div.discoverblock-container div.heading2-wrapper{ - margin-bottom: 5px; -} - -main div.discoverblock-container div.heading2-wrapper h2 { - border-bottom: 0px; - padding-bottom: 3px; -} - -main div.discoverblock-container h2 { +main h2.discoverblock-heading { border-bottom: 1px solid #e1e1e1; margin-top: 40px; padding-top: 20px; @@ -55,7 +64,7 @@ main div.discoverblock-container h2 { margin-bottom: 0px; } -main div.discoverblock-container h3 { +main h3.discoverblock-heading { width: 100%; } @@ -84,11 +93,8 @@ main div.discoverblock-wrapper p { margin-bottom: 0; } -main div.discoverblock-container .discover-heading-with-image { +main .discoverblock-heading.discover-heading-with-image { padding-left: 126px; margin-bottom: 5px; } -main div.discoverblock-wrapper div.discover-content-with-image { - padding-left: 126px; -} diff --git a/hlx_statics/blocks/discoverblock/discoverblock.js b/hlx_statics/blocks/discoverblock/discoverblock.js index d4c2b6dbe..26dd1a425 100644 --- a/hlx_statics/blocks/discoverblock/discoverblock.js +++ b/hlx_statics/blocks/discoverblock/discoverblock.js @@ -1,60 +1,49 @@ -import { createTag, decorateAnchorLink } from "../../scripts/lib-adobeio.js"; +import { decorateAnchorLink } from "../../scripts/lib-adobeio.js"; /** * decorates the discover block * @param {Element} block The discover block element */ export default async function decorate(block) { - block.setAttribute('daa-lh', 'discover'); + block.setAttribute('daa-lh', 'discover'); block.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((h) => { decorateAnchorLink(h); }); - // Set width based on data-width attribute + const width = block.getAttribute('data-width'); const wrapper = block.closest('.discoverblock-wrapper'); - // If there's an image, set the block width to 2*1280/12 and position image on the left const hasImage = block.querySelector('img') !== null; if (hasImage) { let imageWidth = (2 * 1280) / 12; imageWidth = imageWidth + 156; wrapper.style.width = imageWidth + 'px'; - // Get the image const image = block.querySelector('img'); - // Set image width to 100px and move it to the beginning image.style.width = '100px'; image.style.height = 'auto'; image.style.flexShrink = '0'; - image.style.position = 'absolute'; - // Move the image to be the first child (leftmost position) - block.insertBefore(image, block.firstChild); + block.classList.add('has-image'); + image.classList.add('discover-image'); - // Find button-container's parent and give it a class name - const buttonContainer = block.querySelector('.button-container'); - if (buttonContainer && buttonContainer.parentElement) { - buttonContainer.parentElement.classList.add('discover-content-with-image'); - } + block.insertBefore(image, block.firstChild); } else if (width) { - // if data-width exists, override the default width with the data-width. wrapper.style.width = width; } const heading = block.querySelector('h1, h2, h3, h4, h5, h6'); if (heading) { - // Check if block has image and add class to heading if it does if (hasImage) { heading.classList.add('discover-heading-with-image'); } const headingClone = heading.cloneNode(true); - // Insert heading before the wrapper + headingClone.classList.add('discoverblock-heading'); wrapper.parentElement.insertBefore(headingClone, wrapper); - // Remove the original heading from the block content heading.remove(); } } diff --git a/hlx_statics/blocks/resources/resources.css b/hlx_statics/blocks/resources/resources.css index aa6915e4a..2a8696882 100644 --- a/hlx_statics/blocks/resources/resources.css +++ b/hlx_statics/blocks/resources/resources.css @@ -1,5 +1,6 @@ -main div.resources-wrapper a{ +main div.resources-wrapper li > a{ font-size: 16px; + flex: 1; } main div.resources-wrapper ul{ @@ -9,6 +10,7 @@ main div.resources-wrapper ul{ main div.resources-wrapper li{ margin-top:15px; display:flex; + align-items: center; } main div.resources-wrapper h3{ @@ -27,6 +29,5 @@ main div.resources-wrapper div.external-icon { width: 16px; height: 16px; margin-left: 8px; - margin-top:3px; vertical-align: middle; } \ No newline at end of file diff --git a/hlx_statics/blocks/resources/resources.js b/hlx_statics/blocks/resources/resources.js index 94bd78662..a3bc5760c 100644 --- a/hlx_statics/blocks/resources/resources.js +++ b/hlx_statics/blocks/resources/resources.js @@ -13,7 +13,7 @@ export default async function decorate(block) { const href = link.getAttribute('href'); if (href && (href.startsWith('http://') || href.startsWith('https://'))) { const externalLink = createTag('div', {class: 'external-icon'}); - externalLink.innerHTML = `