/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/service/standard-actions-impl.js
624 строки
18 KB
Viktor Szépe
Fix typos in src/ (#39606)
12 авг 2025, 21:35
Не верифицирован
12 авг 2025, 21:35
fca692e
Код
Авторство
О чём код?
import {devAssertElement} from '#core/assert'; import {ActionTrust_Enum} from '#core/constants/action-constants'; import {tryFocus} from '#core/dom'; import {Layout_Enum, getLayoutClass} from '#core/dom/layout'; import {computedStyle, toggle} from '#core/dom/style'; import {isFiniteNumber} from '#core/types'; import {getWin} from '#core/window'; import { copyTextToClipboard, isCopyingToClipboardSupported, } from '#core/window/clipboard'; import {Services} from '#service'; import {createCustomEvent} from '#utils/event-helper'; import {dev, user, userAssert} from '#utils/log'; import {getAmpdoc, registerServiceBuilderForDoc} from '../service-helpers'; /** * @param {!Element} element * @return {boolean} */ function isShowable(element) { return element.hasAttribute('hidden'); } /** * @param {!Element} element * @return {?Element} * @visibleForTesting */ export function getAutofocusElementForShowAction(element) { if (element.hasAttribute('autofocus')) { return element; } return element.querySelector('[autofocus]'); } /** @const {string} */ const TAG = 'STANDARD-ACTIONS'; /** * Regular expression that identifies AMP CSS classes with 'i-amphtml-' prefixes. * @type {!RegExp} */ const AMP_CSS_RE = /^i-amphtml-/; /** * This service contains implementations of some of the most typical actions, * such as hiding DOM elements. * @visibleForTesting */ export class StandardActions { /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ constructor(ampdoc) { /** @const {!./ampdoc-impl.AmpDoc} */ this.ampdoc = ampdoc; const context = ampdoc.getHeadNode(); /** @const @private {!./mutator-interface.MutatorInterface} */ this.mutator_ = Services.mutatorForDoc(ampdoc); /** @const @private {!./viewport/viewport-interface.ViewportInterface} */ this.viewport_ = Services.viewportForDoc(ampdoc); // Explicitly not setting `Action` as a member to scope installation to one // method and for bundle size savings. 💰 this.installActions_(Services.actionServiceForDoc(context)); this.initThemeMode_(); } /** * @param {!./action-impl.ActionService} actionService * @private */ installActions_(actionService) { actionService.addGlobalTarget('AMP', this.handleAmpTarget_.bind(this)); // All standard actions require high trust by default via // addGlobalMethodHandler. actionService.addGlobalMethodHandler('hide', this.handleHide_.bind(this)); actionService.addGlobalMethodHandler('show', this.handleShow_.bind(this)); actionService.addGlobalMethodHandler( 'toggleVisibility', this.handleToggle_.bind(this) ); actionService.addGlobalMethodHandler( 'scrollTo', this.handleScrollTo_.bind(this) ); actionService.addGlobalMethodHandler('focus', this.handleFocus_.bind(this)); actionService.addGlobalMethodHandler( 'toggleClass', this.handleToggleClass_.bind(this) ); actionService.addGlobalMethodHandler('copy', this.handleCopy_.bind(this)); actionService.addGlobalMethodHandler( 'toggleChecked', this.handleToggleChecked_.bind(this) ); } /** * Handles initializing the theme mode. * * This method needs to be called on page load to set the `amp-dark-mode` * class on the body if the user prefers the dark mode. */ initThemeMode_() { if (this.prefersDarkMode_()) { this.ampdoc.waitForBodyOpen().then((body) => { const darkModeClass = body.getAttribute('data-prefers-dark-mode-class') || 'amp-dark-mode'; body.classList.add(darkModeClass); }); } } /** * Checks whether the user prefers dark mode based on local storage and * user's operating system settings. * * @return {boolean} */ prefersDarkMode_() { try { const themeMode = this.ampdoc.win.localStorage.getItem('amp-dark-mode'); if (themeMode) { return 'yes' === themeMode; } } catch (e) {} // LocalStorage may not be accessible return this.ampdoc.win.matchMedia?.('(prefers-color-scheme: dark)').matches; } /** * Handles global `AMP` actions. * See `amp-actions-and-events.md` for details. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @throws If the invocation method is unrecognized. * @private Visible to tests only. */ handleAmpTarget_(invocation) { // All global `AMP` actions require default trust. if (!invocation.satisfiesTrust(ActionTrust_Enum.DEFAULT)) { return null; } const {args, method, node} = invocation; const win = getWin(node); switch (method) { case 'pushState': case 'setState': const element = node.nodeType === Node.DOCUMENT_NODE ? /** @type {!Document} */ (node).documentElement : dev().assertElement(node); return Services.bindForDocOrNull(element).then((bind) => { userAssert(bind, 'AMP-BIND is not installed.'); return bind.invoke(invocation); }); case 'navigateTo': return this.handleNavigateTo_(invocation); case 'closeOrNavigateTo': return this.handleCloseOrNavigateTo_(invocation); case 'scrollTo': userAssert(args['id'], 'AMP.scrollTo must provide element ID'); invocation.node = dev().assertElement( getAmpdoc(node).getElementById(args['id']), 'scrollTo element ID must exist on page' ); return this.handleScrollTo_(invocation); case 'goBack': Services.historyForDoc(this.ampdoc).goBack( /* navigate */ !!(args && args['navigate'] === true) ); return null; case 'print': win.print(); return null; case 'copy': return this.handleCopy_(invocation); case 'optoutOfCid': return Services.cidForDoc(this.ampdoc) .then((cid) => cid.optOut()) .catch((reason) => { dev().error(TAG, 'Failed to opt out of CID', reason); }); case 'toggleTheme': this.handleToggleTheme_(); return null; } throw user().createError('Unknown AMP action ', method); } /** * Handles the copy to clipboard action * @param {!./action-impl.ActionInvocation} invocation */ handleCopy_(invocation) { const {args, node} = invocation; const win = getWin(node); /** @enum {string} */ const CopyEvents = { COPY_ERROR: 'copy-error', COPY_SUCCESS: 'copy-success', }; let textToCopy; if (invocation.tagOrTarget === 'AMP') { // // Copy Static Text // Example: AMP.copy(text='TextToCopy'); // textToCopy = args['text'].trim(); } else { // // Copy Target Element Text // Example: targetId.copy(); // const target = devAssertElement(invocation.node); textToCopy = (target.value ?? target.textContent).trim(); } /** * Raises a status event for copy task result * @param {string} eventName * @param {string} eventResult * @param {!./action-impl.ActionInvocation} invocation */ const triggerEvent = function (eventName, eventResult, invocation) { const eventValue = /** @type {!JsonObject} */ ({ data: /** @type {!JsonObject} */ {type: eventResult}, }); const copyEvent = createCustomEvent(win, `${eventName}`, eventValue); const action_ = Services.actionServiceForDoc(invocation.caller); action_.trigger( invocation.caller, eventName, copyEvent, ActionTrust_Enum.HIGH ); }; // // Trigger Event based on copy action // - If content got copied to the clipboard successfully, it will // fire `copy-success` event with data type `success`. // - If there's any error in copying, it will // fire `copy-error` event with data type `error`. // - If browser is not supporting the copy function/action, it // will fire `copy-error` event with data type `browser`. // // Example: <button on="tap:AMP.copy(text='Hello AMP');copy-success:copied.show()">Copy</button> // if (isCopyingToClipboardSupported(win.document)) { copyTextToClipboard( win, textToCopy, () => { triggerEvent(CopyEvents.COPY_SUCCESS, 'success', invocation); }, () => { // Error encountered while copying. triggerEvent(CopyEvents.COPY_ERROR, 'error', invocation); } ); } else { // Copy is disabled or not supported by user. triggerEvent(CopyEvents.COPY_ERROR, 'unsupported', invocation); } } /** * Handles the `navigateTo` action. * @param {!./action-impl.ActionInvocation} invocation * @return {!Promise} * @private Visible to tests only. */ handleNavigateTo_(invocation) { const {args, caller, method, node} = invocation; const win = getWin(node); // Some components have additional constraints on allowing navigation. let permission = Promise.resolve(); if (caller.tagName.startsWith('AMP-')) { const ampElement = /** @type {!AmpElement} */ (caller); permission = ampElement.getImpl().then((impl) => { if (typeof impl.throwIfCannotNavigate == 'function') { impl.throwIfCannotNavigate(); } }); } return permission.then( () => { Services.navigationForDoc(this.ampdoc).navigateTo( win, args['url'], `AMP.${method}`, {target: args['target'], opener: args['opener']} ); }, /* onrejected */ (e) => { user().error(TAG, e); } ); } /** * Handles the `toggleTheme` action. * * This action sets the `amp-dark-mode` class on the body element and stores the the preference for dark mode in localstorage. */ handleToggleTheme_() { this.ampdoc.waitForBodyOpen().then((body) => { try { const darkModeClass = body.getAttribute('data-prefers-dark-mode-class') || 'amp-dark-mode'; if (this.prefersDarkMode_()) { body.classList.remove(darkModeClass); this.ampdoc.win.localStorage.setItem('amp-dark-mode', 'no'); } else { body.classList.add(darkModeClass); this.ampdoc.win.localStorage.setItem('amp-dark-mode', 'yes'); } } catch (e) { // LocalStorage may not be accessible. } }); } /** * Handles the `handleCloseOrNavigateTo_` action. * This action tries to close the requesting window if allowed, otherwise * navigates the window. * * Window can be closed only from top-level documents that have an opener. * Without an opener or if embedded, it will deny the close method. * @param {!./action-impl.ActionInvocation} invocation * @return {!Promise} * @private Visible to tests only. */ handleCloseOrNavigateTo_(invocation) { const {node} = invocation; const win = getWin(node); // Don't allow closing if embedded in iframe or does not have an opener or // embedded in a multi-doc shadowDOM case. // Note that browser denies win.close in some of these cases already anyway, // so not every check here is strictly needed but works as a short-circuit. const hasParent = win.parent != win; const canBeClosed = win.opener && this.ampdoc.isSingleDoc() && !hasParent; let wasClosed = false; if (canBeClosed) { // Browser may still deny win.close() call, that would be reflected // synchronously in win.closed win.close(); wasClosed = win.closed; } if (!wasClosed) { return this.handleNavigateTo_(invocation); } return Promise.resolve(); } /** * Handles the `scrollTo` action where given an element, we smooth scroll to * it with the given animation duration. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleScrollTo_(invocation) { const node = dev().assertElement(invocation.node); const {args} = invocation; // Duration and position are optional. // Default values are set by the viewport service, so they're passed through // when undefined or invalid. let posOrUndef = args && args['position']; let durationOrUndef = args && args['duration']; if (posOrUndef && !['top', 'bottom', 'center'].includes(posOrUndef)) { posOrUndef = undefined; } if (!isFiniteNumber(durationOrUndef)) { durationOrUndef = undefined; } // Animate the scroll // Should return a promise instead of null return this.viewport_.animateScrollIntoView( node, posOrUndef, durationOrUndef ); } /** * Handles the `focus` action where given an element, we give it focus * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleFocus_(invocation) { const node = dev().assertElement(invocation.node); // Set focus tryFocus(node); return null; } /** * Handles "hide" action. This is a very simple action where "display: none" * is applied to the target element. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleHide_(invocation) { const target = dev().assertElement(invocation.node); if (target.classList.contains('i-amphtml-element')) { const ampElement = /** @type {!AmpElement} */ (target); this.mutator_.mutateElement( ampElement, () => ampElement./*OK*/ collapse(), // It is safe to skip measuring, because `mutator-impl.collapseElement` // will set the size of the element as well as trigger a remeasure of // everything below the collapsed element. /* skipRemeasure */ true ); } else { this.mutator_.mutateElement(target, () => toggle(target, false)); } return null; } /** * Handles "show" action. This is a very simple action where "display: none" * is removed from the target element. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleShow_(invocation) { const {node} = invocation; const target = dev().assertElement(node); const ownerWindow = getWin(target); if (target.classList.contains(getLayoutClass(Layout_Enum.NODISPLAY))) { user().warn( TAG, 'Elements with layout=nodisplay cannot be dynamically shown.', target ); return null; } this.mutator_.measureElement(() => { if ( computedStyle(ownerWindow, target).display == 'none' && !isShowable(target) ) { user().warn( TAG, 'Elements can only be dynamically shown when they have the ' + '"hidden" attribute set or when they were dynamically hidden.', target ); } }); const autofocusElOrNull = getAutofocusElementForShowAction(target); // iOS only honors focus in sync operations. if (autofocusElOrNull && Services.platformFor(ownerWindow).isIos()) { this.handleShowSync_(target, autofocusElOrNull); this.mutator_.mutateElement(target, () => {}); // force a remeasure } else { this.mutator_.mutateElement(target, () => { this.handleShowSync_(target, autofocusElOrNull); }); } return null; } /** * @param {!Element} target * @param {?Element} autofocusElOrNull * @private Visible to tests only. */ handleShowSync_(target, autofocusElOrNull) { if (target.classList.contains('i-amphtml-element')) { const ampElement = /** @type {!AmpElement} */ (target); ampElement./*OK*/ expand(); } else { toggle(target, true); } if (autofocusElOrNull) { tryFocus(autofocusElOrNull); } } /** * Handles "toggle" action. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleToggle_(invocation) { if (isShowable(dev().assertElement(invocation.node))) { return this.handleShow_(invocation); } return this.handleHide_(invocation); } /** * Handles "toggleClass" action. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleToggleClass_(invocation) { const target = dev().assertElement(invocation.node); const {args} = invocation; const className = user().assertString( args['class'], "Argument 'class' must be a string." ); // prevent toggling of amp internal classes if (AMP_CSS_RE.test(className)) { return null; } this.mutator_.mutateElement(target, () => { if (args['force'] !== undefined) { // must be boolean, won't do type conversion const shouldForce = user().assertBoolean( args['force'], "Optional argument 'force' must be a boolean." ); target.classList.toggle(className, shouldForce); } else { target.classList.toggle(className); } }); return null; } /** * Handles "toggleChecked" action. * @param {!./action-impl.ActionInvocation} invocation * @return {?Promise} * @private Visible to tests only. */ handleToggleChecked_(invocation) { const target = dev().assertElement(invocation.node); const {args} = invocation; this.mutator_.mutateElement(target, () => { if (args?.['force'] !== undefined) { // must be boolean, won't do type conversion const shouldForce = user().assertBoolean( args['force'], "Optional argument 'force' must be a boolean." ); target.checked = shouldForce; } else { if (target.checked === true) { target.checked = false; } else { target.checked = true; } } }); return null; } } /** * @param {!./ampdoc-impl.AmpDoc} ampdoc */ export function installStandardActionsForDoc(ampdoc) { registerServiceBuilderForDoc( ampdoc, 'standard-actions', StandardActions, /* opt_instantiate */ true ); }