diff --git a/express/code/blocks/color-extract/color-extract.js b/express/code/blocks/color-extract/color-extract.js index 44166ad32..7f7204114 100644 --- a/express/code/blocks/color-extract/color-extract.js +++ b/express/code/blocks/color-extract/color-extract.js @@ -12,6 +12,8 @@ import loadColorExtractPlaceholders, { DEFAULT_PLACEHOLDERS as COLOR_EXTRACT_DEF import loadImageUploadPlaceholders, { DEFAULT_PLACEHOLDERS as IMAGE_UPLOAD_DEFAULTS } from '../../scripts/color-shared/i18n/loadImageUploadPlaceholders.js'; import loadColorSwatchRailPlaceholders, { DEFAULT_PLACEHOLDERS as COLOR_SWATCH_RAIL_DEFAULTS } from '../../scripts/color-shared/i18n/loadColorSwatchRailPlaceholders.js'; +let colorExtractKbCleanup = null; + const placeholdersPromise = Promise.all([ loadColorExtractPlaceholders(), loadImageUploadPlaceholders(), @@ -793,6 +795,24 @@ function renderColorVariant(block, rows, config, strings = {}) { history.push(getHistoryState()); } + colorExtractKbCleanup?.(); + const onExtractKeyDown = (e) => { + if (!e.metaKey && !e.ctrlKey) return; + if (e.key !== 'z') return; + const el = document.activeElement; + if (el && el !== document.body && el !== document.documentElement) { + if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(el.tagName)) return; + if (el.isContentEditable) return; + if (parseInt(el.getAttribute('tabindex') ?? '-1', 10) >= 0) return; + if (['button', 'link', 'tab', 'slider', 'textbox', 'combobox'].includes(el.getAttribute('role'))) return; + } + e.preventDefault(); + if (e.shiftKey) history.redo(getHistoryState()); + else history.undo(getHistoryState()); + }; + document.addEventListener('keydown', onExtractKeyDown); + colorExtractKbCleanup = () => document.removeEventListener('keydown', onExtractKeyDown); + async function runExtraction(canvas, mood, count) { const swatchCount = count || controller.getState().swatches.length || resolvedConfig.maxColors; const ctx = canvas.getContext('2d'); @@ -1242,6 +1262,24 @@ async function renderGradientVariant(block, rows, config, strings = {}) { history.push(getHistoryState()); } + colorExtractKbCleanup?.(); + const onGradientKeyDown = (e) => { + if (!e.metaKey && !e.ctrlKey) return; + if (e.key !== 'z') return; + const el = document.activeElement; + if (el && el !== document.body && el !== document.documentElement) { + if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(el.tagName)) return; + if (el.isContentEditable) return; + if (parseInt(el.getAttribute('tabindex') ?? '-1', 10) >= 0) return; + if (['button', 'link', 'tab', 'slider', 'textbox', 'combobox'].includes(el.getAttribute('role'))) return; + } + e.preventDefault(); + if (e.shiftKey) history.redo(getHistoryState()); + else history.undo(getHistoryState()); + }; + document.addEventListener('keydown', onGradientKeyDown); + colorExtractKbCleanup = () => document.removeEventListener('keydown', onGradientKeyDown); + gradientEditor.element.addEventListener('pointerdown', () => { if (!gradientInteracting) { gradientInteracting = true; diff --git a/express/code/blocks/color-wheel/color-wheel.js b/express/code/blocks/color-wheel/color-wheel.js index a6065646e..af30e5018 100644 --- a/express/code/blocks/color-wheel/color-wheel.js +++ b/express/code/blocks/color-wheel/color-wheel.js @@ -244,6 +244,11 @@ async function buildDefaultActionMenuConfig(strings) { const THEME_NAME = ''; const HISTORY_EVENT = `${ACTION_MENU_ID}:history-index-changed`; const HISTORY_SKIP_SOURCES = new Set(['active-index', 'metadata', 'base-index']); +const KB_INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A']); +const KB_INTERACTIVE_ROLES = new Set([ + 'button', 'link', 'checkbox', 'menuitem', 'option', + 'radio', 'tab', 'slider', 'spinbutton', 'textbox', 'combobox', +]); let harmonyCarouselCleanup = null; let harmonyStateUnsubscribe = null; let layoutInstance = null; @@ -258,6 +263,7 @@ let primaryColorAdapter = null; let sidebarNaturalWidth = 0; let sidebarTransitionCleanup = null; let historyCleanup = null; +let keyboardCleanup = null; let currentInitToken = 0; function swatchHexListFromState(state) { @@ -818,6 +824,8 @@ function cleanup() { sidebarNaturalWidth = 0; historyCleanup?.(); historyCleanup = null; + keyboardCleanup?.(); + keyboardCleanup = null; } export default async function decorate(block) { @@ -1057,6 +1065,23 @@ export default async function decorate(block) { clearTimeout(historyDebounceTimer); }; + const onKeyDown = (e) => { + const el = document.activeElement; + if (el && el !== document.body && el !== document.documentElement) { + if (KB_INTERACTIVE_TAGS.has(el.tagName)) return; + if (el.isContentEditable) return; + if (parseInt(el.getAttribute('tabindex') ?? '-1', 10) >= 0) return; + if (KB_INTERACTIVE_ROLES.has(el.getAttribute('role'))) return; + } + if (e.key === ' ') { + e.preventDefault(); + actionMenuApi?.generateRandom?.(); + } + // Cmd/Ctrl+Z undo/redo handled by createColorToolLayout + }; + document.addEventListener('keydown', onKeyDown); + keyboardCleanup = () => document.removeEventListener('keydown', onKeyDown); + tabs.setPanelEntryFocus('primary-color', () => { primaryColorAdapter?.element?.shadowRoot?.querySelector('.bc-mode-trigger')?.focus(); }); diff --git a/express/code/scripts/color-shared/components/createActionMenuComponent.js b/express/code/scripts/color-shared/components/createActionMenuComponent.js index 15553b022..c8e8b59cc 100644 --- a/express/code/scripts/color-shared/components/createActionMenuComponent.js +++ b/express/code/scripts/color-shared/components/createActionMenuComponent.js @@ -389,6 +389,7 @@ export async function createActionMenuComponent(options = {}) { getCurrentPalette: getCurrentPaletteFn, undo: handleUndoState, redo: handleRedoState, + generateRandom: handleGenerateRandom, destroy() { document.removeEventListener(eventName, handleHistoryIndexChanged); container.remove(); diff --git a/express/code/scripts/color-shared/shell/layouts/createColorToolLayout.js b/express/code/scripts/color-shared/shell/layouts/createColorToolLayout.js index 8525393b8..e49f12ac6 100644 --- a/express/code/scripts/color-shared/shell/layouts/createColorToolLayout.js +++ b/express/code/scripts/color-shared/shell/layouts/createColorToolLayout.js @@ -14,6 +14,29 @@ const DEFAULT_LAYOUT_SPANS = { const TOOLBAR_VARIANTS = new Set(['inline', 'standalone', 'sticky', 'sticky-on-scroll']); +const KB_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A']); +const KB_ROLES = new Set([ + 'button', 'link', 'checkbox', 'menuitem', 'option', + 'radio', 'tab', 'slider', 'spinbutton', 'textbox', 'combobox', +]); + +function makeUndoRedoHandler(actionMenu) { + return (e) => { + if (!e.metaKey && !e.ctrlKey) return; + if (e.key !== 'z') return; + const el = document.activeElement; + if (el && el !== document.body && el !== document.documentElement) { + if (KB_TAGS.has(el.tagName)) return; + if (el.isContentEditable) return; + if (parseInt(el.getAttribute('tabindex') ?? '-1', 10) >= 0) return; + if (KB_ROLES.has(el.getAttribute('role'))) return; + } + e.preventDefault(); + if (e.shiftKey) actionMenu.redo?.(); + else actionMenu.undo?.(); + }; +} + const LAYOUT_DEPS = { critical: ['scripts/color-shared/shell/layouts/styles/color-tool-layout.css'], deferred: ['scripts/color-shared/action-menu.css'], @@ -245,6 +268,7 @@ function createLayoutAPI(slots, shell, root) { actionMenu: null, toolbar: null, onPaletteChange: () => {}, + kbCleanup: null, }; const layout = { @@ -278,6 +302,8 @@ function createLayoutAPI(slots, shell, root) { destroy() { state.destroyed = true; + state.kbCleanup?.(); + state.kbCleanup = null; if (state.onPaletteChange) shell.context.off('palette', state.onPaletteChange); state.actionMenu?.destroy(); state.toolbar?.destroy(); @@ -333,7 +359,14 @@ export default async function createColorToolLayout(container, config = {}) { // Each callback guards against early destroy so it never mutates a torn-down layout. layout.actionMenuReady = mountActionMenu(slots.topbar, actionMenuConfig, actionMenuModulePromise) .then((handle) => { - if (!state.destroyed) state.actionMenu = handle; + if (!state.destroyed) { + state.actionMenu = handle; + if (handle) { + const onKeyDown = makeUndoRedoHandler(handle); + document.addEventListener('keydown', onKeyDown); + state.kbCleanup = () => document.removeEventListener('keydown', onKeyDown); + } + } return handle; }) .catch(() => null);