From 084c1d97cc3254f80ec0d61ec46a1f5f18ef0883 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 13 Dec 2023 10:18:58 +0000 Subject: [PATCH] Merge changes published in the Gutenberg plugin "release/17.3" branch --- .github/workflows/end2end-test.yml | 2 + bin/plugin/commands/performance.js | 1 + changelog.txt | 692 +++++ docs/README.md | 40 +- docs/getting-started/fundamentals/README.md | 4 +- .../fundamentals/block-json.md | 34 +- .../fundamentals/block-wrapper.md | 12 +- .../fundamentals/file-structure-of-a-block.md | 28 +- .../javascript-in-the-block-editor.md | 2 +- .../fundamentals/registration-of-a-block.md | 6 +- docs/how-to-guides/themes/theme-json.md | 4 +- docs/manifest.json | 6 + .../block-api/block-metadata.md | 2 +- docs/reference-guides/core-blocks.md | 2 +- .../data/data-core-edit-post.md | 20 +- .../reference-guides/data/data-core-editor.md | 61 +- .../theme-json-reference/theme-json-living.md | 2 +- gutenberg.php | 2 +- lib/block-supports/background.php | 3 + lib/block-supports/layout.php | 3 +- lib/block-supports/pattern.php | 36 + lib/class-wp-theme-json-gutenberg.php | 2 + .../html-api/class-wp-html-token.php | 9 - .../class-wp-navigation-block-renderer.php | 4 +- ...ass-gutenberg-html-attribute-token-6-5.php | 116 + .../class-gutenberg-html-span-6-5.php | 56 + ...class-gutenberg-html-tag-processor-6-5.php | 2483 +++++++++++++++++ ...ss-gutenberg-html-text-replacement-6-5.php | 64 + lib/compat/wordpress-6.5/rest-api.php | 6 +- lib/experimental/blocks.php | 39 +- lib/experimental/connection-sources/index.php | 8 +- lib/experimental/editor-settings.php | 4 + .../class-wp-directive-processor.php | 8 +- .../interactivity-api/modules.php | 12 - .../modules/class-gutenberg-modules.php | 23 + lib/experiments-page.php | 12 + lib/load.php | 5 + package-lock.json | 173 +- package.json | 4 +- packages/base-styles/_z-index.scss | 5 +- packages/block-editor/README.md | 13 +- .../src/components/block-canvas/index.js | 48 +- .../src/components/block-caption/README.md | 4 +- .../src/components/block-card/index.js | 8 +- .../src/components/block-card/style.scss | 10 +- .../src/components/block-controls/hook.js | 38 +- .../components/block-controls/test/index.js | 6 +- .../src/components/block-edit/context.js | 3 + .../src/components/block-edit/index.js | 46 +- .../components/block-info-slot-fill/index.js | 9 +- .../src/components/block-inspector/style.scss | 4 - .../src/components/block-list/block.js | 44 +- .../src/components/block-list/block.native.js | 22 +- .../block-list/use-block-props/index.js | 95 +- .../use-block-props/use-block-class-names.js | 66 - .../use-block-custom-class-name.js | 44 - .../use-block-default-class-name.js | 35 - .../use-focus-first-element.js | 35 +- .../use-block-props/use-is-hovered.js | 15 +- .../use-selected-block-event-handlers.js | 6 +- .../block-parent-selector/style.scss | 11 - .../components/block-patterns-list/index.js | 2 +- .../block-removal-warning-modal/index.js | 21 +- .../src/components/block-rename/modal.js | 8 +- .../block-settings/container.native.js | 8 +- .../src/components/block-styles/index.js | 2 +- .../components/block-styles/index.native.js | 6 +- .../src/components/block-styles/style.scss | 11 - .../test/__snapshots__/index.js.snap | 4 +- .../src/components/block-toolbar/index.js | 275 +- .../src/components/block-toolbar/style.scss | 116 +- .../src/components/block-tools/back-compat.js | 35 - .../block-tools/block-contextual-toolbar.js | 100 - .../block-tools/block-toolbar-breadcrumb.js | 46 + .../block-tools/block-toolbar-popover.js | 90 + .../src/components/block-tools/index.js | 71 +- .../block-tools/selected-block-tools.js | 127 - .../src/components/block-tools/style.scss | 232 +- .../block-types-list/index.native.js | 3 +- .../block-variation-picker/index.native.js | 2 +- .../components/colors-gradients/control.js | 79 +- .../components/colors-gradients/style.scss | 7 - .../src/components/duotone-control/index.js | 7 +- .../src/components/duotone-control/style.scss | 7 +- .../components/global-styles/color-panel.js | 59 +- .../components/global-styles/filters-panel.js | 12 +- .../global-styles/typography-panel.js | 66 +- .../image-link-destinations/index.native.js} | 8 +- .../image-link-destinations/style.native.scss | 16 + .../src/components/index.native.js | 1 + .../src/components/inner-blocks/README.md | 15 +- .../src/components}/inserter-button/README.md | 0 .../inserter-button/index.native.js | 2 +- .../components}/inserter-button/sparkles.js | 2 +- .../inserter-button/style.native.scss | 0 .../inserter-draggable-blocks/index.js | 23 +- .../inserter/hooks/use-debounced-input.js | 18 - .../inserter/media-tab/media-panel.js | 2 +- .../src/components/inserter/menu.js | 24 +- .../src/components/inserter/preview-panel.js | 4 +- .../src/components/inserter/style.scss | 32 +- .../src/components/inspector-controls/fill.js | 9 +- .../inspector-controls/fill.native.js | 9 +- .../src/components/link-control/style.scss | 2 +- .../src/components/link-control/test/index.js | 2 +- .../components/navigable-toolbar/README.md | 2 + .../src/components/navigable-toolbar/index.js | 4 +- .../src/components/preview-options/README.md | 94 - .../src/components/preview-options/index.js | 92 +- .../src/components/preview-options/style.scss | 64 - .../src/components/rich-text/content.js | 47 +- .../rich-text/get-rich-text-values.js | 7 +- .../src/components/rich-text/index.js | 51 +- .../src/components/rich-text/index.native.js | 33 +- .../native/get-format-colors.native.js | 73 +- .../rich-text/native/index.native.js | 55 +- .../components/rich-text/use-input-rules.js | 7 +- .../components/rich-text/with-deprecations.js | 51 + .../components/use-block-drop-zone/index.js | 128 +- .../use-display-block-controls/index.js | 36 - .../index.native.js | 37 - .../src/components/use-on-block-drop/index.js | 3 +- .../components/use-resize-canvas/README.md | 6 +- .../src/components/use-resize-canvas/index.js | 5 +- .../src/components/use-settings/index.js | 16 +- packages/block-editor/src/hooks/align.js | 91 +- .../block-editor/src/hooks/align.native.js | 1 + packages/block-editor/src/hooks/anchor.js | 46 +- packages/block-editor/src/hooks/background.js | 51 +- .../block-editor/src/hooks/block-hooks.js | 73 +- .../block-editor/src/hooks/block-renaming.js | 60 +- packages/block-editor/src/hooks/border.js | 185 +- packages/block-editor/src/hooks/color.js | 232 +- .../block-editor/src/hooks/content-lock-ui.js | 232 +- .../src/hooks/custom-class-name.js | 48 +- .../block-editor/src/hooks/custom-fields.js | 67 +- packages/block-editor/src/hooks/dimensions.js | 36 +- packages/block-editor/src/hooks/duotone.js | 197 +- .../block-editor/src/hooks/font-family.js | 39 +- packages/block-editor/src/hooks/font-size.js | 228 +- packages/block-editor/src/hooks/index.js | 56 +- .../block-editor/src/hooks/index.native.js | 9 +- .../block-editor/src/hooks/layout-child.js | 53 + packages/block-editor/src/hooks/layout.js | 115 +- packages/block-editor/src/hooks/padding.js | 4 +- packages/block-editor/src/hooks/position.js | 140 +- packages/block-editor/src/hooks/style.js | 304 +- packages/block-editor/src/hooks/test/align.js | 179 +- packages/block-editor/src/hooks/test/color.js | 112 - packages/block-editor/src/hooks/typography.js | 36 +- packages/block-editor/src/hooks/utils.js | 193 +- packages/block-editor/src/private-apis.js | 2 - .../block-editor/src/store/private-actions.js | 8 + .../src/store/private-selectors.js | 45 + packages/block-editor/src/store/reducer.js | 8 + packages/block-editor/src/store/selectors.js | 74 +- packages/block-editor/src/store/utils.js | 74 + packages/block-editor/src/style.scss | 2 - packages/block-editor/src/utils/object.js | 87 +- packages/block-editor/src/utils/selection.js | 11 +- packages/block-library/src/audio/block.json | 4 +- packages/block-library/src/audio/edit.js | 85 +- .../block-library/src/audio/edit.native.js | 3 +- packages/block-library/src/block/edit.js | 159 +- packages/block-library/src/block/index.js | 5 +- packages/block-library/src/block/index.php | 48 + packages/block-library/src/block/v1/edit.js | 163 ++ .../src/block/{ => v1}/edit.native.js | 4 +- packages/block-library/src/button/block.json | 4 +- packages/block-library/src/button/save.js | 2 +- packages/block-library/src/code/block.json | 4 +- .../block-library/src/code/edit.native.js | 24 +- packages/block-library/src/code/save.js | 5 +- .../src/code/test/edit.native.js | 4 +- .../src/cover/test/edit.native.js | 8 +- packages/block-library/src/details/block.json | 4 +- packages/block-library/src/embed/block.json | 4 +- .../src/embed/embed-preview.native.js | 3 +- packages/block-library/src/embed/icons.js | 2 +- packages/block-library/src/file/block.json | 8 +- packages/block-library/src/file/edit.js | 2 +- .../block-library/src/file/edit.native.js | 2 +- packages/block-library/src/file/save.js | 6 +- .../block-library/src/form-input/block.json | 4 +- .../src/form-input/deprecated.js | 142 + packages/block-library/src/form-input/edit.js | 2 +- .../block-library/src/form-input/index.js | 2 + packages/block-library/src/form-input/save.js | 51 +- packages/block-library/src/gallery/block.json | 8 +- packages/block-library/src/gallery/edit.js | 63 +- packages/block-library/src/gallery/gallery.js | 55 +- .../src/gallery/gallery.native.js | 8 +- .../src/gallery/v1/gallery.native.js | 3 +- packages/block-library/src/group/edit.js | 5 +- packages/block-library/src/heading/block.json | 9 +- packages/block-library/src/image/block.json | 4 +- .../block-library/src/image/edit.native.js | 5 +- packages/block-library/src/image/image.js | 117 +- packages/block-library/src/image/save.js | 4 +- .../block-library/src/list-item/block.json | 13 +- .../test/__snapshots__/hooks.js.snap | 9 +- .../src/navigation/edit/index.js | 9 +- .../block-library/src/navigation/index.php | 2 +- .../src/page-list/convert-to-links-modal.js | 4 +- .../block-library/src/paragraph/block.json | 5 +- packages/block-library/src/paragraph/edit.js | 93 +- .../block-library/src/post-title/block.json | 4 +- .../block-library/src/preformatted/block.json | 5 +- .../block-library/src/pullquote/block.json | 12 +- .../block-library/src/query-title/block.json | 4 +- .../inspector-controls/taxonomy-controls.js | 2 + packages/block-library/src/quote/block.json | 8 +- .../block-library/src/quote/transforms.js | 23 +- .../block-library/src/site-title/block.json | 6 +- .../src/social-link/icons/gravatar.js | 10 + .../src/social-link/icons/index.js | 1 + .../block-library/src/social-link/index.php | 4 + .../src/social-link/socials-with-bg.scss | 5 + .../src/social-link/socials-without-bg.scss | 4 + .../src/social-link/variations.js | 7 + .../block-library/src/social-links/style.scss | 22 +- .../src/table-of-contents/edit.js | 2 +- .../src/table-of-contents/icon.js | 18 - .../src/table-of-contents/index.js | 6 +- packages/block-library/src/table/block.json | 19 +- packages/block-library/src/table/edit.js | 4 +- packages/block-library/src/table/editor.scss | 15 +- packages/block-library/src/utils/caption.js | 108 + .../src/utils/remove-anchor-tag.js | 3 +- packages/block-library/src/verse/block.json | 8 +- packages/block-library/src/video/block.json | 4 +- packages/block-library/src/video/edit.js | 86 +- .../block-library/src/video/edit.native.js | 3 +- packages/blocks/package.json | 1 + packages/blocks/src/api/matchers.js | 12 + .../src/api/parser/get-block-attributes.js | 25 +- .../src/api/raw-handling/html-to-blocks.js | 13 + .../api/raw-handling/test/paste-handler.js | 4 +- packages/blocks/src/api/utils.js | 42 +- packages/commands/README.md | 2 - packages/commands/src/store/index.js | 2 - packages/components/CHANGELOG.md | 37 +- .../border-control-dropdown/component.tsx | 4 +- .../border-control-dropdown/hook.ts | 5 +- .../components/src/border-control/styles.ts | 11 +- .../components/src/checkbox-control/README.md | 3 +- .../components/src/checkbox-control/index.tsx | 14 +- .../test/__snapshots__/index.tsx.snap | 11 +- .../src/checkbox-control/test/index.tsx | 7 + .../components/src/checkbox-control/types.ts | 5 +- .../src/custom-select-control/test/index.js | 402 ++- .../src/date-time/time/timezone.tsx | 18 +- .../src/dimension-control/index.tsx | 2 + .../test/__snapshots__/index.test.js.snap | 4 +- .../components/src/dimension-control/types.ts | 6 + .../src/dropdown-menu-v2-ariakit/styles.ts | 12 + .../src/focal-point-picker/controls.tsx | 4 + .../src/focal-point-picker/index.tsx | 2 + .../styles/focal-point-picker-style.ts | 2 +- .../src/focal-point-picker/types.ts | 7 + .../font-size-picker-select.tsx | 2 + .../font-size-picker-toggle-group.tsx | 10 +- .../components/src/font-size-picker/index.tsx | 14 +- .../components/src/font-size-picker/types.ts | 9 +- .../components/src/form-toggle/style.scss | 6 +- packages/components/src/index.native.js | 2 - .../test/utils.native.js | 22 + .../global-styles-context/utils.native.js | 14 + .../mobile/link-settings/style.native.scss | 17 - .../components/src/palette-edit/index.tsx | 30 +- .../components/src/palette-edit/style.scss | 4 +- .../src/palette-edit/test/index.tsx | 76 +- .../src/query-controls/author-select.tsx | 2 + .../src/query-controls/category-select.tsx | 2 + .../components/src/query-controls/index.tsx | 7 +- .../components/src/query-controls/types.ts | 9 + .../components/src/search-control/README.md | 2 + packages/components/src/spinner/README.md | 2 + packages/components/src/tabs/README.md | 8 +- packages/components/src/tabs/index.tsx | 23 +- .../src/tabs/stories/index.story.tsx | 96 +- packages/components/src/tabs/tab.tsx | 6 +- packages/components/src/tabs/tabpanel.tsx | 10 +- packages/components/src/tabs/test/index.tsx | 286 +- packages/components/src/tabs/types.ts | 11 +- .../src/toggle-group-control/test/index.tsx | 55 +- .../toggle-group-control/utils.ts | 35 +- .../src/tools-panel/tools-panel-item/hook.ts | 31 +- packages/compose/README.md | 12 + .../src/hooks/use-debounced-input/index.js} | 12 +- packages/compose/src/index.js | 1 + packages/compose/src/index.native.js | 1 + packages/core-data/src/entities.js | 20 - .../src/footnotes/get-footnotes-order.js | 17 +- packages/core-data/src/footnotes/index.js | 22 +- packages/core-data/src/reducer.js | 72 +- packages/core-data/src/resolvers.js | 137 +- .../CHANGELOG.md | 3 + .../block-templates/render.php.mustache | 4 +- .../index.js | 1 + .../plugin-templates/$slug.php.mustache | 14 +- .../src/components/header/index.js | 27 +- .../src/components/header/style.scss | 2 +- .../components/sidebar-block-editor/index.js | 31 +- .../sidebar-block-editor/style.scss | 20 - packages/dataviews/.npmrc | 1 + packages/dataviews/CHANGELOG.md | 3 + .../src/components => }/dataviews/README.md | 67 +- packages/dataviews/package.json | 48 + .../dataviews => dataviews/src}/add-filter.js | 6 +- packages/dataviews/src/constants.js | 50 + .../dataviews => dataviews/src}/dataviews.js | 44 +- packages/dataviews/src/filter-summary.js | 221 ++ .../dataviews => dataviews/src}/filters.js | 23 +- packages/dataviews/src/index.js | 2 + .../src}/item-actions.js | 7 +- packages/dataviews/src/lock-unlock.js | 10 + .../dataviews => dataviews/src}/pagination.js | 2 +- .../src}/reset-filters.js | 0 .../dataviews => dataviews/src}/search.js | 6 +- packages/dataviews/src/stories/fixtures.js | 126 + packages/dataviews/src/stories/index.story.js | 137 + packages/dataviews/src/style.scss | 245 ++ .../src}/view-actions.js | 93 +- .../dataviews => dataviews/src}/view-grid.js | 24 +- packages/dataviews/src/view-list.js | 99 + packages/dataviews/src/view-table.js | 425 +++ .../lib/util.js | 1 + .../src/editor/preview.ts | 4 +- .../src/disable-pre-publish-checks.js | 2 +- .../src/enable-pre-publish-checks.js | 6 +- packages/e2e-test-utils/src/preview.js | 8 +- .../router-navigate/render.php | 1 + .../router-regions/render.php | 1 + .../interactive-blocks/store-tag/render.php | 1 + .../plugins/innerblocks-locking-all-embed.js | 56 - .../editor/various/change-detection.test.js | 6 +- .../specs/editor/various/editor-modes.test.js | 15 +- .../specs/editor/various/preferences.test.js | 10 +- .../specs/editor/various/sidebar.test.js | 29 +- .../site-editor/multi-entity-saving.test.js | 239 -- .../site-editor/site-editor-export.test.js | 63 - .../src/components/device-preview/index.js | 73 - .../header/document-actions/index.js | 82 - .../header/document-actions/style.scss | 64 - .../components/header/header-toolbar/index.js | 4 + .../header/header-toolbar/style.scss | 28 +- .../edit-post/src/components/header/index.js | 41 +- .../components/header/mode-switcher/index.js | 3 +- .../src/components/header/more-menu/index.js | 1 + .../src/components/header/style.scss | 94 +- .../components/header/writing-menu/index.js | 34 +- .../test/__snapshots__/index.js.snap | 2 +- .../edit-post/src/components/layout/index.js | 25 +- .../src/components/preferences-modal/index.js | 205 +- .../preferences-modal/test/index.js | 53 +- .../components/sidebar/post-status/index.js | 4 +- .../components/sidebar/post-template/form.js | 141 - .../components/sidebar/post-template/index.js | 120 - .../sidebar/post-template/style.scss | 22 - .../sidebar/settings-header/index.js | 90 +- .../sidebar/settings-header/style.scss | 74 - .../sidebar/settings-sidebar/index.js | 171 +- .../src/components/sidebar/style.scss | 16 +- .../components/sidebar/template/style.scss | 35 - .../components/start-page-options/index.js | 6 +- .../src/components/visual-editor/index.js | 385 +-- .../src/components/visual-editor/style.scss | 15 - .../src/components/welcome-guide/index.js | 6 +- packages/edit-post/src/editor.js | 14 +- packages/edit-post/src/editor.native.js | 12 +- packages/edit-post/src/index.js | 5 +- .../plugins/welcome-guide-menu-item/index.js | 9 +- packages/edit-post/src/store/actions.js | 78 +- packages/edit-post/src/store/reducer.js | 33 - packages/edit-post/src/store/selectors.js | 32 +- packages/edit-post/src/store/test/actions.js | 28 - packages/edit-post/src/style.scss | 3 - .../test/__snapshots__/editor.native.js.snap | 21 + packages/edit-post/src/test/editor.native.js | 146 +- packages/edit-site/package.json | 2 +- .../add-custom-template-modal-content.js | 2 +- .../components/block-editor/editor-canvas.js | 110 +- .../block-editor/site-editor-canvas.js | 128 +- .../src/components/block-editor/style.scss | 18 +- .../block-editor/use-site-editor-settings.js | 83 +- .../src/components/dataviews/constants.js | 5 - .../components/dataviews/filter-summary.js | 79 - .../src/components/dataviews/index.js | 1 - .../src/components/dataviews/style.scss | 131 - .../src/components/dataviews/view-list.js | 512 ---- .../components/dataviews/view-side-by-side.js | 9 - .../edit-site/src/components/editor/index.js | 26 +- .../collection-font-variant.js | 18 +- .../font-library-modal/context.js | 2 +- .../library-font-variant.js | 18 +- .../screen-revisions/get-revision-changes.js | 171 ++ .../global-styles/screen-revisions/index.js | 15 +- .../screen-revisions/revisions-buttons.js | 103 +- .../global-styles/screen-revisions/style.scss | 12 +- .../test/get-revision-changes.js | 191 ++ .../document-actions/index.js | 204 -- .../header-edit-mode/document-tools/index.js | 26 +- .../src/components/header-edit-mode/index.js | 92 +- .../header-edit-mode/more-menu/index.js | 40 +- .../components/header-edit-mode/style.scss | 91 +- .../edit-site/src/components/layout/index.js | 41 +- .../edit-site/src/components/list/style.scss | 5 + .../back-to-page-notification.js | 58 - .../page-content-focus-notifications/index.js | 14 - .../src/components/page-pages/index.js | 94 +- .../src/components/page-pages/style.scss | 4 +- .../components/page-patterns/patterns-list.js | 7 +- .../page-patterns/rename-menu-item.js | 8 +- .../page-templates/dataviews-templates.js | 41 +- .../edit-site/src/components/page/header.js | 3 +- .../edit-site/src/components/page/style.scss | 4 +- .../src/components/preferences-modal/index.js | 92 +- .../src/components/routes/use-title.js | 13 +- .../src/components/save-button/index.js | 1 + .../sidebar-dataviews/dataview-item.js | 10 +- .../sidebar-dataviews/default-views.js | 4 +- .../page-panels/edit-template.js | 108 - .../sidebar-edit-mode/page-panels/index.js | 64 +- .../page-panels/page-summary.js | 4 +- .../sidebar-edit-mode/page-panels/style.scss | 43 +- .../rename-modal.js | 8 +- .../use-pattern-categories.js | 2 +- .../home-template-details.js | 97 +- .../index.js | 10 +- .../template-areas.js | 135 + .../edit-site/src/components/sidebar/index.js | 65 +- .../src/components/sidebar/style.scss | 22 +- .../src/components/site-hub/index.js | 9 +- .../template-actions/rename-menu-item.js | 8 +- .../src/components/welcome-guide/styles.js | 2 +- .../src/hooks/commands/use-common-commands.js | 15 +- .../src/hooks/navigation-menu-edit.js | 2 +- .../edit-site/src/hooks/template-part-edit.js | 2 +- packages/edit-site/src/store/actions.js | 20 +- packages/edit-site/src/store/reducer.js | 18 - packages/edit-site/src/store/selectors.js | 18 +- packages/edit-site/src/style.scss | 3 +- packages/edit-site/src/utils/constants.js | 8 + .../src/components/header/index.js | 7 +- .../src/components/header/style.scss | 27 +- .../index.js | 4 + .../index.js | 5 +- packages/editor/package.json | 1 + .../src/components/document-bar/index.js | 182 ++ .../src/components/document-bar}/style.scss | 61 +- .../edit-template-blocks-notification.js} | 12 +- .../src/components/editor-canvas/index.js | 381 +++ packages/editor/src/components/index.js | 4 +- .../components/post-publish-button/index.js | 1 + .../post-publish-panel/maybe-upload-media.js | 11 +- .../src/components/post-saved-state/index.js | 1 + .../test/__snapshots__/index.js.snap | 4 +- .../src/components/post-schedule/panel.js | 2 +- .../components/post-template/block-theme.js | 109 + .../components/post-template/classic-theme.js | 213 ++ .../create-new-template-modal.js} | 19 +- .../post-template/create-new-template.js | 50 + .../src/components/post-template}/hooks.js | 40 +- .../src/components/post-template/index.js | 64 - .../src/components/post-template/panel.js | 67 + .../post-template}/reset-default-template.js | 32 +- .../src/components/post-template/style.scss | 52 + .../post-template}/swap-template-button.js | 4 +- .../src/components/post-title/index.native.js | 1 - .../src/components/preview-dropdown/index.js | 136 + .../components/preview-dropdown/style.scss | 5 + .../editor/src/components/provider/index.js | 146 +- .../src/components/provider/index.native.js | 38 +- .../provider/navigation-block-editing-mode.js | 37 + .../provider/use-block-editor-settings.js | 15 +- packages/editor/src/hooks/index.js | 1 + .../src/hooks/pattern-partial-syncing.js | 73 + packages/editor/src/private-apis.js | 4 + packages/editor/src/store/actions.js | 60 +- packages/editor/src/store/defaults.js | 1 + packages/editor/src/store/index.js | 3 + packages/editor/src/store/private-actions.js | 61 + packages/editor/src/store/reducer.js | 59 +- packages/editor/src/store/reducer.native.js | 2 - packages/editor/src/store/selectors.js | 106 +- packages/editor/src/store/test/selectors.js | 235 +- packages/editor/src/style.scss | 3 + packages/env/CHANGELOG.md | 4 + packages/env/lib/cli.js | 4 +- packages/env/lib/commands/clean.js | 2 +- packages/env/lib/commands/destroy.js | 2 +- packages/env/lib/commands/logs.js | 2 +- packages/env/lib/commands/run.js | 7 +- packages/env/lib/commands/start.js | 2 +- packages/env/lib/commands/stop.js | 2 +- packages/env/lib/test/cli.js | 2 +- packages/env/lib/wordpress.js | 2 +- packages/env/package.json | 2 +- .../src/text-color/index.native.js | 4 +- .../src/text-color/test/index.native.js | 64 +- packages/icons/src/index.js | 1 + packages/icons/src/library/archive.js | 6 +- packages/icons/src/library/columns.js | 6 +- packages/icons/src/library/copy.js | 6 +- packages/icons/src/library/crop.js | 2 +- packages/icons/src/library/file.js | 6 +- packages/icons/src/library/page.js | 3 +- packages/icons/src/library/pages.js | 4 +- packages/icons/src/library/plus.js | 2 +- packages/icons/src/library/post-excerpt.js | 2 +- packages/icons/src/library/post-list.js | 2 +- .../icons/src/library/table-of-contents.js | 17 + packages/icons/src/library/tag.js | 2 +- .../interactivity/docs/1-getting-started.md | 38 +- .../components/complementary-area/index.js | 1 + .../components/more-menu-dropdown/index.js | 1 + .../src/components/pinned-items/style.scss | 2 +- .../components/preferences-modal/README.md | 6 +- packages/keycodes/package.json | 3 +- packages/keycodes/src/index.js | 27 +- .../src/components/media-upload/index.js | 67 +- packages/patterns/package.json | 3 +- .../components/partial-syncing-controls.js | 98 + .../rename-pattern-category-modal.js | 8 +- .../src/components/rename-pattern-modal.js | 13 +- packages/patterns/src/constants.js | 10 + packages/patterns/src/private-apis.js | 4 + packages/private-apis/src/implementation.js | 1 + .../react-native-aztec/android/build.gradle | 2 +- packages/react-native-aztec/package.json | 2 +- .../GutenbergBridgeJS2Parent.java | 6 + .../RNReactNativeGutenbergBridgeModule.java | 17 + .../WPAndroidGlue/DeferredEventEmitter.java | 9 + .../WPAndroidGlue/WPAndroidGlueCode.java | 17 + .../editor-style-overrides.css | 2 +- packages/react-native-bridge/index.js | 48 + .../react-native-bridge/ios/Gutenberg.swift | 5 + .../ios/GutenbergBridgeDelegate.swift | 2 + .../ios/RNReactNativeGutenbergBridge.m | 1 + .../ios/RNReactNativeGutenbergBridge.swift | 6 + packages/react-native-bridge/package.json | 2 +- packages/react-native-editor/CHANGELOG.md | 15 + .../java/com/gutenberg/MainApplication.java | 5 + .../GutenbergViewController.swift | 4 + packages/react-native-editor/ios/Podfile.lock | 8 +- packages/react-native-editor/package.json | 2 +- .../react-native-editor/src/jsdom-patches.js | 12 + packages/rich-text/README.md | 13 + packages/rich-text/src/component/index.js | 38 +- packages/rich-text/src/create.js | 129 +- packages/rich-text/src/index.ts | 2 +- packages/scripts/CHANGELOG.md | 4 + packages/scripts/config/webpack.config.js | 1 + packages/scripts/scripts/test-playwright.js | 31 +- packages/scripts/utils/index.js | 2 + packages/scripts/utils/process.js | 6 + phpunit/class-block-fixture-test.php | 8 + ...lobal-styles-revisions-controller-test.php | 121 +- ...lass-wp-navigation-block-renderer-test.php | 19 + platform-docs/docs/advanced/_category_.json | 10 - platform-docs/docs/advanced/create-format.md | 5 - platform-docs/docs/advanced/dynamic.md | 5 - platform-docs/docs/advanced/interactivity.md | 5 - platform-docs/docs/advanced/wordpress.md | 5 - platform-docs/docs/basic-concepts/ui.md | 2 +- .../docs/create-block/nested-blocks.md | 228 +- .../docs/create-block/writing-flow.md | 5 - .../src/components/HomepageFeatures/index.js | 13 +- .../HomepageFeatures/styles.module.css | 11 +- .../src/components/HomepageTrustedBy/index.js | 50 + .../HomepageTrustedBy/styles.module.css | 27 + platform-docs/src/pages/index.js | 2 + platform-docs/static/img/dayone.png | Bin 0 -> 3122 bytes platform-docs/static/img/tumblr.png | Bin 0 -> 12340 bytes platform-docs/static/img/wordpress.png | Bin 0 -> 102930 bytes schemas/json/block.json | 2 + schemas/json/theme.json | 8 + storybook/main.js | 1 + storybook/package-styles/config.js | 7 + .../package-styles/dataviews-ltr.lazy.scss | 1 + .../package-styles/dataviews-rtl.lazy.scss | 1 + storybook/stories/playground/box/index.js | 4 +- .../stories/playground/fullpage/index.js | 5 +- .../playground/with-undo-redo/index.js | 4 +- .../playground/with-undo-redo/style.css | 6 +- test/e2e/specs/editor/blocks/image.spec.js | 4 +- .../editor/plugins/block-context.spec.js | 6 +- .../editor/plugins/custom-post-types.spec.js | 2 +- .../inner-blocks-locking-all-embed.spec.js | 59 + test/e2e/specs/editor/various/a11y.spec.js | 11 - .../block-hierarchy-navigation.spec.js | 2 +- .../specs/editor/various/footnotes.spec.js | 4 +- .../editor/various/inserting-blocks.spec.js | 204 +- .../specs/editor/various/is-typing.spec.js | 40 +- .../various/keyboard-navigable-blocks.spec.js | 8 +- .../various/multi-entity-saving.spec.js | 210 ++ .../e2e/specs/editor/various/new-post.spec.js | 4 +- .../various/post-editor-template-mode.spec.js | 19 +- .../specs/editor/various/pref-modal.spec.js | 55 + test/e2e/specs/editor/various/preview.spec.js | 13 +- .../various/shortcut-focus-toolbar.spec.js | 4 + .../specs/site-editor/block-removal.spec.js | 6 +- .../specs/site-editor/font-library.spec.js | 21 + .../site-editor/new-templates-list.spec.js | 32 +- test/e2e/specs/site-editor/pages.spec.js | 23 +- .../site-editor/push-to-global-styles.spec.js | 10 + .../site-editor/site-editor-export.spec.js | 38 + test/e2e/specs/site-editor/title.spec.js | 20 +- .../user-global-styles-revisions.spec.js | 5 + .../fixtures/blocks/core__form-input.html | 4 +- .../fixtures/blocks/core__form-input.json | 11 +- .../blocks/core__form-input.parsed.json | 9 +- .../blocks/core__form-input.serialized.html | 4 +- .../core__form-input__deprecated-v1.html | 6 + .../core__form-input__deprecated-v1.json | 15 + ...ore__form-input__deprecated-v1.parsed.json | 11 + ..._form-input__deprecated-v1.serialized.html | 3 + .../core__form-submission-notification.html | 5 + .../core__form-submission-notification.json | 31 + ...__form-submission-notification.parsed.json | 35 + ...rm-submission-notification.serialized.html | 5 + .../blocks/core__form-submit-button.html | 7 + .../blocks/core__form-submit-button.json | 26 + .../core__form-submit-button.parsed.json | 38 + .../core__form-submit-button.serialized.html | 7 + .../fixtures/blocks/core__form.html | 28 +- .../fixtures/blocks/core__form.json | 79 +- .../fixtures/blocks/core__form.parsed.json | 78 +- .../blocks/core__form.serialized.html | 32 +- .../blocks/core__gallery__deprecated-1.json | 2 + .../blocks/core__social-link-gravatar.html | 1 + .../blocks/core__social-link-gravatar.json | 11 + .../core__social-link-gravatar.parsed.json | 11 + ...core__social-link-gravatar.serialized.html | 1 + .../documents/ms-word-online-out.html | 14 +- .../full-content/full-content.test.js | 18 + .../helpers/integration-test-editor.js | 5 +- .../non-matched-tags-handling.test.js | 8 +- .../blocks-raw-handling.native.js.snap | 212 ++ .../integration/blocks-raw-handling.native.js | 587 ++++ test/native/jest.config.js | 1 - test/native/setup.js | 1 + .../config/performance-reporter.ts | 3 + test/performance/specs/post-editor.spec.js | 125 +- test/unit/jest.config.js | 4 + test/unit/scripts/resolver.js | 3 +- tools/webpack/packages.js | 1 + 648 files changed, 16767 insertions(+), 10045 deletions(-) create mode 100644 lib/block-supports/pattern.php create mode 100644 lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php create mode 100644 lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php create mode 100644 lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php create mode 100644 lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php delete mode 100644 packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js delete mode 100644 packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js delete mode 100644 packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js delete mode 100644 packages/block-editor/src/components/block-parent-selector/style.scss delete mode 100644 packages/block-editor/src/components/block-tools/back-compat.js delete mode 100644 packages/block-editor/src/components/block-tools/block-contextual-toolbar.js create mode 100644 packages/block-editor/src/components/block-tools/block-toolbar-breadcrumb.js create mode 100644 packages/block-editor/src/components/block-tools/block-toolbar-popover.js delete mode 100644 packages/block-editor/src/components/block-tools/selected-block-tools.js rename packages/{components/src/mobile/link-settings/image-link-destinations-screen.native.js => block-editor/src/components/image-link-destinations/index.native.js} (95%) create mode 100644 packages/block-editor/src/components/image-link-destinations/style.native.scss rename packages/{components/src/mobile => block-editor/src/components}/inserter-button/README.md (100%) rename packages/{components/src/mobile => block-editor/src/components}/inserter-button/index.native.js (98%) rename packages/{components/src/mobile => block-editor/src/components}/inserter-button/sparkles.js (95%) rename packages/{components/src/mobile => block-editor/src/components}/inserter-button/style.native.scss (100%) delete mode 100644 packages/block-editor/src/components/inserter/hooks/use-debounced-input.js delete mode 100644 packages/block-editor/src/components/preview-options/README.md delete mode 100644 packages/block-editor/src/components/preview-options/style.scss create mode 100644 packages/block-editor/src/components/rich-text/with-deprecations.js delete mode 100644 packages/block-editor/src/components/use-display-block-controls/index.js delete mode 100644 packages/block-editor/src/components/use-display-block-controls/index.native.js create mode 100644 packages/block-editor/src/hooks/layout-child.js delete mode 100644 packages/block-editor/src/hooks/test/color.js create mode 100644 packages/block-editor/src/store/utils.js create mode 100644 packages/block-library/src/block/v1/edit.js rename packages/block-library/src/block/{ => v1}/edit.native.js (98%) create mode 100644 packages/block-library/src/form-input/deprecated.js create mode 100644 packages/block-library/src/social-link/icons/gravatar.js delete mode 100644 packages/block-library/src/table-of-contents/icon.js create mode 100644 packages/block-library/src/utils/caption.js rename packages/{edit-site/src/utils/use-debounced-input.js => compose/src/hooks/use-debounced-input/index.js} (59%) create mode 100644 packages/dataviews/.npmrc create mode 100644 packages/dataviews/CHANGELOG.md rename packages/{edit-site/src/components => }/dataviews/README.md (67%) create mode 100644 packages/dataviews/package.json rename packages/{edit-site/src/components/dataviews => dataviews/src}/add-filter.js (93%) create mode 100644 packages/dataviews/src/constants.js rename packages/{edit-site/src/components/dataviews => dataviews/src}/dataviews.js (70%) create mode 100644 packages/dataviews/src/filter-summary.js rename packages/{edit-site/src/components/dataviews => dataviews/src}/filters.js (66%) create mode 100644 packages/dataviews/src/index.js rename packages/{edit-site/src/components/dataviews => dataviews/src}/item-actions.js (97%) create mode 100644 packages/dataviews/src/lock-unlock.js rename packages/{edit-site/src/components/dataviews => dataviews/src}/pagination.js (98%) rename packages/{edit-site/src/components/dataviews => dataviews/src}/reset-filters.js (100%) rename packages/{edit-site/src/components/dataviews => dataviews/src}/search.js (90%) create mode 100644 packages/dataviews/src/stories/fixtures.js create mode 100644 packages/dataviews/src/stories/index.story.js create mode 100644 packages/dataviews/src/style.scss rename packages/{edit-site/src/components/dataviews => dataviews/src}/view-actions.js (81%) rename packages/{edit-site/src/components/dataviews => dataviews/src}/view-grid.js (81%) create mode 100644 packages/dataviews/src/view-list.js create mode 100644 packages/dataviews/src/view-table.js delete mode 100644 packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js delete mode 100644 packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js delete mode 100644 packages/e2e-tests/specs/site-editor/site-editor-export.test.js delete mode 100644 packages/edit-post/src/components/device-preview/index.js delete mode 100644 packages/edit-post/src/components/header/document-actions/index.js delete mode 100644 packages/edit-post/src/components/header/document-actions/style.scss delete mode 100644 packages/edit-post/src/components/sidebar/post-template/form.js delete mode 100644 packages/edit-post/src/components/sidebar/post-template/index.js delete mode 100644 packages/edit-post/src/components/sidebar/post-template/style.scss delete mode 100644 packages/edit-post/src/components/sidebar/settings-header/style.scss delete mode 100644 packages/edit-post/src/components/sidebar/template/style.scss create mode 100644 packages/edit-post/src/test/__snapshots__/editor.native.js.snap delete mode 100644 packages/edit-site/src/components/dataviews/constants.js delete mode 100644 packages/edit-site/src/components/dataviews/filter-summary.js delete mode 100644 packages/edit-site/src/components/dataviews/index.js delete mode 100644 packages/edit-site/src/components/dataviews/style.scss delete mode 100644 packages/edit-site/src/components/dataviews/view-list.js delete mode 100644 packages/edit-site/src/components/dataviews/view-side-by-side.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/get-revision-changes.js create mode 100644 packages/edit-site/src/components/global-styles/screen-revisions/test/get-revision-changes.js delete mode 100644 packages/edit-site/src/components/header-edit-mode/document-actions/index.js delete mode 100644 packages/edit-site/src/components/page-content-focus-notifications/back-to-page-notification.js delete mode 100644 packages/edit-site/src/components/page-content-focus-notifications/index.js delete mode 100644 packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template/template-areas.js create mode 100644 packages/editor/src/components/document-bar/index.js rename packages/{edit-site/src/components/header-edit-mode/document-actions => editor/src/components/document-bar}/style.scss (62%) rename packages/{edit-site/src/components/page-content-focus-notifications/edit-template-notification.js => editor/src/components/editor-canvas/edit-template-blocks-notification.js} (91%) create mode 100644 packages/editor/src/components/editor-canvas/index.js create mode 100644 packages/editor/src/components/post-template/block-theme.js create mode 100644 packages/editor/src/components/post-template/classic-theme.js rename packages/{edit-post/src/components/sidebar/post-template/create-modal.js => editor/src/components/post-template/create-new-template-modal.js} (84%) create mode 100644 packages/editor/src/components/post-template/create-new-template.js rename packages/{edit-site/src/components/sidebar-edit-mode/page-panels => editor/src/components/post-template}/hooks.js (78%) delete mode 100644 packages/editor/src/components/post-template/index.js create mode 100644 packages/editor/src/components/post-template/panel.js rename packages/{edit-site/src/components/sidebar-edit-mode/page-panels => editor/src/components/post-template}/reset-default-template.js (69%) create mode 100644 packages/editor/src/components/post-template/style.scss rename packages/{edit-site/src/components/sidebar-edit-mode/page-panels => editor/src/components/post-template}/swap-template-button.js (94%) create mode 100644 packages/editor/src/components/preview-dropdown/index.js create mode 100644 packages/editor/src/components/preview-dropdown/style.scss create mode 100644 packages/editor/src/components/provider/navigation-block-editing-mode.js create mode 100644 packages/editor/src/hooks/pattern-partial-syncing.js create mode 100644 packages/editor/src/store/private-actions.js create mode 100644 packages/icons/src/library/table-of-contents.js create mode 100644 packages/patterns/src/components/partial-syncing-controls.js delete mode 100644 platform-docs/docs/advanced/_category_.json delete mode 100644 platform-docs/docs/advanced/create-format.md delete mode 100644 platform-docs/docs/advanced/dynamic.md delete mode 100644 platform-docs/docs/advanced/interactivity.md delete mode 100644 platform-docs/docs/advanced/wordpress.md delete mode 100644 platform-docs/docs/create-block/writing-flow.md create mode 100644 platform-docs/src/components/HomepageTrustedBy/index.js create mode 100644 platform-docs/src/components/HomepageTrustedBy/styles.module.css create mode 100644 platform-docs/static/img/dayone.png create mode 100644 platform-docs/static/img/tumblr.png create mode 100644 platform-docs/static/img/wordpress.png create mode 100644 storybook/package-styles/dataviews-ltr.lazy.scss create mode 100644 storybook/package-styles/dataviews-rtl.lazy.scss create mode 100644 test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js create mode 100644 test/e2e/specs/editor/various/multi-entity-saving.spec.js create mode 100644 test/e2e/specs/editor/various/pref-modal.spec.js create mode 100644 test/e2e/specs/site-editor/site-editor-export.spec.js create mode 100644 test/integration/fixtures/blocks/core__form-input__deprecated-v1.html create mode 100644 test/integration/fixtures/blocks/core__form-input__deprecated-v1.json create mode 100644 test/integration/fixtures/blocks/core__form-input__deprecated-v1.parsed.json create mode 100644 test/integration/fixtures/blocks/core__form-input__deprecated-v1.serialized.html create mode 100644 test/integration/fixtures/blocks/core__form-submission-notification.html create mode 100644 test/integration/fixtures/blocks/core__form-submission-notification.json create mode 100644 test/integration/fixtures/blocks/core__form-submission-notification.parsed.json create mode 100644 test/integration/fixtures/blocks/core__form-submission-notification.serialized.html create mode 100644 test/integration/fixtures/blocks/core__form-submit-button.html create mode 100644 test/integration/fixtures/blocks/core__form-submit-button.json create mode 100644 test/integration/fixtures/blocks/core__form-submit-button.parsed.json create mode 100644 test/integration/fixtures/blocks/core__form-submit-button.serialized.html create mode 100644 test/integration/fixtures/blocks/core__social-link-gravatar.html create mode 100644 test/integration/fixtures/blocks/core__social-link-gravatar.json create mode 100644 test/integration/fixtures/blocks/core__social-link-gravatar.parsed.json create mode 100644 test/integration/fixtures/blocks/core__social-link-gravatar.serialized.html create mode 100644 test/native/integration/__snapshots__/blocks-raw-handling.native.js.snap create mode 100644 test/native/integration/blocks-raw-handling.native.js diff --git a/.github/workflows/end2end-test.yml b/.github/workflows/end2end-test.yml index 777549e334aa9..5a9750c6bb045 100644 --- a/.github/workflows/end2end-test.yml +++ b/.github/workflows/end2end-test.yml @@ -88,6 +88,8 @@ jobs: npm run wp-env start - name: Run the tests + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 run: | xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:e2e:playwright -- --shard=${{ matrix.part }}/${{ matrix.totalParts }} diff --git a/bin/plugin/commands/performance.js b/bin/plugin/commands/performance.js index 4be675a0a5d40..bdc38347e40c8 100644 --- a/bin/plugin/commands/performance.js +++ b/bin/plugin/commands/performance.js @@ -87,6 +87,7 @@ async function runTestSuite( testSuite, testRunnerDir, runKey ) { testRunnerDir, { ...process.env, + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', WP_ARTIFACTS_PATH: ARTIFACTS_PATH, RESULTS_ID: runKey, } diff --git a/changelog.txt b/changelog.txt index 92fa2690b15d6..d0d8e111937e0 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,697 @@ == Changelog == += 17.3.0-rc.1 = + + + +## Changelog + +### Enhancements + +- Components: Replace `TabPanel` with `Tabs` in the editor's `ColorPanel`. ([56878](https://github.com/WordPress/gutenberg/pull/56878)) +- Editor: Move the edit template blocks notification to editor package. ([56901](https://github.com/WordPress/gutenberg/pull/56901)) +- Editor: Unify the preview dropdown between post and site editors. ([56921](https://github.com/WordPress/gutenberg/pull/56921)) +- Editor: Use the same PostTemplatePanel between post and site editors. ([56817](https://github.com/WordPress/gutenberg/pull/56817)) +- Tabs: Replace `id` with new `tabId` prop. ([56883](https://github.com/WordPress/gutenberg/pull/56883)) +- Update main toolbar buttons to all be compact. ([56635](https://github.com/WordPress/gutenberg/pull/56635), [56729](https://github.com/WordPress/gutenberg/pull/56729)) +- Update preferences organization. ([56481](https://github.com/WordPress/gutenberg/pull/56481)) + +#### Components +- FocalPointPicker with __next40pxDefaultSize. ([56021](https://github.com/WordPress/gutenberg/pull/56021)) +- Font Library: Improve usability of font variant selection. ([56158](https://github.com/WordPress/gutenberg/pull/56158)) +- Tabs: Sync browser focus to selected tab in controlled mode. ([56658](https://github.com/WordPress/gutenberg/pull/56658)) +- Use consistent styling for duotone panels. ([56801](https://github.com/WordPress/gutenberg/pull/56801)) +- `BorderControl`: Fix button styles. ([56730](https://github.com/WordPress/gutenberg/pull/56730)) +- `DimensionControl`: Add __next40pxDefaultSize prop. ([56805](https://github.com/WordPress/gutenberg/pull/56805)) +- `FontSizePicker`: Add opt-in prop for 40px default size. ([56804](https://github.com/WordPress/gutenberg/pull/56804)) +- `QueryControls`: Add opt-in prop for 40px default size. ([56576](https://github.com/WordPress/gutenberg/pull/56576)) + +#### Block Library +- Control dimensions (margin and padding) of the list-item block. ([55874](https://github.com/WordPress/gutenberg/pull/55874)) +- Consistent default typography controls across blocks. ([55208](https://github.com/WordPress/gutenberg/pull/55208)) +- Social Icons: Add Gravatar service. ([56544](https://github.com/WordPress/gutenberg/pull/56544)) +- Tweak table block placeholder with __next40pxDefaultSize props. ([56935](https://github.com/WordPress/gutenberg/pull/56935)) + +#### Site Editor +- Merge the post only mode and the post editor. ([56671](https://github.com/WordPress/gutenberg/pull/56671)) +- Site Editor Sidebar: Add "Areas" details panel to all templates and update icon. ([55677](https://github.com/WordPress/gutenberg/pull/55677)) + +#### Block Editor +- Allow dragging between adjacent container blocks based on a threshold. ([56466](https://github.com/WordPress/gutenberg/pull/56466)) +- Components: Replace `TabPanel` with `Tabs` in the editor's `ColorGradientControl`. ([56351](https://github.com/WordPress/gutenberg/pull/56351)) + +#### Data Views +- Update data view layout. ([56786](https://github.com/WordPress/gutenberg/pull/56786)) + +#### Layout +- Match the front end layout classname in the editor. ([56774](https://github.com/WordPress/gutenberg/pull/56774)) + +#### Global Styles +- Global style revisions: Show change summary on selected item. ([56577](https://github.com/WordPress/gutenberg/pull/56577)) + +#### Icons +- Another round of HiDPI icon tweaks. ([56532](https://github.com/WordPress/gutenberg/pull/56532)) + +#### Media +- Update external images panel in post publish sidebar. ([55524](https://github.com/WordPress/gutenberg/pull/55524)) + +#### Post Editor +- Implement `Tabs` in editor settings. ([55360](https://github.com/WordPress/gutenberg/pull/55360)) + + +### Bug Fixes + +- Create-block-interactive-template: Add all files to the generated plugin zip. ([56943](https://github.com/WordPress/gutenberg/pull/56943)) +- Create-block-interactive-template: Prevent crash when Gutenberg plugin is not installed. ([56941](https://github.com/WordPress/gutenberg/pull/56941)) +- Fix end-to-end test: Update how we find the template title to match markup changes. ([56992](https://github.com/WordPress/gutenberg/pull/56992)) +- Fix: Fatal php error if a template was created by an author that was deleted. ([56990](https://github.com/WordPress/gutenberg/pull/56990)) +- Fix: PHP 8.1 deprecated warning strpos(). ([56171](https://github.com/WordPress/gutenberg/pull/56171)) +- Fix: Use span on template list titles. ([56955](https://github.com/WordPress/gutenberg/pull/56955)) +- Font Library: Add font family and font face preview keys to schema. ([56793](https://github.com/WordPress/gutenberg/pull/56793)) +- Remove unnecessary CSS for shrinking central header area. ([56220](https://github.com/WordPress/gutenberg/pull/56220)) +- Revert format types hook refactor. ([56859](https://github.com/WordPress/gutenberg/pull/56859)) +- Show template center UI when no block is selected. ([56217](https://github.com/WordPress/gutenberg/pull/56217)) +- setImmutably: Don't clone all objects. ([56612](https://github.com/WordPress/gutenberg/pull/56612)) + +#### Block Library +- Fix error when using a navigation block that returns an empty fallback result. ([56629](https://github.com/WordPress/gutenberg/pull/56629)) +- Fixture Tests: Correctly generate fixture files for form-related blocks. ([56719](https://github.com/WordPress/gutenberg/pull/56719)) +- Image: Fix resetting behaviour for alt image text. ([56809](https://github.com/WordPress/gutenberg/pull/56809)) +- Social Links Block: Prevent Theme Styles Distorting Size. ([56301](https://github.com/WordPress/gutenberg/pull/56301)) +- Update image block save to only save align none class. ([56449](https://github.com/WordPress/gutenberg/pull/56449)) + +#### Components +- DropdownMenuV2Ariakit: Prevent prefix collapsing if all radios or checkboxes are unselected. ([56720](https://github.com/WordPress/gutenberg/pull/56720)) +- FormToggle: Do not use "/" math operator. ([56672](https://github.com/WordPress/gutenberg/pull/56672)) +- PaletteEdit: Temporary custom gradient not saving. ([56896](https://github.com/WordPress/gutenberg/pull/56896)) +- `ToggleGroupControl`: React correctly to external controlled updates. ([56678](https://github.com/WordPress/gutenberg/pull/56678)) + +#### Block Editor +- Apply __next40pxDefaultSize to TextControl and Button component in renaming UIs. ([56933](https://github.com/WordPress/gutenberg/pull/56933)) +- Pattern inserter: Fix Broken preview layout. ([56814](https://github.com/WordPress/gutenberg/pull/56814)) +- Patterns: Keep synced pattern when added via drag and drop. ([56924](https://github.com/WordPress/gutenberg/pull/56924)) + +#### Design Tools +- Background image support: Fix duplicate output of styling rules. ([56997](https://github.com/WordPress/gutenberg/pull/56997)) +- Fix sticky position in classic themes with appearance tools support. ([56743](https://github.com/WordPress/gutenberg/pull/56743)) + +#### Post Editor +- Editor Canvas: Fix animation when device type changes. ([56970](https://github.com/WordPress/gutenberg/pull/56970)) +- Editor: Fix display of edit template blocks notification. ([56978](https://github.com/WordPress/gutenberg/pull/56978)) + +#### Site Editor +- Fix active edited post. ([56863](https://github.com/WordPress/gutenberg/pull/56863)) +- Show back button when editing navigation and template area in-place with no URL params. ([56741](https://github.com/WordPress/gutenberg/pull/56741)) + +#### Typography +- Fix order of typography sizes and families. ([56659](https://github.com/WordPress/gutenberg/pull/56659)) +- Font Library: Fix font uninstallation. ([56762](https://github.com/WordPress/gutenberg/pull/56762)) + +#### Navigation in Site View +- Navigation editor: Fix content mode. ([56856](https://github.com/WordPress/gutenberg/pull/56856)) + +#### Patterns +- Fix top position and height of Pattern Modal Sidebar. ([56787](https://github.com/WordPress/gutenberg/pull/56787)) + +#### Interactivity API +- Start using modules in the interactive create-block template. ([56694](https://github.com/WordPress/gutenberg/pull/56694)) + +#### Layout +- Fix input not showing when switching to "Fixed" width. ([56660](https://github.com/WordPress/gutenberg/pull/56660)) + +#### Data Views +- Align data view icon usage. ([56602](https://github.com/WordPress/gutenberg/pull/56602)) + +#### Block Styles +- Consolidate and resolve display issues between InserterPreviewPanel and BlockStylesPreviewPanel. ([56011](https://github.com/WordPress/gutenberg/pull/56011)) + +#### Inspector Controls +- Decode some characters if used in taxonomy name so it's displayed correctly in Query Loop filters. ([50376](https://github.com/WordPress/gutenberg/pull/50376)) + + +### Accessibility + +#### Data Views +- Add scroll padding to dataviews container. ([56946](https://github.com/WordPress/gutenberg/pull/56946)) +- Adding `aria-sort` to table view headers. ([56860](https://github.com/WordPress/gutenberg/pull/56860)) +- Fix: Use span instead of heading for the template titles. ([56785](https://github.com/WordPress/gutenberg/pull/56785)) + +#### Post Editor +- Avoid to show unnecessary Tooltip for the Post Schedule button. ([56759](https://github.com/WordPress/gutenberg/pull/56759)) + +#### Block Editor +- Increase right padding of URL field to take the Submit button into account. ([56685](https://github.com/WordPress/gutenberg/pull/56685)) + +#### Site Editor +- Shorter screen reader announcement after changing pages. ([56339](https://github.com/WordPress/gutenberg/pull/56339)) + +#### Components +- Use tooltip for the Timezone only when necessary. ([56214](https://github.com/WordPress/gutenberg/pull/56214)) + + +### Performance + +- Block editor: Make all BlockEdit hooks pure. ([56813](https://github.com/WordPress/gutenberg/pull/56813)) +- Block editor: Remove 4 useSelect in favour of context. ([56915](https://github.com/WordPress/gutenberg/pull/56915)) +- Block editor: hooks: Avoid BlockEdit filter for content locking UI. ([56957](https://github.com/WordPress/gutenberg/pull/56957)) +- Block editor: hooks: Share block settings. ([56852](https://github.com/WordPress/gutenberg/pull/56852)) +- Keycodes: Avoid regex for capital case. ([56822](https://github.com/WordPress/gutenberg/pull/56822)) +- Measure typing without inspector. ([56753](https://github.com/WordPress/gutenberg/pull/56753)) +- Media upload component: Lazy mount. ([56958](https://github.com/WordPress/gutenberg/pull/56958)) +- Paragraph: Store subscription for selected block only. ([56967](https://github.com/WordPress/gutenberg/pull/56967)) +- Perf: Reopen inspector for remaining tests. ([56780](https://github.com/WordPress/gutenberg/pull/56780)) +- useBlockProps: Combine store subscriptions. ([56847](https://github.com/WordPress/gutenberg/pull/56847)) + +#### Block Editor +- Improve opening inserter in post editor. ([57006](https://github.com/WordPress/gutenberg/pull/57006)) +- hooks: Subscribe only to relevant attributes. ([56783](https://github.com/WordPress/gutenberg/pull/56783)) + +#### Site Editor +- Fix typing performance by not rendering sidebar. ([56927](https://github.com/WordPress/gutenberg/pull/56927)) + +#### Components +- ToolsPanel: Fix deregister/register on type. ([56770](https://github.com/WordPress/gutenberg/pull/56770)) + +#### Modules API +- Load the import map polyfill only when there is an import map. ([56699](https://github.com/WordPress/gutenberg/pull/56699)) + +#### Post Editor +- Editor: Avoid double parsing content in 'getSuggestedPostFormat' selelector. ([56679](https://github.com/WordPress/gutenberg/pull/56679)) + + +### Experiments + +#### Data Views +- DataViews: Add story. ([56761](https://github.com/WordPress/gutenberg/pull/56761)) +- DataViews: Add support for `NOT IN` operator in filter. ([56479](https://github.com/WordPress/gutenberg/pull/56479)) +- DataViews: Centralize the view definition and rename `list` to `table`. ([56693](https://github.com/WordPress/gutenberg/pull/56693)) +- DataViews: Do not export strings constants. ([56754](https://github.com/WordPress/gutenberg/pull/56754)) +- DataViews: Export the view components as defaults. ([56677](https://github.com/WordPress/gutenberg/pull/56677)) +- DataViews: Fix dropdown menu actions with modal. ([56760](https://github.com/WordPress/gutenberg/pull/56760)) +- DataViews: Hide pagination if we have only one page. ([56948](https://github.com/WordPress/gutenberg/pull/56948)) +- DataViews: Implement `NOT IN` operator for author filter in templates. ([56777](https://github.com/WordPress/gutenberg/pull/56777)) +- DataViews: Iterate on list view. ([56746](https://github.com/WordPress/gutenberg/pull/56746)) +- DataViews: Make `Actions` styles the same as any other column header. ([56654](https://github.com/WordPress/gutenberg/pull/56654)) +- DataViews: Make `mediaField` not hidable. ([56643](https://github.com/WordPress/gutenberg/pull/56643)) +- DataViews: Rename view components. ([56709](https://github.com/WordPress/gutenberg/pull/56709)) +- DataViews: Render data async conditionally. ([56851](https://github.com/WordPress/gutenberg/pull/56851)) +- DataViews: Set proper role for AddFilter's items. ([56714](https://github.com/WordPress/gutenberg/pull/56714)) +- DataViews: Set proper semantics for dropdown items. ([56676](https://github.com/WordPress/gutenberg/pull/56676)) +- DataViews: Update sorting semantics. ([56717](https://github.com/WordPress/gutenberg/pull/56717)) +- Dataviews: Extract to dedicated bundled package. ([56721](https://github.com/WordPress/gutenberg/pull/56721)) + +#### Block Validation/Deprecation +- Input Field Block: Use `useblockProps` hook in save function. ([56507](https://github.com/WordPress/gutenberg/pull/56507)) + +#### Patterns +- Implement partially synced patterns behind an experimental flag. ([56235](https://github.com/WordPress/gutenberg/pull/56235)) + + +### Documentation + +- Add the nested blocks chapter to the platform documentation. ([56689](https://github.com/WordPress/gutenberg/pull/56689)) +- Components: Update CHANGELOG.md. ([56960](https://github.com/WordPress/gutenberg/pull/56960)) +- Doc: Search Control - add Storybook link. ([56815](https://github.com/WordPress/gutenberg/pull/56815)) +- Doc: Spinner - add Storybook link. ([56818](https://github.com/WordPress/gutenberg/pull/56818)) +- Docs: Add storybook link for spinner component. ([56953](https://github.com/WordPress/gutenberg/pull/56953)) +- Docs: Fix {% end %} tab position to show the text. ([56735](https://github.com/WordPress/gutenberg/pull/56735)) +- Docs: Fundamentals of Block Development - Minor fixes - registration-of-a-block. ([56731](https://github.com/WordPress/gutenberg/pull/56731)) +- Docs: Fundamentals of Block Development - add links. ([56700](https://github.com/WordPress/gutenberg/pull/56700)) +- Docs: Fundamentals of Block Development ---- Small fixes for "Block wrapper". ([56651](https://github.com/WordPress/gutenberg/pull/56651)) +- Link to Dashicons. ([56872](https://github.com/WordPress/gutenberg/pull/56872)) +- Platform Docs: Add trusted by section. ([56749](https://github.com/WordPress/gutenberg/pull/56749)) +- Revert "Doc: Spinner - add Storybook link". ([56913](https://github.com/WordPress/gutenberg/pull/56913)) +- Update Getting Started Guide for Gutenberg 17.2. ([56674](https://github.com/WordPress/gutenberg/pull/56674)) +- Update InnerBlocks defaultblock doc usage. ([56728](https://github.com/WordPress/gutenberg/pull/56728)) +- Update formatting and fix grammar in the Block Editor Handbook readme. ([56798](https://github.com/WordPress/gutenberg/pull/56798)) + + +### Code Quality + +- Block editor: hooks: Avoid getEditWrapperProps. ([56912](https://github.com/WordPress/gutenberg/pull/56912)) +- Block lib: Use RichText.isEmpty where forgotten. ([56726](https://github.com/WordPress/gutenberg/pull/56726)) +- Block library: Reusable caption component util. ([56606](https://github.com/WordPress/gutenberg/pull/56606)) +- Core data revisions: Remove hardcoded supports constant. ([56701](https://github.com/WordPress/gutenberg/pull/56701)) +- Editor: Cleanup default editor mode handling. ([56819](https://github.com/WordPress/gutenberg/pull/56819)) +- Editor: Move the BlockCanvas component within the EditorCanvas component. ([56850](https://github.com/WordPress/gutenberg/pull/56850)) +- Editor: Move the device type state to the editor package. ([56866](https://github.com/WordPress/gutenberg/pull/56866)) +- Editor: Unify device preview styles. ([56904](https://github.com/WordPress/gutenberg/pull/56904)) +- Fix PHP linter failing. ([56905](https://github.com/WordPress/gutenberg/pull/56905)) +- Framework: Bundle the BlockTools component within BlockCanvas. ([56996](https://github.com/WordPress/gutenberg/pull/56996)) +- Move `useDebouncedInput` hook to @wordpress/compose package. ([56744](https://github.com/WordPress/gutenberg/pull/56744)) +- Post Editor: Rely on the editor store for the template mode state. ([56716](https://github.com/WordPress/gutenberg/pull/56716)) +- Refactor . ([56335](https://github.com/WordPress/gutenberg/pull/56335)) +- Remove Block Tools BackCompat. ([56874](https://github.com/WordPress/gutenberg/pull/56874)) +- Site and Post Editor: Unify the DocumentBar component. ([56778](https://github.com/WordPress/gutenberg/pull/56778)) +- getValueFromObjectPath: Remove memize. ([56711](https://github.com/WordPress/gutenberg/pull/56711)) + +#### Block Editor +- Don't render undefined classname in useBlockProps hook. ([56923](https://github.com/WordPress/gutenberg/pull/56923)) +- One hook to rule them all: Preparation for a block supports API. ([56862](https://github.com/WordPress/gutenberg/pull/56862)) +- RichText: Pass value to store. ([43204](https://github.com/WordPress/gutenberg/pull/43204)) +- hooks: Manage BlockListBlock filters in one place. ([56875](https://github.com/WordPress/gutenberg/pull/56875)) + +#### Global Styles +- Command Palette: Use getRevisions instead of deprecated selector. ([56738](https://github.com/WordPress/gutenberg/pull/56738)) +- Global styles revisions: Remove PHP unit tests that are running in Core. ([56492](https://github.com/WordPress/gutenberg/pull/56492)) + +#### Components +- Site editor: Do not use navigator's internal classname. ([56911](https://github.com/WordPress/gutenberg/pull/56911)) + +#### Data Views +- DataViews: Remove TanStack. ([56873](https://github.com/WordPress/gutenberg/pull/56873)) + + +### Tools + +- Env: Migrate to Compose V2. ([51339](https://github.com/WordPress/gutenberg/pull/51339)) +- Scripts: Fix CSS imports not minified. ([56516](https://github.com/WordPress/gutenberg/pull/56516)) +- wp-env: Make env-cwd option work on Windows. ([56265](https://github.com/WordPress/gutenberg/pull/56265)) + +#### Testing +- Migrate 'editor multi entity saving' end-to-end tests to Playwright. ([56670](https://github.com/WordPress/gutenberg/pull/56670)) +- Migrate 'inner-blocks-locking-all-embed' end-to-end tests to Playwright. ([56673](https://github.com/WordPress/gutenberg/pull/56673)) +- Migrate 'site editor export' end-to-end tests to Playwright. ([56675](https://github.com/WordPress/gutenberg/pull/56675)) +- RN: Add watch mode for native tests. ([56788](https://github.com/WordPress/gutenberg/pull/56788)) +- Scripts: Enable skipping Playwright browser installation. ([56594](https://github.com/WordPress/gutenberg/pull/56594)) +- Tabs: Implement `ariakit/test` in unit tests. ([56835](https://github.com/WordPress/gutenberg/pull/56835)) +- `CustomSelectControl`: Add additional unit tests. ([56575](https://github.com/WordPress/gutenberg/pull/56575)) + + +### Various + +- Copy/fix capitalization of WordPress. ([56834](https://github.com/WordPress/gutenberg/pull/56834)) + +#### Site Editor +- Improve text and design of the block removal warnings. ([56869](https://github.com/WordPress/gutenberg/pull/56869)) + +#### Global Styles +- Global styles welcome guide: Add a space between translated strings. ([56839](https://github.com/WordPress/gutenberg/pull/56839)) + +#### Block Library +- Simplify page list edit warning. ([56829](https://github.com/WordPress/gutenberg/pull/56829)) + +#### Patterns +- End pattern page descriptions with a period. ([56828](https://github.com/WordPress/gutenberg/pull/56828)) + + +## First time contributors + +The following PRs were merged by first time contributors: + +- @benoitchantre: Scripts: Fix CSS imports not minified. ([56516](https://github.com/WordPress/gutenberg/pull/56516)) +- @kmanijak: Decode some characters if used in taxonomy name so it's displayed correctly in Query Loop filters. ([50376](https://github.com/WordPress/gutenberg/pull/50376)) +- @lithrel: Env: Migrate to Compose V2. ([51339](https://github.com/WordPress/gutenberg/pull/51339)) +- @nk-o: Fix: PHP 8.1 deprecated warning strpos(). ([56171](https://github.com/WordPress/gutenberg/pull/56171)) +- @taylorgorman: Link to Dashicons. ([56872](https://github.com/WordPress/gutenberg/pull/56872)) +- @valerogarte: #55702 - Control dimensions (margin and padding) of the list-item block. ([55874](https://github.com/WordPress/gutenberg/pull/55874)) + + +## Contributors + +The following contributors merged PRs in this release: + +@afercia @ajlende @alexstine @andrewhayward @andrewserong @apeatling @atachibana @Aurorum @benoitchantre @bph @brookewp @chad1008 @ciampo @colorful-tones @dcalhoun @derekblank @draganescu @ellatrix @fluiddot @geriux @getdave @jameskoster @jasmussen @jeherve @jeryj @jffng @jonathanbossenger @jorgefilipecosta @jsnajdr @juanmaguitar @kevin940726 @kmanijak @lithrel @luisherranz @Mamaduka @matiasbenedetto @mikachan @miminari @mtias @ndiego @nk-o @ntsekouras @oandregal @ramonjd @richtabor @scruffian @SiobhyB @t-hamano @talldan @taylorgorman @tellthemachines @tyxla @valerogarte @WunderBart @youknowriad + + += 17.2.1 = + +## Changelog + +### Bug Fixes + +- Fix: Fatal php error if a template was created by an author that was deleted ([56990](https://github.com/WordPress/gutenberg/pull/56990)) + +## Contributors + +The following contributors merged PRs in this release: + +@jorgefilipecosta + + += 17.2.0 = + + + +## Changelog + +### Bug Fixes + +#### Post Editor +- Editor: Fix issue where createBlock in block template caused list view collapse. ([56666](https://github.com/WordPress/gutenberg/pull/56666)) + +#### Modules API +- Modules: Fix import map polyfill not being copied on the generated plugin ZIP. ([56655](https://github.com/WordPress/gutenberg/pull/56655)) + + +### Documentation + +- Interactivity API: New store() API documentation. ([56764](https://github.com/WordPress/gutenberg/pull/56764)) +- Interactivity API: Update TS/JSDocs after migrating to the new `store()` API. ([56748](https://github.com/WordPress/gutenberg/pull/56748)) + + +### Tools + +#### Build Tooling +- Add missing labels to changelog script, and enhance mapping function. ([55066](https://github.com/WordPress/gutenberg/pull/55066)) + + + + +## Contributors + +The following contributors merged PRs in this release: + +@andrewserong @DAreRodz @luisherranz @vcanales += 17.2.0-rc.1 = + + + +## Changelog + +### Features + +#### Modules API +- Interactivity API: Use modules instead of scripts in the frontend. ([56143](https://github.com/WordPress/gutenberg/pull/56143)) + + +### Enhancements + +- Add translator comments for strings containing date formats. ([56531](https://github.com/WordPress/gutenberg/pull/56531)) +- Block Settings: Only display parent block selector on small screens. ([56431](https://github.com/WordPress/gutenberg/pull/56431)) +- Block Theme Preview: Display the theme name on the activate button. ([55752](https://github.com/WordPress/gutenberg/pull/55752)) +- Core data revisions: Extend support to other post types. ([56353](https://github.com/WordPress/gutenberg/pull/56353)) +- Improve tooltip for parent blocks on the block toolbar. ([56146](https://github.com/WordPress/gutenberg/pull/56146)) +- Simplify template author token. ([56566](https://github.com/WordPress/gutenberg/pull/56566)) +- Style engine: Allow CSS var output for fontSize and fontFamily and update documentation. ([56528](https://github.com/WordPress/gutenberg/pull/56528)) +- Try: Change "Detach pattern" to "Detach". ([56323](https://github.com/WordPress/gutenberg/pull/56323)) +- useEntityRecord: Improve unit tests. ([56415](https://github.com/WordPress/gutenberg/pull/56415)) + +#### Components +- Add focus rings to focusable disabled buttons. ([56383](https://github.com/WordPress/gutenberg/pull/56383)) +- DropdownMenu V2 tweaks. ([56041](https://github.com/WordPress/gutenberg/pull/56041)) +- DropdownMenu V2: Add support for rendering in legacy popover slot. ([56342](https://github.com/WordPress/gutenberg/pull/56342)) +- FormToggle: Refine animation. ([56515](https://github.com/WordPress/gutenberg/pull/56515)) +- Slot: Add styles prop to bubblesVirtually version. ([56428](https://github.com/WordPress/gutenberg/pull/56428)) +- Tabs: Cleanup and improvements. ([56224](https://github.com/WordPress/gutenberg/pull/56224)) +- Try Ariakit Select for new CustomSelectControl component. ([55790](https://github.com/WordPress/gutenberg/pull/55790)) + +#### Data Views +- Data list view: Make filter row, table header, and pagination sticky. ([56157](https://github.com/WordPress/gutenberg/pull/56157)) +- Simplify dataviews view button. ([56485](https://github.com/WordPress/gutenberg/pull/56485)) +- Update data view menu item actions. ([56398](https://github.com/WordPress/gutenberg/pull/56398)) + +#### Global Styles +- Global style revisions: Redesign style revision items. ([55913](https://github.com/WordPress/gutenberg/pull/55913)) +- Global styles revisions: Migrate API call to getRevisions(). ([56349](https://github.com/WordPress/gutenberg/pull/56349)) +- Style Revisions: Remove style revisions dropdown menu. ([56454](https://github.com/WordPress/gutenberg/pull/56454)) + +#### Site Editor +- Add 'View site' action to 'Site updated' snackbar. ([52693](https://github.com/WordPress/gutenberg/pull/52693)) +- Add the Post Author component to the Page sidebar. ([56368](https://github.com/WordPress/gutenberg/pull/56368)) +- Redirect to main page menu if page record not found. ([56177](https://github.com/WordPress/gutenberg/pull/56177)) + +#### Block Editor +- Drag and drop: Allow dragging to the beginning and end of a document. ([56070](https://github.com/WordPress/gutenberg/pull/56070)) +- List View: Expand state if a block is dragged to within a collapsed block in the editor canvas. ([56493](https://github.com/WordPress/gutenberg/pull/56493)) + +#### Layout +- Add layout classes to legacy Group inner container. ([56130](https://github.com/WordPress/gutenberg/pull/56130)) +- Add setting to disable custom content size controls. ([56236](https://github.com/WordPress/gutenberg/pull/56236)) + +#### Patterns +- Small tweaks to CreatePatternModal. ([56016](https://github.com/WordPress/gutenberg/pull/56016)) +- Update Labels in Block Inserter (block patterns tab). ([55986](https://github.com/WordPress/gutenberg/pull/55986)) + +#### Icons +- Update trash icon. ([56569](https://github.com/WordPress/gutenberg/pull/56569)) + +#### Block Library +- Disable block renaming support for Nav Link block. ([56425](https://github.com/WordPress/gutenberg/pull/56425)) + +#### Distraction Free +- Add top toolbar to distraction free mode. ([56295](https://github.com/WordPress/gutenberg/pull/56295)) + +#### CSS & Styling +- Gallery Block: Use styled scrollbars for image captions. ([56252](https://github.com/WordPress/gutenberg/pull/56252)) + +#### Typography +- Font Library: Remove insecure properties. ([56230](https://github.com/WordPress/gutenberg/pull/56230)) + + +### New APIs + +- Revisions: Add new selectors to fetch entity revisions. ([54046](https://github.com/WordPress/gutenberg/pull/54046)) + +#### Interactivity API +- Migration to the new `store()` API. ([55459](https://github.com/WordPress/gutenberg/pull/55459)) + + +### Bug Fixes + +- Block Editor: Undeprecate the '__experimentalImageSizeControl' component. ([56414](https://github.com/WordPress/gutenberg/pull/56414)) +- Core data: Harmonize getRevision selector and resolver function signatures. ([56416](https://github.com/WordPress/gutenberg/pull/56416)) +- Editor styles: Scope without adding specificity. ([56564](https://github.com/WordPress/gutenberg/pull/56564)) +- Fix Restore Post title placeholder. ([56580](https://github.com/WordPress/gutenberg/pull/56580)) +- Post Schedule Panel: Remove text overflow ellipsis. ([56319](https://github.com/WordPress/gutenberg/pull/56319)) +- PostCSS style transformation: Fail gracefully instead of throwing an error. ([56093](https://github.com/WordPress/gutenberg/pull/56093)) +- Rich text: Pad multiple spaces through en/em replacement. ([56341](https://github.com/WordPress/gutenberg/pull/56341)) +- Site Editor Sidebar: Fix actions vertical alignment. ([56218](https://github.com/WordPress/gutenberg/pull/56218)) +- Site Editor: Add a fallback template showing the title and content for the post only mode. ([56509](https://github.com/WordPress/gutenberg/pull/56509)) +- useEntityRecord: Do not trigger REST API requests when disabled. ([56108](https://github.com/WordPress/gutenberg/pull/56108)) + +#### Block Library +- File block: Remove anchor tag when copy pasting to file name. ([56508](https://github.com/WordPress/gutenberg/pull/56508)) +- Fix label of columns inspector panel. ([56647](https://github.com/WordPress/gutenberg/pull/56647)) +- Post Template: Fix incorrect offset query. ([56440](https://github.com/WordPress/gutenberg/pull/56440)) + +#### Block Editor +- (RichText)(Workaround)(17.1.x) Fallback to a string arg in `collapseWhiteSpace()` if `value` is not a string. ([56570](https://github.com/WordPress/gutenberg/pull/56570)) +- Cover block: Pass dropZoneElement reference to fix dragging within cover block area. ([56312](https://github.com/WordPress/gutenberg/pull/56312)) +- useMovingAnimation: Clear translate3d rule when animation is finished. ([56410](https://github.com/WordPress/gutenberg/pull/56410)) + +#### Components +- Design Tools: Fix last ToolsPanelItem styling. ([56536](https://github.com/WordPress/gutenberg/pull/56536)) +- Fix FormTokenField suggestions broken scrollbar when `__experimentalExpandOnFocus` is defined. ([56426](https://github.com/WordPress/gutenberg/pull/56426)) +- Tabs: Fix flaky unit tests. ([55950](https://github.com/WordPress/gutenberg/pull/55950)) + +#### Global Styles +- Additional CSS: Fix on change validation. ([56434](https://github.com/WordPress/gutenberg/pull/56434)) +- Global styles revisions: Update isResolving flag. ([56491](https://github.com/WordPress/gutenberg/pull/56491)) +- Spacing: Fix block error if spacing unit array empty in theme.json. ([56306](https://github.com/WordPress/gutenberg/pull/56306)) + +#### CSS & Styling +- Reduce specificity of default Cover text color styles. ([56411](https://github.com/WordPress/gutenberg/pull/56411)) +- Restore Post Title visual styles in Code View mode. ([56582](https://github.com/WordPress/gutenberg/pull/56582)) + +#### Saving +- Editor: Reinstate anonymous callback for saved post state. ([56529](https://github.com/WordPress/gutenberg/pull/56529)) + +#### Post Editor +- Save post button: Avoid extra re-renders when enablng/disabling tooltip. ([56502](https://github.com/WordPress/gutenberg/pull/56502)) + +#### Plugin +- Update Readme.txt tested up to 6.4. ([56427](https://github.com/WordPress/gutenberg/pull/56427)) + +#### Site Editor +- Fix template resolution for templates assigned as home page. ([56418](https://github.com/WordPress/gutenberg/pull/56418)) + +#### Patterns +- Fix issue with template in replace template screen. ([56407](https://github.com/WordPress/gutenberg/pull/56407)) + +#### Layout +- Fix issue where layout classnames are injected for blocks without layout support. ([56187](https://github.com/WordPress/gutenberg/pull/56187)) + +#### Typography +- Font Library: Fix fonts not displaying correctly. ([55393](https://github.com/WordPress/gutenberg/pull/55393)) + +#### Colors +- Duotone: Backport from Core to fix filters in classic themes. ([54778](https://github.com/WordPress/gutenberg/pull/54778)) + + +### Accessibility + +- Migrating `StyleBook` to use updated `Composite` implementation. ([55344](https://github.com/WordPress/gutenberg/pull/55344)) + +#### Data Views +- DataViews: Make disabled pagination buttons focusable. ([56422](https://github.com/WordPress/gutenberg/pull/56422)) + +#### Block Library +- Image Block: Enable image block to be selected correctly when clicked. ([56043](https://github.com/WordPress/gutenberg/pull/56043)) + +#### Post Editor +- Tooltip: Don't render buttons tooltip when show button text labels is enabled. ([55842](https://github.com/WordPress/gutenberg/pull/55842)) + +#### Components +- Improve `Button` saving state accessibility. ([55547](https://github.com/WordPress/gutenberg/pull/55547)) + +#### Patterns +- Fix focus loss after converting to a synced pattern. ([55473](https://github.com/WordPress/gutenberg/pull/55473)) + + +### Performance + +- Avoid calling postcss when not needed. ([56601](https://github.com/WordPress/gutenberg/pull/56601)) +- Block Editor: Optimize 'Connections' inspector controls. ([56443](https://github.com/WordPress/gutenberg/pull/56443)) + +#### Global Styles +- Make search more responsive for block type list. ([56139](https://github.com/WordPress/gutenberg/pull/56139)) + + +### Experiments + +#### Data Views +- DataViews: Document `view.layout`. ([56637](https://github.com/WordPress/gutenberg/pull/56637)) +- DataViews: Extract common constants to file. ([56251](https://github.com/WordPress/gutenberg/pull/56251)) +- DataViews: Rename `InFilter` component to `FilterSummary`. ([56506](https://github.com/WordPress/gutenberg/pull/56506)) +- DataViews: Scope names of V2 UI components. ([56503](https://github.com/WordPress/gutenberg/pull/56503)) +- DataViews: Update field API to generate filters based on type. ([55996](https://github.com/WordPress/gutenberg/pull/55996)) +- DataViews: Update filter component. ([56110](https://github.com/WordPress/gutenberg/pull/56110)) +- Dataviews: Add confirmation step before deleting a page. ([56504](https://github.com/WordPress/gutenberg/pull/56504)) +- Dataviews: Add preview and grid view in templates list. ([56382](https://github.com/WordPress/gutenberg/pull/56382)) +- Dataviews: Grid layout refinements. ([56441](https://github.com/WordPress/gutenberg/pull/56441)) +- Dataviews: Remove link from author. ([56467](https://github.com/WordPress/gutenberg/pull/56467)) +- Dataviews: Update item actions in grid view. ([56501](https://github.com/WordPress/gutenberg/pull/56501)) +- Fix data view menu item radius. ([56395](https://github.com/WordPress/gutenberg/pull/56395)) + +#### Post Editor +- Render html in post titles in visual mode and edit HTML in post title in code view. ([54718](https://github.com/WordPress/gutenberg/pull/54718)) + + +### Documentation + +- Add the attributes definition page to the create block tutorial of the platform documentation. ([56429](https://github.com/WordPress/gutenberg/pull/56429)) +- Add the transforms page to the create block tutorial of the platform documentation. ([56559](https://github.com/WordPress/gutenberg/pull/56559)) +- Add thee block supports page to the create block tutorial of the framework docs. ([56483](https://github.com/WordPress/gutenberg/pull/56483)) +- Added clarifications and examples to "Get started with wp-scripts". ([56298](https://github.com/WordPress/gutenberg/pull/56298)) +- Block Editor: Fix typo in `URLInput`'s `onKeyDown` prop documentation. ([56322](https://github.com/WordPress/gutenberg/pull/56322)) +- Bring back non-JS tabs in block editor handbook. ([56561](https://github.com/WordPress/gutenberg/pull/56561)) +- Docs: Fix incorrect build script description in script package. ([56332](https://github.com/WordPress/gutenberg/pull/56332)) +- Docs: Fundamentals of Block Development - File structure of a block. ([56551](https://github.com/WordPress/gutenberg/pull/56551)) +- Docs: Fundamentals of Block Development - Registration of a block. ([56334](https://github.com/WordPress/gutenberg/pull/56334)) +- Docs: Fundamentals of Block Development - The block wrapper. ([56596](https://github.com/WordPress/gutenberg/pull/56596)) +- Docs: Fundamentals of Block Development - Working with Javascript in the Block Editor. ([56553](https://github.com/WordPress/gutenberg/pull/56553)) +- Docs: Fundamentals of Block Development - block.json. ([56435](https://github.com/WordPress/gutenberg/pull/56435)) +- Docs: Improve downloadBlob example. ([56225](https://github.com/WordPress/gutenberg/pull/56225)) +- Documentation - Block Editor Handbook - Add end user documentation about Block Editor as a resource on the Landing Page of the Block Editor Handbook. ([49854](https://github.com/WordPress/gutenberg/pull/49854)) +- Fix overly complex code example in ComboboxControl readme. ([56365](https://github.com/WordPress/gutenberg/pull/56365)) +- Fix version in useSetting deprecation notice. ([56377](https://github.com/WordPress/gutenberg/pull/56377)) +- Fundamentals block development - landing and first pages. ([56584](https://github.com/WordPress/gutenberg/pull/56584)) +- Fundamentals of Block Development - fix save definition. ([56605](https://github.com/WordPress/gutenberg/pull/56605)) +- Link preview image to live example using WordPress Playground. ([56292](https://github.com/WordPress/gutenberg/pull/56292)) +- NavigableContainers: Fix doc typo in onKeyDown prop. ([56352](https://github.com/WordPress/gutenberg/pull/56352)) +- Release docs: Add new section about troubleshooting the release. ([56436](https://github.com/WordPress/gutenberg/pull/56436)) +- Remove all {% codetabs %} instances and any vanilla JS references. ([56121](https://github.com/WordPress/gutenberg/pull/56121)) +- Simplify code example in ToggleControl component readme. ([56389](https://github.com/WordPress/gutenberg/pull/56389)) +- Text and Heading: Improve documentation around default values and truncation logic. ([56518](https://github.com/WordPress/gutenberg/pull/56518)) +- Theme JSON schema: Add heading/button key to color definition. ([55674](https://github.com/WordPress/gutenberg/pull/55674)) +- Update for 6.4.1 for versions in WP. ([56216](https://github.com/WordPress/gutenberg/pull/56216)) +- Update references to the gutenberg-examples repo to the new block-development-examples. ([56119](https://github.com/WordPress/gutenberg/pull/56119)) +- Update template name in `create-block` command. ([56281](https://github.com/WordPress/gutenberg/pull/56281)) +- Update webpack options for wp-scripts in README.md. ([56314](https://github.com/WordPress/gutenberg/pull/56314)) +- `BoxControl`: Update story and refactor to Typescript. ([56462](https://github.com/WordPress/gutenberg/pull/56462)) + + +### Code Quality + +- Blocks pkg: Remove 'browser' dependencies. ([56433](https://github.com/WordPress/gutenberg/pull/56433)) +- DataViews: Code Quality remove some unused props from action. ([56477](https://github.com/WordPress/gutenberg/pull/56477)) +- Editor: Move the template focus modes to the editor store. ([56472](https://github.com/WordPress/gutenberg/pull/56472)) +- Extract a PostPanelRow component from the different sidebar panels. ([56238](https://github.com/WordPress/gutenberg/pull/56238)) +- Interactivity API: Add missing changelog entry for the new `store()` API. ([56611](https://github.com/WordPress/gutenberg/pull/56611)) +- Migrating block editor `BlockPatternsList` component. ([56210](https://github.com/WordPress/gutenberg/pull/56210)) +- Move the DisableNonContentBlocks component to the editor package. ([56423](https://github.com/WordPress/gutenberg/pull/56423)) +- Post Schedule Panel: Fix Sass deprecation warning for division. ([56412](https://github.com/WordPress/gutenberg/pull/56412)) +- Remove compatibility layer for WP 6.2. ([56464](https://github.com/WordPress/gutenberg/pull/56464)) +- Unify the PostSchedule component between site and post editors. ([56196](https://github.com/WordPress/gutenberg/pull/56196)) +- Update: Refactor useAddedBy to use authorText and originalSource fields. ([56568](https://github.com/WordPress/gutenberg/pull/56568)) + +#### Block Library +- Add align support to the image block - alternative. ([55954](https://github.com/WordPress/gutenberg/pull/55954)) +- Backmerge block renaming fixes/refactors from 6.4 branch into Gutenberg trunk. ([56386](https://github.com/WordPress/gutenberg/pull/56386)) +- Pattern placeholder: Remove duplicate 'useDispatch' hook. ([56397](https://github.com/WordPress/gutenberg/pull/56397)) + +#### Components +- Remove incorrect version from deprecated `__nextHasNoMarginBottom` prop of `AnglePickerControl` Component. ([56336](https://github.com/WordPress/gutenberg/pull/56336)) +- Revert "DropdownMenu V2: Add support for rendering in legacy popover slot". ([56484](https://github.com/WordPress/gutenberg/pull/56484)) + +#### Data Views +- Dataviews: Ensure items and fields are using a unique id. ([56366](https://github.com/WordPress/gutenberg/pull/56366)) + +#### Block Editor +- useInnerBlocksProps: Stabilise dropZoneElement prop. ([56313](https://github.com/WordPress/gutenberg/pull/56313)) + +#### Design Tools +- Fix: Theme.json font settings in unit test. ([56309](https://github.com/WordPress/gutenberg/pull/56309)) + + +### Tools + +- Workflows: Update 'days-before-stale' for flaky test report issues. ([56585](https://github.com/WordPress/gutenberg/pull/56585)) +- scripts: Update `jest-dev-server` to v9. ([56552](https://github.com/WordPress/gutenberg/pull/56552)) + +#### Testing +- Dataviews: Add first end-to-end tests. ([56634](https://github.com/WordPress/gutenberg/pull/56634)) +- Migrate 'align hook' end-to-end tests to Playwright. ([56480](https://github.com/WordPress/gutenberg/pull/56480)) +- Migrate 'block directory' end-to-end tests to Playwright. ([56593](https://github.com/WordPress/gutenberg/pull/56593)) +- Migrate 'block icons' end-to-end tests to Playwright. ([56610](https://github.com/WordPress/gutenberg/pull/56610)) +- Migrate 'custom taxonomies' end-to-end test to Playwright. ([56486](https://github.com/WordPress/gutenberg/pull/56486)) +- Migrate 'sidebar permalink' end-to-end tests to Playwright. ([56253](https://github.com/WordPress/gutenberg/pull/56253)) +- Migrate Is Typing Test to Playwright. ([56616](https://github.com/WordPress/gutenberg/pull/56616)) +- Page spec: Merging create page and toggle preview tests. ([56129](https://github.com/WordPress/gutenberg/pull/56129)) +- Playwright Utils: Fix the method of getting post ID in 'publishPost'. ([56421](https://github.com/WordPress/gutenberg/pull/56421)) +- end-to-end tests: Merge Puppeteer into single job, split Playwright further. ([56363](https://github.com/WordPress/gutenberg/pull/56363)) + +#### Build Tooling +- Create block: Update `interactive-template` to the new `store()` API. ([56613](https://github.com/WordPress/gutenberg/pull/56613)) + + +### Security + +- WP_Theme_JSON_Gutenberg: Add nested indexed array schema sanitization. ([56447](https://github.com/WordPress/gutenberg/pull/56447)) + + +### Various + +- Add: Author text and original source to wp_template_part. ([56567](https://github.com/WordPress/gutenberg/pull/56567)) +- Migrating `BlockPatternSetup` to use updated `Composite` implementation. ([55425](https://github.com/WordPress/gutenberg/pull/55425)) +- Migrating `InserterListbox` to use updated Composite implementation. ([56246](https://github.com/WordPress/gutenberg/pull/56246)) + +#### Data Views +- Dataviews: All Templates: Add filters to template author. ([56338](https://github.com/WordPress/gutenberg/pull/56338)) +- Dataviews: All templates: Add: Sorting to template author and add author_text to the rest API. ([56333](https://github.com/WordPress/gutenberg/pull/56333)) + +#### HTML API +- Backport updates from Core. ([56578](https://github.com/WordPress/gutenberg/pull/56578)) + + + + +## Contributors + +The following contributors merged PRs in this release: + +@aaronrobertshaw @afercia @andrewhayward @andrewserong @annezazu @apeatling @arthur791004 @bph @brookewp @chad1008 @chiilog @ciampo @DAreRodz @dmsnell @draganescu @ellatrix @fabiankaegy @flootr @fluiddot @fullofcaffeine @geriux @getdave @glendaviesnz @jameskoster @jasmussen @jeryj @jffng @jorgefilipecosta @juanmaguitar @kevin940726 @luisherranz @MaggieCabrera @Mamaduka @matiasbenedetto @megane9988 @NekoJonez @ntsekouras @oandregal @ramonjd @richtabor @ryanwelcher @SavPhill @Soean @t-hamano @talldan @tellthemachines @youknowriad @zaguiini + + + + += 17.1.4 = + +## Changelog + +### Bug Fixes + +#### Block Library +- Post Template: Fix incorrect offset query. ([56440](https://github.com/WordPress/gutenberg/pull/56440)) + +## Contributors + +The following contributors merged PRs in this release: + +@t-hamano + + = 17.2.0-rc.1 = diff --git a/docs/README.md b/docs/README.md index b94a8d78d41a7..222b54209c7d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,8 @@ # Block Editor Handbook -Hi! 👋 Welcome to the Block Editor Handbook. +👋 Welcome to the Block Editor Handbook. -The [**Block editor**](https://wordpress.org/gutenberg/) is a modern and up-to-date paradigm for WordPress site building and publishing. It uses a modular system of **Blocks** to compose and format content, and is designed to create rich and flexible layouts for websites and digital products. +The [**Block Editor**](https://wordpress.org/gutenberg/) is a modern and up-to-date paradigm for WordPress site building and publishing. It uses a modular system of **Blocks** to compose and format content and is designed to create rich and flexible layouts for websites and digital products. The editor consists of several primary elements, as shown in the following figure: @@ -12,54 +12,44 @@ The elements highlighted in the figure are: 1. **Inserter**: A panel for inserting blocks into the content canvas 2. **Content canvas**: The content editor, which holds content created with blocks -3. **Settings sidebar**: A sidebar panel for configuring a block’s settings (among other things) +3. **Settings Sidebar**: A sidebar panel for configuring a block’s settings (among other things) -Through the Block editor, you create content modularly using Blocks. There are a number of [core blocks](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/) ready to be used, and you can also [create your own custom block](https://developer.wordpress.org/block-editor/getting-started/create-block/). +Through the Block editor, you create content modularly using Blocks. There are many [core blocks](https://developer.wordpress.org/block-editor/reference-guides/core-blocks/) ready to be used, and you can also [create your own custom block](https://developer.wordpress.org/block-editor/getting-started/create-block/). -A [Block](https://developer.wordpress.org/block-editor/explanations/architecture/key-concepts/#blocks) is a discrete element such as a Paragraph, Heading, Media element, or Embed. Each block is treated as a separate element with individual editing and format controls. When all these components are pieced together, they make up the content that is then [stored in the WordPress database](https://developer.wordpress.org/block-editor/explanations/architecture/data-flow/#serialization-and-parsing). +A [Block](https://developer.wordpress.org/block-editor/explanations/architecture/key-concepts/#blocks) is a discrete element such as a Paragraph, Heading, Media, or Embed. Each block is treated as a separate element with individual editing and format controls. When all these components are pieced together, they make up the content that is then [stored in the WordPress database](https://developer.wordpress.org/block-editor/explanations/architecture/data-flow/#serialization-and-parsing). -The Block Editor is the result of the [work done on the **Gutenberg project**](https://developer.wordpress.org/block-editor/getting-started/faq/#what-is-gutenberg) which is aimed to revolutionize the WordPress editing experience. +The Block Editor is the result of the work done on the [**Gutenberg project**](https://developer.wordpress.org/block-editor/getting-started/faq/#what-is-gutenberg), which aims to revolutionize the WordPress editing experience. -Besides offering an [enhanced editing experience](https://wordpress.org/gutenberg/) through visual content creation tools, the Block Editor is also a powerful developer platform with a [rich feature set of APIs](https://developer.wordpress.org/block-editor/reference-guides/) that allow it to be manipulated and extended in a multitude of different ways. +Besides offering an [enhanced editing experience](https://wordpress.org/gutenberg/) through visual content creation tools, the Block Editor is also a powerful developer platform with a [rich feature set of APIs](https://developer.wordpress.org/block-editor/reference-guides/) that allow it to be manipulated and extended many different ways. ## Navigating this handbook This handbook is focused on block development and is divided into five sections, each serving a different purpose. -**[Getting Started](https://developer.wordpress.org/block-editor/getting-started/)** +- [**Getting Started**](https://developer.wordpress.org/block-editor/getting-started/) - For those just starting out with block development, this is where you can get set up with a [development environment](https://developer.wordpress.org/block-editor/getting-started/devenv/) and learn the [fundamentals of block development](https://developer.wordpress.org/block-editor/getting-started/create-block/). Its [Glossary of terms](https://developer.wordpress.org/block-editor/getting-started/glossary/) and [FAQs](https://developer.wordpress.org/block-editor/getting-started/faq/) should answer any outstanding questions you may have. -For those just starting out with block development this is where you can get set up with a [development environment](https://developer.wordpress.org/block-editor/getting-started/devenv/) and learn the [fundamentals of block development](https://developer.wordpress.org/block-editor/getting-started/create-block/). Its [Glossary of terms](https://developer.wordpress.org/block-editor/getting-started/glossary/) and [FAQs](https://developer.wordpress.org/block-editor/getting-started/faq/) should answer any outstanding questions you may have. +- [**How-to Guides**](https://developer.wordpress.org/block-editor/how-to-guides/) - Here, you can build on what you learned in the Getting Started section and learn how to solve particular problems you might encounter. You can also get tutorials and example code that you can reuse for projects such as [building a full-featured block](https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/) or [working with WordPress’ data](https://developer.wordpress.org/block-editor/how-to-guides/data-basics/). In addition, you can learn [How to use JavaScript with the Block Editor](https://developer.wordpress.org/block-editor/how-to-guides/javascript/). -**[How-to Guides](https://developer.wordpress.org/block-editor/how-to-guides/)** -Here you can build on what you learned in the Getting Started section and learn how to solve particular problems that you might encounter. You can also get tutorials, and example code that you can reuse, for projects such as [building a full-featured block](https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/) or [working with WordPress’ data](https://developer.wordpress.org/block-editor/how-to-guides/data-basics/). In addition you can learn [How to use JavaScript with the Block Editor](https://developer.wordpress.org/block-editor/how-to-guides/javascript/). +- [**Reference Guides**](https://developer.wordpress.org/block-editor/reference-guides/) - This section is the heart of the handbook and is where you can get down to the nitty-gritty and look up the details of the particular API you’re working with or need information on. Among other things, the [Block API Reference](https://developer.wordpress.org/block-editor/reference-guides/block-api/) covers most of what you will want to do with a block, and each [component](https://developer.wordpress.org/block-editor/reference-guides/components/) and [package](https://developer.wordpress.org/block-editor/reference-guides/packages/) is also documented here. _Components are also documented via [Storybook](https://wordpress.github.io/gutenberg/?path=/story/docs-introduction--page)._ -**[Reference Guides](https://developer.wordpress.org/block-editor/reference-guides/)** +- [**Explanations**](https://developer.wordpress.org/block-editor/explanations/) - This section enables you to go deeper and reinforce your practical knowledge with a theoretical understanding of the [Architecture](https://developer.wordpress.org/block-editor/explanations/architecture/) of the block editor. -This section is the heart of the handbook and is where you can get down to the nitty-gritty and look up the details of the particular API that you’re working with or need information on. Among other things, the [Block API Reference](https://developer.wordpress.org/block-editor/reference-guides/block-api/) covers most of what you will want to do with a block, and each [component](https://developer.wordpress.org/block-editor/reference-guides/components/) and [package](https://developer.wordpress.org/block-editor/reference-guides/packages/) is also documented here. _Components are also documented via [Storybook](https://wordpress.github.io/gutenberg/?path=/story/docs-introduction--page)._ - - -**[Explanations](https://developer.wordpress.org/block-editor/explanations/)** - -This section enables you to go deeper and reinforce your practical knowledge with a theoretical understanding of the [Architecture](https://developer.wordpress.org/block-editor/explanations/architecture/) of the block editor. - -**[Contributor Guide](https://developer.wordpress.org/block-editor/contributors/)** - -Gutenberg is open source software and anyone is welcome to contribute to the project. This section details how to contribute and can help you choose in which way you want to contribute, whether that be with [code](https://developer.wordpress.org/block-editor/contributors/code/), with [design](https://developer.wordpress.org/block-editor/contributors/design/), with [documentation](https://developer.wordpress.org/block-editor/contributors/documentation/), or in some other way. +- [**Contributor Guide**](https://developer.wordpress.org/block-editor/contributors/) - Gutenberg is open source software, and anyone is welcome to contribute to the project. This section details how to contribute and can help you choose in which way you want to contribute, whether with [code](https://developer.wordpress.org/block-editor/contributors/code/), [design](https://developer.wordpress.org/block-editor/contributors/design/), [documentation](https://developer.wordpress.org/block-editor/contributors/documentation/), or in some other way. ## Further resources -This handbook should be considered the canonical resource for all things related to block development. However there are other resources that can help you. +This handbook should be considered the canonical resource for all things related to block development. However, there are other resources that can help you. - [**WordPress Developer Blog**](https://developer.wordpress.org/news/) - An ever-growing resource of technical articles covering specific topics related to block development and a wide variety of use cases. The blog is also an excellent way to [keep up with the latest developments in WordPress](https://developer.wordpress.org/news/tag/roundup/). - [**Learn WordPress**](https://learn.wordpress.org/) - The WordPress hub for learning resources where you can find courses like [Introduction to Block Development: Build your first custom block](https://learn.wordpress.org/course/introduction-to-block-development-build-your-first-custom-block/), [Converting a Shortcode to a Block](https://learn.wordpress.org/course/converting-a-shortcode-to-a-block/) or [Using the WordPress Data Layer](https://learn.wordpress.org/course/using-the-wordpress-data-layer/) -- [**WordPress.tv**](https://wordpress.tv/) - A hub of WordPress-related videos (from talks at WordCamps to recordings of online workshops) curated and moderated by the WordPress.org community. You’re sure to find something to aid your learning about [block development](https://wordpress.tv/?s=block%20development&sort=newest) or the [block-editor](https://wordpress.tv/?s=block%20editor&sort=relevance) here. +- [**WordPress.tv**](https://wordpress.tv/) - A hub of WordPress-related videos (from talks at WordCamps to recordings of online workshops) curated and moderated by the WordPress.org community. You’re sure to find something to aid your learning about [block development](https://wordpress.tv/?s=block%20development&sort=newest) or the [Block Editor](https://wordpress.tv/?s=block%20editor&sort=relevance) here. - [**Gutenberg repository**](https://github.com/WordPress/gutenberg/) - Development of the block editor project is carried out in this GitHub repository. It contains the code of interesting packages such as [`block-library`](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src) (core blocks) or [`components`](https://github.com/WordPress/gutenberg/tree/trunk/packages/components) (common UI elements). _The [gutenberg-examples](https://github.com/WordPress/gutenberg-examples) repository is another useful reference._ -- [**End User Documentation**](https://wordpress.org/documentation/) - Documentation site targeted to the end user (not developers) where you can also find documentation about the [Block Editor](https://wordpress.org/documentation/category/block-editor/) and [working with blocks](https://wordpress.org/documentation/article/work-with-blocks/). +- [**End User Documentation**](https://wordpress.org/documentation/) - This documentation site is targeted to the end user (not developers), where you can also find documentation about the [Block Editor](https://wordpress.org/documentation/category/block-editor/) and [working with blocks](https://wordpress.org/documentation/article/work-with-blocks/). ## Are you in the right place? diff --git a/docs/getting-started/fundamentals/README.md b/docs/getting-started/fundamentals/README.md index 6367603351c82..26fc88981348b 100644 --- a/docs/getting-started/fundamentals/README.md +++ b/docs/getting-started/fundamentals/README.md @@ -1,11 +1,11 @@ # Fundamentals of Block Development -This section provides an introduction to the most important concepts in Block Development. +This section provides an introduction to the most relevant concepts in Block Development. In this section, you will learn: 1. [**File structure of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/file-structure-of-a-block) - The purpose of each one of the types of files available for a block, the relationships between them, and their role in the output of the block. 1. [**`block.json`**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/block-json) - How a block is defined using its `block.json` metadata and some relevant properties of this file. 1. [**Registration of a block**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How a block is registered in both the server and the client. -1. [**Block wrapper**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/registration-of-a-block) - How to set proper attributes to the block's markup wrapper. +1. [**Block wrapper**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/block-wrapper) - How to set proper attributes to the block's markup wrapper. 1. [**Javascript in the Block Editor**](https://developer.wordpress.org/block-editor/getting-started/fundamentals/javascript-in-the-block-editor) - How to work with Javascript for the Block Editor. \ No newline at end of file diff --git a/docs/getting-started/fundamentals/block-json.md b/docs/getting-started/fundamentals/block-json.md index 3d65a8f016914..5959547804290 100644 --- a/docs/getting-started/fundamentals/block-json.md +++ b/docs/getting-started/fundamentals/block-json.md @@ -21,30 +21,30 @@ At [**Metadata in block.json**](https://developer.wordpress.org/block-editor/ref Through properties of the `block.json`, we can define how the block will be uniquely identified, how it can be found, and the info displayed for the block in the Block Editor. Some of these properties are: -- `apiVersion`: the version of [the API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/) used by the block (current version is 2). -- `name`: a unique identifier for a block, including a namespace. -- `title`: a display title for a block. -- `category`: a block category for the block in the Inserter panel. -- `icon`: a [Dashicon](https://developer.wordpress.org/resource/dashicons) slug or a custom SVG icon. -- `description`: a short description visible in the block inspector. -- `keywords`: to locate the block in the inserter. -- `textdomain`: the plugin text-domain (important for things such as translations). +- [`apiVersion`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#api-version): the version of [the API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/) used by the block (current version is 2). +- [`name`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#name): a unique identifier for a block, including a namespace. +- [`title`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#title): a display title for a block. +- [`category`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#category): a block category for the block in the Inserter panel. +- [`icon`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#icon): a [Dashicon](https://developer.wordpress.org/resource/dashicons) slug or a custom SVG icon. +- [`description`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#description): a short description visible in the block inspector. +- [`keywords`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#keywords): to locate the block in the inserter. +- [`textdomain`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#text-domain): the plugin text-domain (important for things such as translations). ## Files for the block's behavior, output, or style -The `editorScript` and `editorStyle` properties allow defining Javascript and CSS files to be enqueued and loaded **only in the editor**. +The [`editorScript`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#editor-script) and [`editorStyle`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#editor-style) properties allow defining Javascript and CSS files to be enqueued and loaded **only in the editor**. -The `script` and `style` properties allow the definition of Javascript and CSS files to be enqueued and loaded **in both the editor and the front end**. +The [`script`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#script) and [`style`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#style) properties allow the definition of Javascript and CSS files to be enqueued and loaded **in both the editor and the front end**. -The `viewScript` property allow us to define the Javascript file or files to be enqueued and loaded **only in the front end**. +The [`viewScript`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#view-script) property allow us to define the Javascript file or files to be enqueued and loaded **only in the front end**. -All these properties (`editorScript`, `editorStyle`, `script` `style`,`viewScript`) accept as a value a path for the file, a handle registered with `wp_register_script` or `wp_register_style`, or an array with a mix of both. Paths values in `block.json` are prefixed with `file:`. +All these properties (`editorScript`, `editorStyle`, `script` `style`,`viewScript`) accept as a value a [path for the file](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#wpdefinedpath) (prefixed with `file:`), a [handle registered with `wp_register_script` or `wp_register_style`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#wpdefinedasset), or an array with a mix of both. -The `render` property ([introduced on WordPress 6.1](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/)) sets the path of a `.php` template file that will render the markup returned to the front end. This only method will be used to return the markup for the block on request only if `$render_callback` function has not been passed to the `register_block_type` function. +The [`render`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#render) property ([introduced on WordPress 6.1](https://make.wordpress.org/core/2022/10/12/block-api-changes-in-wordpress-6-1/)) sets the path of a `.php` template file that will render the markup returned to the front end. This only method will be used to return the markup for the block on request only if `$render_callback` function has not been passed to the `register_block_type` function. ## Data Storage in the Block with `attributes` -The [`attributes` property](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-attributes/) allows a block to declare "variables" that store data or content for the block. +The [`attributes` property](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#attributes) allows a block to declare "variables" that store data or content for the block. _Example: Attributes as defined in block.json_ ```json @@ -69,9 +69,9 @@ _Example: Atributes stored in the Markup representation of the block_ x ``` -These attributes are passed to the React component `Edit`(to display in the Block Editor) and the `save` function (to return the markup saved to the DB) of the block, and to any server-side render definition for the block (see `render` prop above). +These [`attributes`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#attributes) are passed to the React component `Edit`(to display in the Block Editor) and the `save` function (to return the markup saved to the DB) of the block, and to any server-side render definition for the block (see `render` prop above). -The `Edit` component receives exclusively the capability of updating the attributes via the `setAttributes` function. +The `Edit` component receives exclusively the capability of updating the attributes via the [`setAttributes`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#setattributes) function. _See how the attributes are passed to the [`Edit` component](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/edit.js), [the `save` function](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/save.js) and [the `render.php`](https://github.com/WordPress/block-development-examples/blob/trunk/plugins/copyright-date-block-09aac3/src/render.php) in this [full block example](https://github.com/WordPress/block-development-examples/tree/trunk/plugins/copyright-date-block-09aac3) of the code above_ @@ -84,7 +84,7 @@ Check the The webpack config used internally by wp-scripts includes a css-loader chained with postcss-loader and sass-loader that allows it to process CSS, SASS or SCSS files. Check Default webpack config for more info @@ -60,19 +60,19 @@ A `style` file with any of the extensions `.css`, `.scss` or `.sass`, contains t ### `editor.(css|scss|sass)` -An `editor` file with any of the extensions `.css`, `.scss` or `.sass`, contains the additional styles applied to the block only in the editor’s context. In the build process this file is converted into `index.css` which is usually defined at `editorStyle` property in `block.json` +An `editor` file with any of the extensions `.css`, `.scss` or `.sass`, contains the additional styles applied to the block only in the editor’s context. In the build process this file is converted into `index.css` which is usually defined at [`editorStyle`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#editor-style) property in `block.json` ### `render.php` -The `render.php` file (or any other file defined in the `render` property of `block.json`) defines the server side process that returns the markup for the block when there is a request from the frontend. If this file is defined, it will take precedence over any other ways to render the block's markup for the frontend. +The `render.php` file (or any other file defined in the [`render`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#render) property of `block.json`) defines the server side process that returns the markup for the block when there is a request from the frontend. If this file is defined, it will take precedence over any other ways to render the block's markup for the frontend. ### `view.js` -The `view.js` file (or any other file defined in the `viewScript` property of `block.json`) will be loaded in the front-end when the block is displayed. +The `view.js` file (or any other file defined in the [`viewScript`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#view-script) property of `block.json`) will be loaded in the front-end when the block is displayed. ### `build` folder -In a standard project, the `build` folder contains the generated files in the build process triggered by the `build` or `start` commands of `wp-scripts`. +In a standard project, the `build` folder contains the generated files in [the build process triggered by the `build` or `start` commands of `wp-scripts`](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/#the-build-process-with-wp-scripts).
You can use webpack-src-dir and output-path option of wp-scripts build commands to customize the entry and output points diff --git a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md index 73c6a6c56e632..615f7f74ce151 100644 --- a/docs/getting-started/fundamentals/javascript-in-the-block-editor.md +++ b/docs/getting-started/fundamentals/javascript-in-the-block-editor.md @@ -44,7 +44,7 @@ Use [`enqueue_block_editor_assets`](https://developer.wordpress.org/reference/ho - [Get started with wp-scripts](https://developer.wordpress.org/block-editor/getting-started/devenv/get-started-with-wp-scripts/) - [Enqueueing assets in the Editor](https://developer.wordpress.org/block-editor/how-to-guides/enqueueing-assets-in-the-editor/) -- [Wordpress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) +- [WordPress Packages handles](https://developer.wordpress.org/block-editor/contributors/code/scripts/) - [Javascript Reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript) | MDN Web Docs - [block-development-examples](https://github.com/WordPress/block-development-examples) | GitHub repository - [block-theme-examples](https://github.com/wptrainingteam/block-theme-examples) | GitHub repository diff --git a/docs/getting-started/fundamentals/registration-of-a-block.md b/docs/getting-started/fundamentals/registration-of-a-block.md index 7cc8e6bcbe8b0..a330d46e676d5 100644 --- a/docs/getting-started/fundamentals/registration-of-a-block.md +++ b/docs/getting-started/fundamentals/registration-of-a-block.md @@ -65,10 +65,10 @@ The function takes two params: - `$settings` (`Object`) – client-side block settings.
-The content of block.json (or any other .json file) can be imported directly in Javascript files when using a build process like the one available with wp-scripts +The content of block.json (or any other .json file) can be imported directly into Javascript files when using a build process like the one available with wp-scripts
-The client-side block settings object passed as a second parameter include two properties that are especially relevant: +The client-side block settings object passed as a second parameter includes two especially relevant properties: - `edit`: The React component that gets used in the editor for our block. - `save`: The function that returns the static HTML markup that gets saved to the Database. @@ -95,4 +95,4 @@ _See the [code above](https://github.com/WordPress/block-development-examples/bl - [`register_block_type` PHP function](https://developer.wordpress.org/reference/functions/register_block_type/) - [`registerBlockType` JS function](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-blocks/#registerblocktype) -- [Why a block needs to be registered in both the server and the client?](https://github.com/WordPress/gutenberg/discussions/55884) | GitHub Discussion \ No newline at end of file +- [Why a block needs to be registered in both the server and the client?](https://github.com/WordPress/gutenberg/discussions/55884) | GitHub Discussion diff --git a/docs/how-to-guides/themes/theme-json.md b/docs/how-to-guides/themes/theme-json.md index bd27abca1494c..1f7480649f6ab 100644 --- a/docs/how-to-guides/themes/theme-json.md +++ b/docs/how-to-guides/themes/theme-json.md @@ -103,10 +103,10 @@ body { } ``` -- **Custom properties**: there's also a mechanism to create your own CSS Custom Properties. - {% end %} +- **Custom properties**: there's also a mechanism to create your own CSS Custom Properties. + {% codetabs %} {% Input %} diff --git a/docs/manifest.json b/docs/manifest.json index 3ab4cefb2b533..b8939951d7183 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1619,6 +1619,12 @@ "markdown_source": "../packages/data/README.md", "parent": "packages" }, + { + "title": "@wordpress/dataviews", + "slug": "packages-dataviews", + "markdown_source": "../packages/dataviews/README.md", + "parent": "packages" + }, { "title": "@wordpress/date", "slug": "packages-date", diff --git a/docs/reference-guides/block-api/block-metadata.md b/docs/reference-guides/block-api/block-metadata.md index edc61d138128e..d023742092df1 100644 --- a/docs/reference-guides/block-api/block-metadata.md +++ b/docs/reference-guides/block-api/block-metadata.md @@ -197,7 +197,7 @@ The `ancestor` property makes a block available inside the specified block types { "icon": "smile" } ``` -An icon property should be specified to make it easier to identify a block. These can be any of WordPress' Dashicons (slug serving also as a fallback in non-js contexts). +An icon property should be specified to make it easier to identify a block. These can be any of [WordPress' Dashicons](https://developer.wordpress.org/resource/dashicons/) (slug serving also as a fallback in non-js contexts). **Note:** It's also possible to override this property on the client-side with the source of the SVG element. In addition, this property can be defined with JavaScript as an object containing background and foreground colors. This colors will appear with the icon when they are applicable e.g.: in the inserter. Custom SVG icons are automatically wrapped in the [wp.primitives.SVG](/packages/primitives/README.md) component to add accessibility attributes (aria-hidden, role, and focusable). diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index dd7ef824aa6b0..c68bb419467f3 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -415,7 +415,7 @@ Create a list item. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/ - **Name:** core/list-item - **Category:** text - **Parent:** core/list -- **Supports:** typography (fontSize, lineHeight), ~~className~~ +- **Supports:** spacing (margin, padding), typography (fontSize, lineHeight), ~~className~~ - **Attributes:** content, placeholder ## Login/out diff --git a/docs/reference-guides/data/data-core-edit-post.md b/docs/reference-guides/data/data-core-edit-post.md index 7d6a1deed455b..e09cf0caaec51 100644 --- a/docs/reference-guides/data/data-core-edit-post.md +++ b/docs/reference-guides/data/data-core-edit-post.md @@ -138,15 +138,9 @@ _Returns_ ### isEditingTemplate -Returns true if the template editing mode is enabled. - -_Parameters_ - -- _state_ `Object`: Global application state. - -_Returns_ +> **Deprecated** -- `boolean`: Whether we're editing the template. +Returns true if the template editing mode is enabled. ### isEditorPanelEnabled @@ -438,15 +432,9 @@ _Parameters_ ### setIsEditingTemplate -Returns an action object used to switch to template editing. - -_Parameters_ - -- _value_ `boolean`: Is editing template. +> **Deprecated** -_Returns_ - -- `Object`: Action object. +Returns an action object used to switch to template editing. ### setIsInserterOpened diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 4774934651b13..f6086090f9b54 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -256,6 +256,30 @@ _Returns_ - `string`: Post type. +### getCurrentTemplateId + +Returns the template ID currently being rendered/edited + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string?`: Template ID. + +### getDeviceType + +Returns the current editing canvas device type. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `string`: Device type. + ### getEditedPostAttribute Returns a single attribute of the post being edited, preferring the unsaved edit if one exists, but falling back to the attribute for the last known saved state of the post. @@ -547,10 +571,6 @@ Returns state object prior to a specified optimist transaction ID, or `null` if Returns a suggested post format for the current post, inferred only if there is a single block within the post and it is of a type known to match a default post format. Returns null if the format cannot be determined. -_Parameters_ - -- _state_ `Object`: Global application state. - _Returns_ - `?string`: Suggested post format. @@ -1253,6 +1273,31 @@ _Related_ - selectBlock in core/block-editor store. +### setDeviceType + +Action that changes the width of the editing canvas. + +_Parameters_ + +- _deviceType_ `string`: + +_Returns_ + +- `Object`: Action object. + +### setEditedPost + +Returns an action that sets the current post Type and post ID. + +_Parameters_ + +- _postType_ `string`: Post Type. +- _postId_ `string`: Post ID. + +_Returns_ + +- `Object`: Action object. + ### setRenderingMode Returns an action used to set the rendering mode of the post editor. We support multiple rendering modes: @@ -1284,16 +1329,14 @@ _Parameters_ ### setupEditorState -Returns an action object used to setup the editor state when first opening an editor. +> **Deprecated** + +Setup the editor state. _Parameters_ - _post_ `Object`: Post object. -_Returns_ - -- `Object`: Action object. - ### showInsertionPoint _Related_ diff --git a/docs/reference-guides/theme-json-reference/theme-json-living.md b/docs/reference-guides/theme-json-reference/theme-json-living.md index 24a5845381bfd..627fee6071816 100644 --- a/docs/reference-guides/theme-json-reference/theme-json-living.md +++ b/docs/reference-guides/theme-json-reference/theme-json-living.md @@ -188,7 +188,7 @@ Settings related to typography. | textTransform | boolean | true | | | dropCap | boolean | true | | | fontSizes | array | | fluid, name, size, slug | -| fontFamilies | array | | fontFace, fontFamily, name, slug | +| fontFamilies | array | | fontFace, fontFamily, name, preview, slug | --- diff --git a/gutenberg.php b/gutenberg.php index 2526aac377054..20c51fdb6ead2 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.3 * Requires PHP: 7.0 - * Version: 17.2.0-rc.1 + * Version: 17.3.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/block-supports/background.php b/lib/block-supports/background.php index b4779b1a150e4..ab2fa84361fc2 100644 --- a/lib/block-supports/background.php +++ b/lib/block-supports/background.php @@ -103,4 +103,7 @@ function gutenberg_render_background_support( $block_content, $block ) { ) ); +if ( function_exists( 'wp_render_background_support' ) ) { + remove_filter( 'render_block', 'wp_render_background_support' ); +} add_filter( 'render_block', 'gutenberg_render_background_support', 10, 2 ); diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index d35c963d0bed4..db02364b70790 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -819,7 +819,8 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { break; } - if ( false !== strpos( $processor->get_attribute( 'class' ), $inner_block_wrapper_classes ) ) { + $class_attribute = $processor->get_attribute( 'class' ); + if ( is_string( $class_attribute ) && false !== strpos( $class_attribute, $inner_block_wrapper_classes ) ) { break; } } while ( $processor->next_tag() ); diff --git a/lib/block-supports/pattern.php b/lib/block-supports/pattern.php new file mode 100644 index 0000000000000..a783135c793e3 --- /dev/null +++ b/lib/block-supports/pattern.php @@ -0,0 +1,36 @@ +supports, array( '__experimentalConnections' ), false ) : false; + + if ( $pattern_support ) { + if ( ! $block_type->uses_context ) { + $block_type->uses_context = array(); + } + + if ( ! in_array( 'pattern/overrides', $block_type->uses_context, true ) ) { + $block_type->uses_context[] = 'pattern/overrides'; + } + } + } + + // Register the block support. + WP_Block_Supports::get_instance()->register( + 'pattern', + array( + 'register_attribute' => 'gutenberg_register_pattern_support', + ) + ); +} diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 9311001f2edd1..43a3772a1c3af 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -431,6 +431,7 @@ class WP_Theme_JSON_Gutenberg { 'fontFamily' => null, 'name' => null, 'slug' => null, + 'preview' => null, 'fontFace' => array( array( 'ascentOverride' => null, @@ -446,6 +447,7 @@ class WP_Theme_JSON_Gutenberg { 'sizeAdjust' => null, 'src' => null, 'unicodeRange' => null, + 'preview' => null, ), ), ), diff --git a/lib/compat/wordpress-6.4/html-api/class-wp-html-token.php b/lib/compat/wordpress-6.4/html-api/class-wp-html-token.php index 6823d14fadcc8..4975eb840437c 100644 --- a/lib/compat/wordpress-6.4/html-api/class-wp-html-token.php +++ b/lib/compat/wordpress-6.4/html-api/class-wp-html-token.php @@ -98,13 +98,4 @@ public function __destruct() { call_user_func( $this->on_destroy, $this->bookmark_name ); } } - - /** - * Wakeup magic method. - * - * @since 6.4.2 - */ - public function __wakeup() { - throw new \LogicException( __CLASS__ . ' should never be unserialized' ); - } } diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index 9c2314ebe6890..189e5a695c23a 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -185,7 +185,7 @@ private static function get_inner_blocks_html( $attributes, $inner_blocks ) { private static function get_inner_blocks_from_navigation_post( $attributes ) { $navigation_post = get_post( $attributes['ref'] ); if ( ! isset( $navigation_post ) ) { - return ''; + return new WP_Block_List( array(), $attributes ); } // Only published posts are valid. If this is changed then a corresponding change @@ -214,7 +214,7 @@ private static function get_inner_blocks_from_fallback( $attributes ) { // Fallback my have been filtered so do basic test for validity. if ( empty( $fallback_blocks ) || ! is_array( $fallback_blocks ) ) { - return ''; + return new WP_Block_List( array(), $attributes ); } return new WP_Block_List( $fallback_blocks, $attributes ); diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php new file mode 100644 index 0000000000000..70359ea339d66 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php @@ -0,0 +1,116 @@ + + * ------------ length is 12, including quotes + * + * + * ------- length is 6 + * + * + * ------------ length is 11 + * + * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * + * @var int + */ + public $length; + + /** + * Whether the attribute is a boolean attribute with value `true`. + * + * @since 6.2.0 + * + * @var bool + */ + public $is_true; + + /** + * Constructor. + * + * @since 6.2.0 + * @since 6.5.0 Replaced `end` with `length` to more closely match `substr()`. + * + * @param string $name Attribute name. + * @param int $value_start Attribute value. + * @param int $value_length Number of bytes attribute value spans. + * @param int $start The string offset where the attribute name starts. + * @param int $length Byte length of the entire attribute name or name and value pair expression. + * @param bool $is_true Whether the attribute is a boolean attribute with true value. + */ + public function __construct( $name, $value_start, $value_length, $start, $length, $is_true ) { + $this->name = $name; + $this->value_starts_at = $value_start; + $this->value_length = $value_length; + $this->start = $start; + $this->length = $length; + $this->is_true = $is_true; + } +} diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php new file mode 100644 index 0000000000000..ed596f1266ab5 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php @@ -0,0 +1,56 @@ +start = $start; + $this->length = $length; + } +} diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php new file mode 100644 index 0000000000000..5594110f0d1c8 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php @@ -0,0 +1,2483 @@ + "c" not " c". + * This would increase the size of the changes for some operations but leave more + * natural-looking output HTML. + * - Decode HTML character references within class names when matching. E.g. match having + * class `1<"2` needs to recognize `class="1<"2"`. Currently the Tag Processor + * will fail to find the right tag if the class name is encoded as such. + * - Properly decode HTML character references in `get_attribute()`. PHP's + * `html_entity_decode()` is wrong in a couple ways: it doesn't account for the + * no-ambiguous-ampersand rule, and it improperly handles the way semicolons may + * or may not terminate a character reference. + * + * @package WordPress + * @subpackage HTML-API + * @since 6.2.0 + */ + +/** + * Core class used to modify attributes in an HTML document for tags matching a query. + * + * ## Usage + * + * Use of this class requires three steps: + * + * 1. Create a new class instance with your input HTML document. + * 2. Find the tag(s) you are looking for. + * 3. Request changes to the attributes in those tag(s). + * + * Example: + * + * $tags = new WP_HTML_Tag_Processor( $html ); + * if ( $tags->next_tag( 'option' ) ) { + * $tags->set_attribute( 'selected', true ); + * } + * + * ### Finding tags + * + * The `next_tag()` function moves the internal cursor through + * your input HTML document until it finds a tag meeting any of + * the supplied restrictions in the optional query argument. If + * no argument is provided then it will find the next HTML tag, + * regardless of what kind it is. + * + * If you want to _find whatever the next tag is_: + * + * $tags->next_tag(); + * + * | Goal | Query | + * |-----------------------------------------------------------|---------------------------------------------------------------------------------| + * | Find any tag. | `$tags->next_tag();` | + * | Find next image tag. | `$tags->next_tag( array( 'tag_name' => 'img' ) );` | + * | Find next image tag (without passing the array). | `$tags->next_tag( 'img' );` | + * | Find next tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'class_name' => 'fullwidth' ) );` | + * | Find next image tag containing the `fullwidth` CSS class. | `$tags->next_tag( array( 'tag_name' => 'img', 'class_name' => 'fullwidth' ) );` | + * + * If a tag was found meeting your criteria then `next_tag()` + * will return `true` and you can proceed to modify it. If it + * returns `false`, however, it failed to find the tag and + * moved the cursor to the end of the file. + * + * Once the cursor reaches the end of the file the processor + * is done and if you want to reach an earlier tag you will + * need to recreate the processor and start over, as it's + * unable to back up or move in reverse. + * + * See the section on bookmarks for an exception to this + * no-backing-up rule. + * + * #### Custom queries + * + * Sometimes it's necessary to further inspect an HTML tag than + * the query syntax here permits. In these cases one may further + * inspect the search results using the read-only functions + * provided by the processor or external state or variables. + * + * Example: + * + * // Paint up to the first five DIV or SPAN tags marked with the "jazzy" style. + * $remaining_count = 5; + * while ( $remaining_count > 0 && $tags->next_tag() ) { + * if ( + * ( 'DIV' === $tags->get_tag() || 'SPAN' === $tags->get_tag() ) && + * 'jazzy' === $tags->get_attribute( 'data-style' ) + * ) { + * $tags->add_class( 'theme-style-everest-jazz' ); + * $remaining_count--; + * } + * } + * + * `get_attribute()` will return `null` if the attribute wasn't present + * on the tag when it was called. It may return `""` (the empty string) + * in cases where the attribute was present but its value was empty. + * For boolean attributes, those whose name is present but no value is + * given, it will return `true` (the only way to set `false` for an + * attribute is to remove it). + * + * ### Modifying HTML attributes for a found tag + * + * Once you've found the start of an opening tag you can modify + * any number of the attributes on that tag. You can set a new + * value for an attribute, remove the entire attribute, or do + * nothing and move on to the next opening tag. + * + * Example: + * + * if ( $tags->next_tag( array( 'class_name' => 'wp-group-block' ) ) ) { + * $tags->set_attribute( 'title', 'This groups the contained content.' ); + * $tags->remove_attribute( 'data-test-id' ); + * } + * + * If `set_attribute()` is called for an existing attribute it will + * overwrite the existing value. Similarly, calling `remove_attribute()` + * for a non-existing attribute has no effect on the document. Both + * of these methods are safe to call without knowing if a given attribute + * exists beforehand. + * + * ### Modifying CSS classes for a found tag + * + * The tag processor treats the `class` attribute as a special case. + * Because it's a common operation to add or remove CSS classes, this + * interface adds helper methods to make that easier. + * + * As with attribute values, adding or removing CSS classes is a safe + * operation that doesn't require checking if the attribute or class + * exists before making changes. If removing the only class then the + * entire `class` attribute will be removed. + * + * Example: + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `Yippee!` + * // to `Yippee!` + * $tags->add_class( 'is-active' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * // from `` + * // to ` + * $tags->remove_class( 'rugby' ); + * + * When class changes are enqueued but a direct change to `class` is made via + * `set_attribute` then the changes to `set_attribute` (or `remove_attribute`) + * will take precedence over those made through `add_class` and `remove_class`. + * + * ### Bookmarks + * + * While scanning through the input HTMl document it's possible to set + * a named bookmark when a particular tag is found. Later on, after + * continuing to scan other tags, it's possible to `seek` to one of + * the set bookmarks and then proceed again from that point forward. + * + * Because bookmarks create processing overhead one should avoid + * creating too many of them. As a rule, create only bookmarks + * of known string literal names; avoid creating "mark_{$index}" + * and so on. It's fine from a performance standpoint to create a + * bookmark and update it frequently, such as within a loop. + * + * $total_todos = 0; + * while ( $p->next_tag( array( 'tag_name' => 'UL', 'class_name' => 'todo' ) ) ) { + * $p->set_bookmark( 'list-start' ); + * while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + * if ( 'UL' === $p->get_tag() && $p->is_tag_closer() ) { + * $p->set_bookmark( 'list-end' ); + * $p->seek( 'list-start' ); + * $p->set_attribute( 'data-contained-todos', (string) $total_todos ); + * $total_todos = 0; + * $p->seek( 'list-end' ); + * break; + * } + * + * if ( 'LI' === $p->get_tag() && ! $p->is_tag_closer() ) { + * $total_todos++; + * } + * } + * } + * + * ## Design and limitations + * + * The Tag Processor is designed to linearly scan HTML documents and tokenize + * HTML tags and their attributes. It's designed to do this as efficiently as + * possible without compromising parsing integrity. Therefore it will be + * slower than some methods of modifying HTML, such as those incorporating + * over-simplified PCRE patterns, but will not introduce the defects and + * failures that those methods bring in, which lead to broken page renders + * and often to security vulnerabilities. On the other hand, it will be faster + * than full-blown HTML parsers such as DOMDocument and use considerably + * less memory. It requires a negligible memory overhead, enough to consider + * it a zero-overhead system. + * + * The performance characteristics are maintained by avoiding tree construction + * and semantic cleanups which are specified in HTML5. Because of this, for + * example, it's not possible for the Tag Processor to associate any given + * opening tag with its corresponding closing tag, or to return the inner markup + * inside an element. Systems may be built on top of the Tag Processor to do + * this, but the Tag Processor is and should be constrained so it can remain an + * efficient, low-level, and reliable HTML scanner. + * + * The Tag Processor's design incorporates a "garbage-in-garbage-out" philosophy. + * HTML5 specifies that certain invalid content be transformed into different forms + * for display, such as removing null bytes from an input document and replacing + * invalid characters with the Unicode replacement character `U+FFFD` (visually "�"). + * Where errors or transformations exist within the HTML5 specification, the Tag Processor + * leaves those invalid inputs untouched, passing them through to the final browser + * to handle. While this implies that certain operations will be non-spec-compliant, + * such as reading the value of an attribute with invalid content, it also preserves a + * simplicity and efficiency for handling those error cases. + * + * Most operations within the Tag Processor are designed to minimize the difference + * between an input and output document for any given change. For example, the + * `add_class` and `remove_class` methods preserve whitespace and the class ordering + * within the `class` attribute; and when encountering tags with duplicated attributes, + * the Tag Processor will leave those invalid duplicate attributes where they are but + * update the proper attribute which the browser will read for parsing its value. An + * exception to this rule is that all attribute updates store their values as + * double-quoted strings, meaning that attributes on input with single-quoted or + * unquoted values will appear in the output with double-quotes. + * + * @since 6.2.0 + * @since 6.2.1 Fix: Support for various invalid comments; attribute updates are case-insensitive. + * @since 6.3.2 Fix: Skip HTML-like content inside rawtext elements such as STYLE. + */ +class Gutenberg_HTML_Tag_Processor_6_5 { + /** + * The maximum number of bookmarks allowed to exist at + * any given time. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::set_bookmark() + */ + const MAX_BOOKMARKS = 10; + + /** + * Maximum number of times seek() can be called. + * Prevents accidental infinite loops. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::seek() + */ + const MAX_SEEK_OPS = 1000; + + /** + * The HTML document to parse. + * + * @since 6.2.0 + * @var string + */ + protected $html; + + /** + * The last query passed to next_tag(). + * + * @since 6.2.0 + * @var array|null + */ + private $last_query; + + /** + * The tag name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_tag_name; + + /** + * The CSS class name this processor currently scans for. + * + * @since 6.2.0 + * @var string|null + */ + private $sought_class_name; + + /** + * The match offset this processor currently scans for. + * + * @since 6.2.0 + * @var int|null + */ + private $sought_match_offset; + + /** + * Whether to visit tag closers, e.g.
, when walking an input document. + * + * @since 6.2.0 + * @var bool + */ + private $stop_on_tag_closers; + + /** + * How many bytes from the original HTML document have been read and parsed. + * + * This value points to the latest byte offset in the input document which + * has been already parsed. It is the internal cursor for the Tag Processor + * and updates while scanning through the HTML tokens. + * + * @since 6.2.0 + * @var int + */ + private $bytes_already_parsed = 0; + + /** + * Byte offset in input document where current token starts. + * + * Example: + * + *
... + * 01234 + * - token starts at 0 + * + * @since 6.5.0 + * + * @var int|null + */ + private $token_starts_at; + + /** + * Byte length of current token. + * + * Example: + * + *
... + * 012345678901234 + * - token length is 14 - 0 = 14 + * + * a is a token. + * 0123456789 123456789 123456789 + * - token length is 17 - 2 = 15 + * + * @since 6.5.0 + * + * @var int|null + */ + private $token_length; + + /** + * Byte offset in input document where current tag name starts. + * + * Example: + * + *
... + * 01234 + * - tag name starts at 1 + * + * @since 6.2.0 + * + * @var int|null + */ + private $tag_name_starts_at; + + /** + * Byte length of current tag name. + * + * Example: + * + *
... + * 01234 + * --- tag name length is 3 + * + * @since 6.2.0 + * + * @var int|null + */ + private $tag_name_length; + + /** + * Whether the current tag is an opening tag, e.g.
, or a closing tag, e.g.
. + * + * @var bool + */ + private $is_closing_tag; + + /** + * Lazily-built index of attributes found within an HTML tag, keyed by the attribute name. + * + * Example: + * + * // Supposing the parser is working through this content + * // and stops after recognizing the `id` attribute. + * //
+ * // ^ parsing will continue from this point. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ) + * ); + * + * // When picking up parsing again, or when asking to find the + * // `class` attribute we will continue and add to this array. + * $this->attributes = array( + * 'id' => new WP_HTML_Attribute_Token( 'id', 9, 6, 5, 11, false ), + * 'class' => new WP_HTML_Attribute_Token( 'class', 23, 7, 17, 13, false ) + * ); + * + * // Note that only the `class` attribute value is stored in the index. + * // That's because it is the only value used by this class at the moment. + * + * @since 6.2.0 + * @var WP_HTML_Attribute_Token[] + */ + private $attributes = array(); + + /** + * Tracks spans of duplicate attributes on a given tag, used for removing + * all copies of an attribute when calling `remove_attribute()`. + * + * @since 6.3.2 + * + * @var (WP_HTML_Span[])[]|null + */ + private $duplicate_attributes = null; + + /** + * Which class names to add or remove from a tag. + * + * These are tracked separately from attribute updates because they are + * semantically distinct, whereas this interface exists for the common + * case of adding and removing class names while other attributes are + * generally modified as with DOM `setAttribute` calls. + * + * When modifying an HTML document these will eventually be collapsed + * into a single `set_attribute( 'class', $changes )` call. + * + * Example: + * + * // Add the `wp-block-group` class, remove the `wp-group` class. + * $classname_updates = array( + * // Indexed by a comparable class name. + * 'wp-block-group' => WP_HTML_Tag_Processor::ADD_CLASS, + * 'wp-group' => WP_HTML_Tag_Processor::REMOVE_CLASS + * ); + * + * @since 6.2.0 + * @var bool[] + */ + private $classname_updates = array(); + + /** + * Tracks a semantic location in the original HTML which + * shifts with updates as they are applied to the document. + * + * @since 6.2.0 + * @var WP_HTML_Span[] + */ + protected $bookmarks = array(); + + const ADD_CLASS = true; + const REMOVE_CLASS = false; + const SKIP_CLASS = null; + + /** + * Lexical replacements to apply to input HTML document. + * + * "Lexical" in this class refers to the part of this class which + * operates on pure text _as text_ and not as HTML. There's a line + * between the public interface, with HTML-semantic methods like + * `set_attribute` and `add_class`, and an internal state that tracks + * text offsets in the input document. + * + * When higher-level HTML methods are called, those have to transform their + * operations (such as setting an attribute's value) into text diffing + * operations (such as replacing the sub-string from indices A to B with + * some given new string). These text-diffing operations are the lexical + * updates. + * + * As new higher-level methods are added they need to collapse their + * operations into these lower-level lexical updates since that's the + * Tag Processor's internal language of change. Any code which creates + * these lexical updates must ensure that they do not cross HTML syntax + * boundaries, however, so these should never be exposed outside of this + * class or any classes which intentionally expand its functionality. + * + * These are enqueued while editing the document instead of being immediately + * applied to avoid processing overhead, string allocations, and string + * copies when applying many updates to a single document. + * + * Example: + * + * // Replace an attribute stored with a new value, indices + * // sourced from the lazily-parsed HTML recognizer. + * $start = $attributes['src']->start; + * $length = $attributes['src']->length; + * $modifications[] = new WP_HTML_Text_Replacement( $start, $length, $new_value ); + * + * // Correspondingly, something like this will appear in this array. + * $lexical_updates = array( + * WP_HTML_Text_Replacement( 14, 28, 'https://my-site.my-domain/wp-content/uploads/2014/08/kittens.jpg' ) + * ); + * + * @since 6.2.0 + * @var WP_HTML_Text_Replacement[] + */ + protected $lexical_updates = array(); + + /** + * Tracks and limits `seek()` calls to prevent accidental infinite loops. + * + * @since 6.2.0 + * @var int + * + * @see WP_HTML_Tag_Processor::seek() + */ + protected $seek_count = 0; + + /** + * Constructor. + * + * @since 6.2.0 + * + * @param string $html HTML to process. + */ + public function __construct( $html ) { + $this->html = $html; + } + + /** + * Finds the next tag matching the $query. + * + * @since 6.2.0 + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string|null $class_name Tag must contain this whole class name to match. + * @type string|null $tag_closers "visit" or "skip": whether to stop on tag closers, e.g.
. + * } + * @return bool Whether a tag was matched. + */ + public function next_tag( $query = null ) { + $this->parse_query( $query ); + $already_found = 0; + + do { + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + // Find the next tag if it exists. + if ( false === $this->parse_next_tag() ) { + $this->bytes_already_parsed = strlen( $this->html ); + + return false; + } + + // Parse all of its attributes. + while ( $this->parse_next_attribute() ) { + continue; + } + + // Ensure that the tag closes before the end of the document. + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); + if ( false === $tag_ends_at ) { + return false; + } + $this->token_length = $tag_ends_at - $this->token_starts_at; + $this->bytes_already_parsed = $tag_ends_at; + + // Finally, check if the parsed tag and its attributes match the search query. + if ( $this->matches() ) { + ++$already_found; + } + + /* + * For non-DATA sections which might contain text that looks like HTML tags but + * isn't, scan with the appropriate alternative mode. Looking at the first letter + * of the tag name as a pre-check avoids a string allocation when it's not needed. + */ + $t = $this->html[ $this->tag_name_starts_at ]; + if ( + ! $this->is_closing_tag && + ( + 'i' === $t || 'I' === $t || + 'n' === $t || 'N' === $t || + 's' === $t || 'S' === $t || + 't' === $t || 'T' === $t + ) ) { + $tag_name = $this->get_tag(); + + if ( 'SCRIPT' === $tag_name && ! $this->skip_script_data() ) { + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } elseif ( + ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) && + ! $this->skip_rcdata( $tag_name ) + ) { + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } elseif ( + ( + 'IFRAME' === $tag_name || + 'NOEMBED' === $tag_name || + 'NOFRAMES' === $tag_name || + 'NOSCRIPT' === $tag_name || + 'STYLE' === $tag_name + ) && + ! $this->skip_rawtext( $tag_name ) + ) { + /* + * "XMP" should be here too but its rules are more complicated and require the + * complexity of the HTML Processor (it needs to close out any open P element, + * meaning it can't be skipped here or else the HTML Processor will lose its + * place). For now, it can be ignored as it's a rare HTML tag in practice and + * any normative HTML should be using PRE instead. + */ + $this->bytes_already_parsed = strlen( $this->html ); + return false; + } + } + } while ( $already_found < $this->sought_match_offset ); + + return true; + } + + + /** + * Generator for a foreach loop to step through each class name for the matched tag. + * + * This generator function is designed to be used inside a "foreach" loop. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( "
" ); + * $p->next_tag(); + * foreach ( $p->class_list() as $class_name ) { + * echo "{$class_name} "; + * } + * // Outputs: "free lang-en " + * + * @since 6.4.0 + */ + public function class_list() { + /** @var string $class contains the string value of the class attribute, with character references decoded. */ + $class = $this->get_attribute( 'class' ); + + if ( ! is_string( $class ) ) { + return; + } + + $seen = array(); + + $at = 0; + while ( $at < strlen( $class ) ) { + // Skip past any initial boundary characters. + $at += strspn( $class, " \t\f\r\n", $at ); + if ( $at >= strlen( $class ) ) { + return; + } + + // Find the byte length until the next boundary. + $length = strcspn( $class, " \t\f\r\n", $at ); + if ( 0 === $length ) { + return; + } + + /* + * CSS class names are case-insensitive in the ASCII range. + * + * @see https://www.w3.org/TR/CSS2/syndata.html#x1 + */ + $name = strtolower( substr( $class, $at, $length ) ); + $at += $length; + + /* + * It's expected that the number of class names for a given tag is relatively small. + * Given this, it is probably faster overall to scan an array for a value rather + * than to use the class name as a key and check if it's a key of $seen. + */ + if ( in_array( $name, $seen, true ) ) { + continue; + } + + $seen[] = $name; + yield $name; + } + } + + + /** + * Returns if a matched tag contains the given ASCII case-insensitive class name. + * + * @since 6.4.0 + * + * @param string $wanted_class Look for this CSS class name, ASCII case-insensitive. + * @return bool|null Whether the matched tag contains the given class name, or null if not matched. + */ + public function has_class( $wanted_class ) { + if ( ! $this->tag_name_starts_at ) { + return null; + } + + $wanted_class = strtolower( $wanted_class ); + + foreach ( $this->class_list() as $class_name ) { + if ( $class_name === $wanted_class ) { + return true; + } + } + + return false; + } + + + /** + * Sets a bookmark in the HTML document. + * + * Bookmarks represent specific places or tokens in the HTML + * document, such as a tag opener or closer. When applying + * edits to a document, such as setting an attribute, the + * text offsets of that token may shift; the bookmark is + * kept updated with those shifts and remains stable unless + * the entire span of text in which the token sits is removed. + * + * Release bookmarks when they are no longer needed. + * + * Example: + * + *

Surprising fact you may not know!

+ * ^ ^ + * \-|-- this `H2` opener bookmark tracks the token + * + *

Surprising fact you may no… + * ^ ^ + * \-|-- it shifts with edits + * + * Bookmarks provide the ability to seek to a previously-scanned + * place in the HTML document. This avoids the need to re-scan + * the entire document. + * + * Example: + * + *
  • One
  • Two
  • Three
+ * ^^^^ + * want to note this last item + * + * $p = new WP_HTML_Tag_Processor( $html ); + * $in_list = false; + * while ( $p->next_tag( array( 'tag_closers' => $in_list ? 'visit' : 'skip' ) ) ) { + * if ( 'UL' === $p->get_tag() ) { + * if ( $p->is_tag_closer() ) { + * $in_list = false; + * $p->set_bookmark( 'resume' ); + * if ( $p->seek( 'last-li' ) ) { + * $p->add_class( 'last-li' ); + * } + * $p->seek( 'resume' ); + * $p->release_bookmark( 'last-li' ); + * $p->release_bookmark( 'resume' ); + * } else { + * $in_list = true; + * } + * } + * + * if ( 'LI' === $p->get_tag() ) { + * $p->set_bookmark( 'last-li' ); + * } + * } + * + * Bookmarks intentionally hide the internal string offsets + * to which they refer. They are maintained internally as + * updates are applied to the HTML document and therefore + * retain their "position" - the location to which they + * originally pointed. The inability to use bookmarks with + * functions like `substr` is therefore intentional to guard + * against accidentally breaking the HTML. + * + * Because bookmarks allocate memory and require processing + * for every applied update, they are limited and require + * a name. They should not be created with programmatically-made + * names, such as "li_{$index}" with some loop. As a general + * rule they should only be created with string-literal names + * like "start-of-section" or "last-paragraph". + * + * Bookmarks are a powerful tool to enable complicated behavior. + * Consider double-checking that you need this tool if you are + * reaching for it, as inappropriate use could lead to broken + * HTML structure or unwanted processing overhead. + * + * @since 6.2.0 + * + * @param string $name Identifies this particular bookmark. + * @return bool Whether the bookmark was successfully created. + */ + public function set_bookmark( $name ) { + if ( null === $this->tag_name_starts_at ) { + return false; + } + + if ( ! array_key_exists( $name, $this->bookmarks ) && count( $this->bookmarks ) >= static::MAX_BOOKMARKS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many bookmarks: cannot create any more.' ), + '6.2.0' + ); + return false; + } + + $this->bookmarks[ $name ] = new Gutenberg_HTML_Span_6_5( $this->token_starts_at, $this->token_length ); + + return true; + } + + + /** + * Removes a bookmark that is no longer needed. + * + * Releasing a bookmark frees up the small + * performance overhead it requires. + * + * @param string $name Name of the bookmark to remove. + * @return bool Whether the bookmark already existed before removal. + */ + public function release_bookmark( $name ) { + if ( ! array_key_exists( $name, $this->bookmarks ) ) { + return false; + } + + unset( $this->bookmarks[ $name ] ); + + return true; + } + + /** + * Skips contents of generic rawtext elements. + * + * @since 6.3.2 + * + * @see https://html.spec.whatwg.org/#generic-raw-text-element-parsing-algorithm + * + * @param string $tag_name The uppercase tag name which will close the RAWTEXT region. + * @return bool Whether an end to the RAWTEXT region was found before the end of the document. + */ + private function skip_rawtext( $tag_name ) { + /* + * These two functions distinguish themselves on whether character references are + * decoded, and since functionality to read the inner markup isn't supported, it's + * not necessary to implement these two functions separately. + */ + return $this->skip_rcdata( $tag_name ); + } + + /** + * Skips contents of RCDATA elements, namely title and textarea tags. + * + * @since 6.2.0 + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#rcdata-state + * + * @param string $tag_name The uppercase tag name which will close the RCDATA region. + * @return bool Whether an end to the RCDATA region was found before the end of the document. + */ + private function skip_rcdata( $tag_name ) { + $html = $this->html; + $doc_length = strlen( $html ); + $tag_length = strlen( $tag_name ); + + $at = $this->bytes_already_parsed; + + while ( false !== $at && $at < $doc_length ) { + $at = strpos( $this->html, '= $doc_length ) { + $this->bytes_already_parsed = $doc_length; + return false; + } + + $closer_potentially_starts_at = $at; + $at += 2; + + /* + * Find a case-insensitive match to the tag name. + * + * Because tag names are limited to US-ASCII there is no + * need to perform any kind of Unicode normalization when + * comparing; any character which could be impacted by such + * normalization could not be part of a tag name. + */ + for ( $i = 0; $i < $tag_length; $i++ ) { + $tag_char = $tag_name[ $i ]; + $html_char = $html[ $at + $i ]; + + if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + $at += $i; + continue 2; + } + } + + $at += $tag_length; + $this->bytes_already_parsed = $at; + + /* + * Ensure that the tag name terminates to avoid matching on + * substrings of a longer tag name. For example, the sequence + * "' !== $c ) { + continue; + } + + while ( $this->parse_next_attribute() ) { + continue; + } + $at = $this->bytes_already_parsed; + if ( $at >= strlen( $this->html ) ) { + return false; + } + + if ( '>' === $html[ $at ] || '/' === $html[ $at ] ) { + $this->bytes_already_parsed = $closer_potentially_starts_at; + return true; + } + } + + return false; + } + + /** + * Skips contents of script tags. + * + * @since 6.2.0 + * + * @return bool Whether the script tag was closed before the end of the document. + */ + private function skip_script_data() { + $state = 'unescaped'; + $html = $this->html; + $doc_length = strlen( $html ); + $at = $this->bytes_already_parsed; + + while ( false !== $at && $at < $doc_length ) { + $at += strcspn( $html, '-<', $at ); + + /* + * For all script states a "-->" transitions + * back into the normal unescaped script mode, + * even if that's the current state. + */ + if ( + $at + 2 < $doc_length && + '-' === $html[ $at ] && + '-' === $html[ $at + 1 ] && + '>' === $html[ $at + 2 ] + ) { + $at += 3; + $state = 'unescaped'; + continue; + } + + // Everything of interest past here starts with "<". + if ( $at + 1 >= $doc_length || '<' !== $html[ $at++ ] ) { + continue; + } + + /* + * Unlike with "-->", the " + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $at + 3 && + '-' === $html[ $at + 2 ] && + '-' === $html[ $at + 3 ] + ) { + $closer_at = $at + 4; + // If it's not possible to close the comment then there is nothing more to scan. + if ( strlen( $html ) <= $closer_at ) { + return false; + } + + // Abruptly-closed empty comments are a sequence of dashes followed by `>`. + $span_of_dashes = strspn( $html, '-', $closer_at ); + if ( '>' === $html[ $closer_at + $span_of_dashes ] ) { + $at = $closer_at + $span_of_dashes + 1; + continue; + } + + /* + * Comments may be closed by either a --> or an invalid --!>. + * The first occurrence closes the comment. + * + * See https://html.spec.whatwg.org/#parse-error-incorrectly-closed-comment + */ + --$closer_at; // Pre-increment inside condition below reduces risk of accidental infinite looping. + while ( ++$closer_at < strlen( $html ) ) { + $closer_at = strpos( $html, '--', $closer_at ); + if ( false === $closer_at ) { + return false; + } + + if ( $closer_at + 2 < strlen( $html ) && '>' === $html[ $closer_at + 2 ] ) { + $at = $closer_at + 3; + continue 2; + } + + if ( $closer_at + 3 < strlen( $html ) && '!' === $html[ $closer_at + 2 ] && '>' === $html[ $closer_at + 3 ] ) { + $at = $closer_at + 4; + continue 2; + } + } + } + + /* + * + * The CDATA is case-sensitive. + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $at + 8 && + '[' === $html[ $at + 2 ] && + 'C' === $html[ $at + 3 ] && + 'D' === $html[ $at + 4 ] && + 'A' === $html[ $at + 5 ] && + 'T' === $html[ $at + 6 ] && + 'A' === $html[ $at + 7 ] && + '[' === $html[ $at + 8 ] + ) { + $closer_at = strpos( $html, ']]>', $at + 9 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 3; + continue; + } + + /* + * + * These are ASCII-case-insensitive. + * https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( + strlen( $html ) > $at + 8 && + ( 'D' === $html[ $at + 2 ] || 'd' === $html[ $at + 2 ] ) && + ( 'O' === $html[ $at + 3 ] || 'o' === $html[ $at + 3 ] ) && + ( 'C' === $html[ $at + 4 ] || 'c' === $html[ $at + 4 ] ) && + ( 'T' === $html[ $at + 5 ] || 't' === $html[ $at + 5 ] ) && + ( 'Y' === $html[ $at + 6 ] || 'y' === $html[ $at + 6 ] ) && + ( 'P' === $html[ $at + 7 ] || 'p' === $html[ $at + 7 ] ) && + ( 'E' === $html[ $at + 8 ] || 'e' === $html[ $at + 8 ] ) + ) { + $closer_at = strpos( $html, '>', $at + 9 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + /* + * Anything else here is an incorrectly-opened comment and transitions + * to the bogus comment state - skip to the nearest >. + */ + $at = strpos( $html, '>', $at + 1 ); + continue; + } + + /* + * is a missing end tag name, which is ignored. + * + * See https://html.spec.whatwg.org/#parse-error-missing-end-tag-name + */ + if ( '>' === $html[ $at + 1 ] ) { + ++$at; + continue; + } + + /* + * + * See https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state + */ + if ( '?' === $html[ $at + 1 ] ) { + $closer_at = strpos( $html, '>', $at + 2 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + /* + * If a non-alpha starts the tag name in a tag closer it's a comment. + * Find the first `>`, which closes the comment. + * + * See https://html.spec.whatwg.org/#parse-error-invalid-first-character-of-tag-name + */ + if ( $this->is_closing_tag ) { + $closer_at = strpos( $html, '>', $at + 3 ); + if ( false === $closer_at ) { + return false; + } + + $at = $closer_at + 1; + continue; + } + + ++$at; + } + + return false; + } + + /** + * Parses the next attribute. + * + * @since 6.2.0 + * + * @return bool Whether an attribute was found before the end of the document. + */ + private function parse_next_attribute() { + // Skip whitespace and slashes. + $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n/", $this->bytes_already_parsed ); + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + /* + * Treat the equal sign as a part of the attribute + * name if it is the first encountered byte. + * + * @see https://html.spec.whatwg.org/multipage/parsing.html#before-attribute-name-state + */ + $name_length = '=' === $this->html[ $this->bytes_already_parsed ] + ? 1 + strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed + 1 ) + : strcspn( $this->html, "=/> \t\f\r\n", $this->bytes_already_parsed ); + + // No attribute, just tag closer. + if ( 0 === $name_length || $this->bytes_already_parsed + $name_length >= strlen( $this->html ) ) { + return false; + } + + $attribute_start = $this->bytes_already_parsed; + $attribute_name = substr( $this->html, $attribute_start, $name_length ); + $this->bytes_already_parsed += $name_length; + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + $has_value = '=' === $this->html[ $this->bytes_already_parsed ]; + if ( $has_value ) { + ++$this->bytes_already_parsed; + $this->skip_whitespace(); + if ( $this->bytes_already_parsed >= strlen( $this->html ) ) { + return false; + } + + switch ( $this->html[ $this->bytes_already_parsed ] ) { + case "'": + case '"': + $quote = $this->html[ $this->bytes_already_parsed ]; + $value_start = $this->bytes_already_parsed + 1; + $value_length = strcspn( $this->html, $quote, $value_start ); + $attribute_end = $value_start + $value_length + 1; + $this->bytes_already_parsed = $attribute_end; + break; + + default: + $value_start = $this->bytes_already_parsed; + $value_length = strcspn( $this->html, "> \t\f\r\n", $value_start ); + $attribute_end = $value_start + $value_length; + $this->bytes_already_parsed = $attribute_end; + } + } else { + $value_start = $this->bytes_already_parsed; + $value_length = 0; + $attribute_end = $attribute_start + $name_length; + } + + if ( $attribute_end >= strlen( $this->html ) ) { + return false; + } + + if ( $this->is_closing_tag ) { + return true; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $comparable_name = strtolower( $attribute_name ); + + // If an attribute is listed many times, only use the first declaration and ignore the rest. + if ( ! array_key_exists( $comparable_name, $this->attributes ) ) { + $this->attributes[ $comparable_name ] = new Gutenberg_HTML_Attribute_Token_6_5( + $attribute_name, + $value_start, + $value_length, + $attribute_start, + $attribute_end - $attribute_start, + ! $has_value + ); + + return true; + } + + /* + * Track the duplicate attributes so if we remove it, all disappear together. + * + * While `$this->duplicated_attributes` could always be stored as an `array()`, + * which would simplify the logic here, storing a `null` and only allocating + * an array when encountering duplicates avoids needless allocations in the + * normative case of parsing tags with no duplicate attributes. + */ + $duplicate_span = new Gutenberg_HTML_Span_6_5( $attribute_start, $attribute_end - $attribute_start ); + if ( null === $this->duplicate_attributes ) { + $this->duplicate_attributes = array( $comparable_name => array( $duplicate_span ) ); + } elseif ( ! array_key_exists( $comparable_name, $this->duplicate_attributes ) ) { + $this->duplicate_attributes[ $comparable_name ] = array( $duplicate_span ); + } else { + $this->duplicate_attributes[ $comparable_name ][] = $duplicate_span; + } + + return true; + } + + /** + * Move the internal cursor past any immediate successive whitespace. + * + * @since 6.2.0 + */ + private function skip_whitespace() { + $this->bytes_already_parsed += strspn( $this->html, " \t\f\r\n", $this->bytes_already_parsed ); + } + + /** + * Applies attribute updates and cleans up once a tag is fully parsed. + * + * @since 6.2.0 + */ + private function after_tag() { + $this->get_updated_html(); + $this->token_starts_at = null; + $this->token_length = null; + $this->tag_name_starts_at = null; + $this->tag_name_length = null; + $this->is_closing_tag = null; + $this->attributes = array(); + $this->duplicate_attributes = null; + } + + /** + * Converts class name updates into tag attributes updates + * (they are accumulated in different data formats for performance). + * + * @since 6.2.0 + * + * @see WP_HTML_Tag_Processor::$lexical_updates + * @see WP_HTML_Tag_Processor::$classname_updates + */ + private function class_name_updates_to_attributes_updates() { + if ( count( $this->classname_updates ) === 0 ) { + return; + } + + $existing_class = $this->get_enqueued_attribute_value( 'class' ); + if ( null === $existing_class || true === $existing_class ) { + $existing_class = ''; + } + + if ( false === $existing_class && isset( $this->attributes['class'] ) ) { + $existing_class = substr( + $this->html, + $this->attributes['class']->value_starts_at, + $this->attributes['class']->value_length + ); + } + + if ( false === $existing_class ) { + $existing_class = ''; + } + + /** + * Updated "class" attribute value. + * + * This is incrementally built while scanning through the existing class + * attribute, skipping removed classes on the way, and then appending + * added classes at the end. Only when finished processing will the + * value contain the final new value. + + * @var string $class + */ + $class = ''; + + /** + * Tracks the cursor position in the existing + * class attribute value while parsing. + * + * @var int $at + */ + $at = 0; + + /** + * Indicates if there's any need to modify the existing class attribute. + * + * If a call to `add_class()` and `remove_class()` wouldn't impact + * the `class` attribute value then there's no need to rebuild it. + * For example, when adding a class that's already present or + * removing one that isn't. + * + * This flag enables a performance optimization when none of the enqueued + * class updates would impact the `class` attribute; namely, that the + * processor can continue without modifying the input document, as if + * none of the `add_class()` or `remove_class()` calls had been made. + * + * This flag is set upon the first change that requires a string update. + * + * @var bool $modified + */ + $modified = false; + + // Remove unwanted classes by only copying the new ones. + $existing_class_length = strlen( $existing_class ); + while ( $at < $existing_class_length ) { + // Skip to the first non-whitespace character. + $ws_at = $at; + $ws_length = strspn( $existing_class, " \t\f\r\n", $ws_at ); + $at += $ws_length; + + // Capture the class name – it's everything until the next whitespace. + $name_length = strcspn( $existing_class, " \t\f\r\n", $at ); + if ( 0 === $name_length ) { + // If no more class names are found then that's the end. + break; + } + + $name = substr( $existing_class, $at, $name_length ); + $at += $name_length; + + // If this class is marked for removal, start processing the next one. + $remove_class = ( + isset( $this->classname_updates[ $name ] ) && + self::REMOVE_CLASS === $this->classname_updates[ $name ] + ); + + // If a class has already been seen then skip it; it should not be added twice. + if ( ! $remove_class ) { + $this->classname_updates[ $name ] = self::SKIP_CLASS; + } + + if ( $remove_class ) { + $modified = true; + continue; + } + + /* + * Otherwise, append it to the new "class" attribute value. + * + * There are options for handling whitespace between tags. + * Preserving the existing whitespace produces fewer changes + * to the HTML content and should clarify the before/after + * content when debugging the modified output. + * + * This approach contrasts normalizing the inter-class + * whitespace to a single space, which might appear cleaner + * in the output HTML but produce a noisier change. + */ + $class .= substr( $existing_class, $ws_at, $ws_length ); + $class .= $name; + } + + // Add new classes by appending those which haven't already been seen. + foreach ( $this->classname_updates as $name => $operation ) { + if ( self::ADD_CLASS === $operation ) { + $modified = true; + + $class .= strlen( $class ) > 0 ? ' ' : ''; + $class .= $name; + } + } + + $this->classname_updates = array(); + if ( ! $modified ) { + return; + } + + if ( strlen( $class ) > 0 ) { + $this->set_attribute( 'class', $class ); + } else { + $this->remove_attribute( 'class' ); + } + } + + /** + * Applies attribute updates to HTML document. + * + * @since 6.2.0 + * @since 6.2.1 Accumulates shift for internal cursor and passed pointer. + * @since 6.3.0 Invalidate any bookmarks whose targets are overwritten. + * + * @param int $shift_this_point Accumulate and return shift for this position. + * @return int How many bytes the given pointer moved in response to the updates. + */ + private function apply_attributes_updates( $shift_this_point = 0 ) { + if ( ! count( $this->lexical_updates ) ) { + return 0; + } + + $accumulated_shift_for_given_point = 0; + + /* + * Attribute updates can be enqueued in any order but updates + * to the document must occur in lexical order; that is, each + * replacement must be made before all others which follow it + * at later string indices in the input document. + * + * Sorting avoid making out-of-order replacements which + * can lead to mangled output, partially-duplicated + * attributes, and overwritten attributes. + */ + usort( $this->lexical_updates, array( self::class, 'sort_start_ascending' ) ); + + $bytes_already_copied = 0; + $output_buffer = ''; + foreach ( $this->lexical_updates as $diff ) { + $shift = strlen( $diff->text ) - $diff->length; + + // Adjust the cursor position by however much an update affects it. + if ( $diff->start <= $this->bytes_already_parsed ) { + $this->bytes_already_parsed += $shift; + } + + // Accumulate shift of the given pointer within this function call. + if ( $diff->start <= $shift_this_point ) { + $accumulated_shift_for_given_point += $shift; + } + + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + $output_buffer .= $diff->text; + $bytes_already_copied = $diff->start + $diff->length; + } + + $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); + + /* + * Adjust bookmark locations to account for how the text + * replacements adjust offsets in the input document. + */ + foreach ( $this->bookmarks as $bookmark_name => $bookmark ) { + $bookmark_end = $bookmark->start + $bookmark->length; + + /* + * Each lexical update which appears before the bookmark's endpoints + * might shift the offsets for those endpoints. Loop through each change + * and accumulate the total shift for each bookmark, then apply that + * shift after tallying the full delta. + */ + $head_delta = 0; + $tail_delta = 0; + + foreach ( $this->lexical_updates as $diff ) { + $diff_end = $diff->start + $diff->length; + + if ( $bookmark->start < $diff->start && $bookmark_end < $diff->start ) { + break; + } + + if ( $bookmark->start >= $diff->start && $bookmark_end < $diff_end ) { + $this->release_bookmark( $bookmark_name ); + continue 2; + } + + $delta = strlen( $diff->text ) - $diff->length; + + if ( $bookmark->start >= $diff->start ) { + $head_delta += $delta; + } + + if ( $bookmark_end >= $diff_end ) { + $tail_delta += $delta; + } + } + + $bookmark->start += $head_delta; + $bookmark->length += $tail_delta - $head_delta; + } + + $this->lexical_updates = array(); + + return $accumulated_shift_for_given_point; + } + + /** + * Checks whether a bookmark with the given name exists. + * + * @since 6.3.0 + * + * @param string $bookmark_name Name to identify a bookmark that potentially exists. + * @return bool Whether that bookmark exists. + */ + public function has_bookmark( $bookmark_name ) { + return array_key_exists( $bookmark_name, $this->bookmarks ); + } + + /** + * Move the internal cursor in the Tag Processor to a given bookmark's location. + * + * In order to prevent accidental infinite loops, there's a + * maximum limit on the number of times seek() can be called. + * + * @since 6.2.0 + * + * @param string $bookmark_name Jump to the place in the document identified by this bookmark name. + * @return bool Whether the internal cursor was successfully moved to the bookmark's location. + */ + public function seek( $bookmark_name ) { + if ( ! array_key_exists( $bookmark_name, $this->bookmarks ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Unknown bookmark name.' ), + '6.2.0' + ); + return false; + } + + if ( ++$this->seek_count > static::MAX_SEEK_OPS ) { + _doing_it_wrong( + __METHOD__, + __( 'Too many calls to seek() - this can lead to performance issues.' ), + '6.2.0' + ); + return false; + } + + // Flush out any pending updates to the document. + $this->get_updated_html(); + + // Point this tag processor before the sought tag opener and consume it. + $this->bytes_already_parsed = $this->bookmarks[ $bookmark_name ]->start; + return $this->next_tag( array( 'tag_closers' => 'visit' ) ); + } + + /** + * Compare two WP_HTML_Text_Replacement objects. + * + * @since 6.2.0 + * + * @param WP_HTML_Text_Replacement $a First attribute update. + * @param WP_HTML_Text_Replacement $b Second attribute update. + * @return int Comparison value for string order. + */ + private static function sort_start_ascending( $a, $b ) { + $by_start = $a->start - $b->start; + if ( 0 !== $by_start ) { + return $by_start; + } + + $by_text = isset( $a->text, $b->text ) ? strcmp( $a->text, $b->text ) : 0; + if ( 0 !== $by_text ) { + return $by_text; + } + + /* + * This code should be unreachable, because it implies the two replacements + * start at the same location and contain the same text. + */ + return $a->length - $b->length; + } + + /** + * Return the enqueued value for a given attribute, if one exists. + * + * Enqueued updates can take different data types: + * - If an update is enqueued and is boolean, the return will be `true` + * - If an update is otherwise enqueued, the return will be the string value of that update. + * - If an attribute is enqueued to be removed, the return will be `null` to indicate that. + * - If no updates are enqueued, the return will be `false` to differentiate from "removed." + * + * @since 6.2.0 + * + * @param string $comparable_name The attribute name in its comparable form. + * @return string|boolean|null Value of enqueued update if present, otherwise false. + */ + private function get_enqueued_attribute_value( $comparable_name ) { + if ( ! isset( $this->lexical_updates[ $comparable_name ] ) ) { + return false; + } + + $enqueued_text = $this->lexical_updates[ $comparable_name ]->text; + + // Removed attributes erase the entire span. + if ( '' === $enqueued_text ) { + return null; + } + + /* + * Boolean attribute updates are just the attribute name without a corresponding value. + * + * This value might differ from the given comparable name in that there could be leading + * or trailing whitespace, and that the casing follows the name given in `set_attribute`. + * + * Example: + * + * $p->set_attribute( 'data-TEST-id', 'update' ); + * 'update' === $p->get_enqueued_attribute_value( 'data-test-id' ); + * + * Detect this difference based on the absence of the `=`, which _must_ exist in any + * attribute containing a value, e.g. ``. + * ¹ ² + * 1. Attribute with a string value. + * 2. Boolean attribute whose value is `true`. + */ + $equals_at = strpos( $enqueued_text, '=' ); + if ( false === $equals_at ) { + return true; + } + + /* + * Finally, a normal update's value will appear after the `=` and + * be double-quoted, as performed incidentally by `set_attribute`. + * + * e.g. `type="text"` + * ¹² ³ + * 1. Equals is here. + * 2. Double-quoting starts one after the equals sign. + * 3. Double-quoting ends at the last character in the update. + */ + $enqueued_value = substr( $enqueued_text, $equals_at + 2, -1 ); + return html_entity_decode( $enqueued_value ); + } + + /** + * Returns the value of a requested attribute from a matched tag opener if that attribute exists. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute( 'data-test-id' ) === '14'; + * $p->get_attribute( 'enabled' ) === true; + * $p->get_attribute( 'aria-label' ) === null; + * + * $p->next_tag() === false; + * $p->get_attribute( 'class' ) === null; + * + * @since 6.2.0 + * + * @param string $name Name of attribute whose value is requested. + * @return string|true|null Value of attribute or `null` if not available. Boolean attributes return `true`. + */ + public function get_attribute( $name ) { + if ( null === $this->tag_name_starts_at ) { + return null; + } + + $comparable = strtolower( $name ); + + /* + * For every attribute other than `class` it's possible to perform a quick check if + * there's an enqueued lexical update whose value takes priority over what's found in + * the input document. + * + * The `class` attribute is special though because of the exposed helpers `add_class` + * and `remove_class`. These form a builder for the `class` attribute, so an additional + * check for enqueued class changes is required in addition to the check for any enqueued + * attribute values. If any exist, those enqueued class changes must first be flushed out + * into an attribute value update. + */ + if ( 'class' === $name ) { + $this->class_name_updates_to_attributes_updates(); + } + + // Return any enqueued attribute value updates if they exist. + $enqueued_value = $this->get_enqueued_attribute_value( $comparable ); + if ( false !== $enqueued_value ) { + return $enqueued_value; + } + + if ( ! isset( $this->attributes[ $comparable ] ) ) { + return null; + } + + $attribute = $this->attributes[ $comparable ]; + + /* + * This flag distinguishes an attribute with no value + * from an attribute with an empty string value. For + * unquoted attributes this could look very similar. + * It refers to whether an `=` follows the name. + * + * e.g.
+ * ¹ ² + * 1. Attribute `boolean-attribute` is `true`. + * 2. Attribute `empty-attribute` is `""`. + */ + if ( true === $attribute->is_true ) { + return true; + } + + $raw_value = substr( $this->html, $attribute->value_starts_at, $attribute->value_length ); + + return html_entity_decode( $raw_value ); + } + + /** + * Gets lowercase names of all attributes matching a given prefix in the current tag. + * + * Note that matching is case-insensitive. This is in accordance with the spec: + * + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag( array( 'class_name' => 'test' ) ) === true; + * $p->get_attribute_names_with_prefix( 'data-' ) === array( 'data-enabled', 'data-test-id' ); + * + * $p->next_tag() === false; + * $p->get_attribute_names_with_prefix( 'data-' ) === null; + * + * @since 6.2.0 + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + * + * @param string $prefix Prefix of requested attribute names. + * @return array|null List of attribute names, or `null` when no tag opener is matched. + */ + public function get_attribute_names_with_prefix( $prefix ) { + if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) { + return null; + } + + $comparable = strtolower( $prefix ); + + $matches = array(); + foreach ( array_keys( $this->attributes ) as $attr_name ) { + if ( str_starts_with( $attr_name, $comparable ) ) { + $matches[] = $attr_name; + } + } + return $matches; + } + + /** + * Returns the uppercase name of the matched tag. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
Test
' ); + * $p->next_tag() === true; + * $p->get_tag() === 'DIV'; + * + * $p->next_tag() === false; + * $p->get_tag() === null; + * + * @since 6.2.0 + * + * @return string|null Name of currently matched tag in input HTML, or `null` if none found. + */ + public function get_tag() { + if ( null === $this->tag_name_starts_at ) { + return null; + } + + $tag_name = substr( $this->html, $this->tag_name_starts_at, $this->tag_name_length ); + + return strtoupper( $tag_name ); + } + + /** + * Indicates if the currently matched tag contains the self-closing flag. + * + * No HTML elements ought to have the self-closing flag and for those, the self-closing + * flag will be ignored. For void elements this is benign because they "self close" + * automatically. For non-void HTML elements though problems will appear if someone + * intends to use a self-closing element in place of that element with an empty body. + * For HTML foreign elements and custom elements the self-closing flag determines if + * they self-close or not. + * + * This function does not determine if a tag is self-closing, + * but only if the self-closing flag is present in the syntax. + * + * @since 6.3.0 + * + * @return bool Whether the currently matched tag contains the self-closing flag. + */ + public function has_self_closing_flag() { + if ( ! $this->tag_name_starts_at ) { + return false; + } + + /* + * The self-closing flag is the solidus at the _end_ of the tag, not the beginning. + * + * Example: + * + *
+ * ^ this appears one character before the end of the closing ">". + */ + return '/' === $this->html[ $this->token_starts_at + $this->token_length - 1 ]; + } + + /** + * Indicates if the current tag token is a tag closer. + * + * Example: + * + * $p = new WP_HTML_Tag_Processor( '
' ); + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === false; + * + * $p->next_tag( array( 'tag_name' => 'div', 'tag_closers' => 'visit' ) ); + * $p->is_tag_closer() === true; + * + * @since 6.2.0 + * + * @return bool Whether the current tag is a tag closer. + */ + public function is_tag_closer() { + return $this->is_closing_tag; + } + + /** + * Updates or creates a new attribute on the currently matched tag with the passed value. + * + * For boolean attributes special handling is provided: + * - When `true` is passed as the value, then only the attribute name is added to the tag. + * - When `false` is passed, the attribute gets removed if it existed before. + * + * For string attributes, the value is escaped using the `esc_attr` function. + * + * @since 6.2.0 + * @since 6.2.1 Fix: Only create a single update for multiple calls with case-variant attribute names. + * + * @param string $name The attribute name to target. + * @param string|bool $value The new attribute value. + * @return bool Whether an attribute value was set. + */ + public function set_attribute( $name, $value ) { + if ( $this->is_closing_tag || null === $this->tag_name_starts_at ) { + return false; + } + + /* + * WordPress rejects more characters than are strictly forbidden + * in HTML5. This is to prevent additional security risks deeper + * in the WordPress and plugin stack. Specifically the + * less-than (<) greater-than (>) and ampersand (&) aren't allowed. + * + * The use of a PCRE match enables looking for specific Unicode + * code points without writing a UTF-8 decoder. Whereas scanning + * for one-byte characters is trivial (with `strcspn`), scanning + * for the longer byte sequences would be more complicated. Given + * that this shouldn't be in the hot path for execution, it's a + * reasonable compromise in efficiency without introducing a + * noticeable impact on the overall system. + * + * @see https://html.spec.whatwg.org/#attributes-2 + * + * @todo As the only regex pattern maybe we should take it out? + * Are Unicode patterns available broadly in Core? + */ + if ( preg_match( + '~[' . + // Syntax-like characters. + '"\'>& The values "true" and "false" are not allowed on boolean attributes. + * > To represent a false value, the attribute has to be omitted altogether. + * - HTML5 spec, https://html.spec.whatwg.org/#boolean-attributes + */ + if ( false === $value ) { + return $this->remove_attribute( $name ); + } + + if ( true === $value ) { + $updated_attribute = $name; + } else { + $escaped_new_value = esc_attr( $value ); + $updated_attribute = "{$name}=\"{$escaped_new_value}\""; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $comparable_name = strtolower( $name ); + + if ( isset( $this->attributes[ $comparable_name ] ) ) { + /* + * Update an existing attribute. + * + * Example – set attribute id to "new" in
: + * + *
+ * ^-------------^ + * start end + * replacement: `id="new"` + * + * Result:
+ */ + $existing_attribute = $this->attributes[ $comparable_name ]; + $this->lexical_updates[ $comparable_name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $existing_attribute->start, + $existing_attribute->length, + $updated_attribute + ); + } else { + /* + * Create a new attribute at the tag's name end. + * + * Example – add attribute id="new" to
: + * + *
+ * ^ + * start and end + * replacement: ` id="new"` + * + * Result:
+ */ + $this->lexical_updates[ $comparable_name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $this->tag_name_starts_at + $this->tag_name_length, + 0, + ' ' . $updated_attribute + ); + } + + /* + * Any calls to update the `class` attribute directly should wipe out any + * enqueued class changes from `add_class` and `remove_class`. + */ + if ( 'class' === $comparable_name && ! empty( $this->classname_updates ) ) { + $this->classname_updates = array(); + } + + return true; + } + + /** + * Remove an attribute from the currently-matched tag. + * + * @since 6.2.0 + * + * @param string $name The attribute name to remove. + * @return bool Whether an attribute was removed. + */ + public function remove_attribute( $name ) { + if ( $this->is_closing_tag ) { + return false; + } + + /* + * > There must never be two or more attributes on + * > the same start tag whose names are an ASCII + * > case-insensitive match for each other. + * - HTML 5 spec + * + * @see https://html.spec.whatwg.org/multipage/syntax.html#attributes-2:ascii-case-insensitive + */ + $name = strtolower( $name ); + + /* + * Any calls to update the `class` attribute directly should wipe out any + * enqueued class changes from `add_class` and `remove_class`. + */ + if ( 'class' === $name && count( $this->classname_updates ) !== 0 ) { + $this->classname_updates = array(); + } + + /* + * If updating an attribute that didn't exist in the input + * document, then remove the enqueued update and move on. + * + * For example, this might occur when calling `remove_attribute()` + * after calling `set_attribute()` for the same attribute + * and when that attribute wasn't originally present. + */ + if ( ! isset( $this->attributes[ $name ] ) ) { + if ( isset( $this->lexical_updates[ $name ] ) ) { + unset( $this->lexical_updates[ $name ] ); + } + return false; + } + + /* + * Removes an existing tag attribute. + * + * Example – remove the attribute id from
: + *
+ * ^-------------^ + * start end + * replacement: `` + * + * Result:
+ */ + $this->lexical_updates[ $name ] = new Gutenberg_HTML_Text_Replacement_6_5( + $this->attributes[ $name ]->start, + $this->attributes[ $name ]->length, + '' + ); + + // Removes any duplicated attributes if they were also present. + if ( null !== $this->duplicate_attributes && array_key_exists( $name, $this->duplicate_attributes ) ) { + foreach ( $this->duplicate_attributes[ $name ] as $attribute_token ) { + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( + $attribute_token->start, + $attribute_token->length, + '' + ); + } + } + + return true; + } + + /** + * Adds a new class name to the currently matched tag. + * + * @since 6.2.0 + * + * @param string $class_name The class name to add. + * @return bool Whether the class was set to be added. + */ + public function add_class( $class_name ) { + if ( $this->is_closing_tag ) { + return false; + } + + if ( null !== $this->tag_name_starts_at ) { + $this->classname_updates[ $class_name ] = self::ADD_CLASS; + } + + return true; + } + + /** + * Removes a class name from the currently matched tag. + * + * @since 6.2.0 + * + * @param string $class_name The class name to remove. + * @return bool Whether the class was set to be removed. + */ + public function remove_class( $class_name ) { + if ( $this->is_closing_tag ) { + return false; + } + + if ( null !== $this->tag_name_starts_at ) { + $this->classname_updates[ $class_name ] = self::REMOVE_CLASS; + } + + return true; + } + + /** + * Returns the string representation of the HTML Tag Processor. + * + * @since 6.2.0 + * + * @see WP_HTML_Tag_Processor::get_updated_html() + * + * @return string The processed HTML. + */ + public function __toString() { + return $this->get_updated_html(); + } + + /** + * Returns the string representation of the HTML Tag Processor. + * + * @since 6.2.0 + * @since 6.2.1 Shifts the internal cursor corresponding to the applied updates. + * @since 6.4.0 No longer calls subclass method `next_tag()` after updating HTML. + * + * @return string The processed HTML. + */ + public function get_updated_html() { + $requires_no_updating = 0 === count( $this->classname_updates ) && 0 === count( $this->lexical_updates ); + + /* + * When there is nothing more to update and nothing has already been + * updated, return the original document and avoid a string copy. + */ + if ( $requires_no_updating ) { + return $this->html; + } + + /* + * Keep track of the position right before the current tag. This will + * be necessary for reparsing the current tag after updating the HTML. + */ + $before_current_tag = $this->token_starts_at; + + /* + * 1. Apply the enqueued edits and update all the pointers to reflect those changes. + */ + $this->class_name_updates_to_attributes_updates(); + $before_current_tag += $this->apply_attributes_updates( $before_current_tag ); + + /* + * 2. Rewind to before the current tag and reparse to get updated attributes. + * + * At this point the internal cursor points to the end of the tag name. + * Rewind before the tag name starts so that it's as if the cursor didn't + * move; a call to `next_tag()` will reparse the recently-updated attributes + * and additional calls to modify the attributes will apply at this same + * location, but in order to avoid issues with subclasses that might add + * behaviors to `next_tag()`, the internal methods should be called here + * instead. + * + * It's important to note that in this specific place there will be no change + * because the processor was already at a tag when this was called and it's + * rewinding only to the beginning of this very tag before reprocessing it + * and its attributes. + * + *

Previous HTMLMore HTML

+ * ↑ │ back up by the length of the tag name plus the opening < + * └←─┘ back up by strlen("em") + 1 ==> 3 + */ + $this->bytes_already_parsed = $before_current_tag; + $this->parse_next_tag(); + // Reparse the attributes. + while ( $this->parse_next_attribute() ) { + continue; + } + + $tag_ends_at = strpos( $this->html, '>', $this->bytes_already_parsed ); + $this->token_length = $tag_ends_at - $this->token_starts_at; + $this->bytes_already_parsed = $tag_ends_at; + + return $this->html; + } + + /** + * Parses tag query input into internal search criteria. + * + * @since 6.2.0 + * + * @param array|string|null $query { + * Optional. Which tag name to find, having which class, etc. Default is to find any tag. + * + * @type string|null $tag_name Which tag to find, or `null` for "any tag." + * @type int|null $match_offset Find the Nth tag matching all search criteria. + * 1 for "first" tag, 3 for "third," etc. + * Defaults to first tag. + * @type string|null $class_name Tag must contain this class name to match. + * @type string $tag_closers "visit" or "skip": whether to stop on tag closers, e.g.
. + * } + */ + private function parse_query( $query ) { + if ( null !== $query && $query === $this->last_query ) { + return; + } + + $this->last_query = $query; + $this->sought_tag_name = null; + $this->sought_class_name = null; + $this->sought_match_offset = 1; + $this->stop_on_tag_closers = false; + + // A single string value means "find the tag of this name". + if ( is_string( $query ) ) { + $this->sought_tag_name = $query; + return; + } + + // An empty query parameter applies no restrictions on the search. + if ( null === $query ) { + return; + } + + // If not using the string interface, an associative array is required. + if ( ! is_array( $query ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The query argument must be an array or a tag name.' ), + '6.2.0' + ); + return; + } + + if ( isset( $query['tag_name'] ) && is_string( $query['tag_name'] ) ) { + $this->sought_tag_name = $query['tag_name']; + } + + if ( isset( $query['class_name'] ) && is_string( $query['class_name'] ) ) { + $this->sought_class_name = $query['class_name']; + } + + if ( isset( $query['match_offset'] ) && is_int( $query['match_offset'] ) && 0 < $query['match_offset'] ) { + $this->sought_match_offset = $query['match_offset']; + } + + if ( isset( $query['tag_closers'] ) ) { + $this->stop_on_tag_closers = 'visit' === $query['tag_closers']; + } + } + + + /** + * Checks whether a given tag and its attributes match the search criteria. + * + * @since 6.2.0 + * + * @return bool Whether the given tag and its attribute match the search criteria. + */ + private function matches() { + if ( $this->is_closing_tag && ! $this->stop_on_tag_closers ) { + return false; + } + + // Does the tag name match the requested tag name in a case-insensitive manner? + if ( null !== $this->sought_tag_name ) { + /* + * String (byte) length lookup is fast. If they aren't the + * same length then they can't be the same string values. + */ + if ( strlen( $this->sought_tag_name ) !== $this->tag_name_length ) { + return false; + } + + /* + * Check each character to determine if they are the same. + * Defer calls to `strtoupper()` to avoid them when possible. + * Calling `strcasecmp()` here tested slowed than comparing each + * character, so unless benchmarks show otherwise, it should + * not be used. + * + * It's expected that most of the time that this runs, a + * lower-case tag name will be supplied and the input will + * contain lower-case tag names, thus normally bypassing + * the case comparison code. + */ + for ( $i = 0; $i < $this->tag_name_length; $i++ ) { + $html_char = $this->html[ $this->tag_name_starts_at + $i ]; + $tag_char = $this->sought_tag_name[ $i ]; + + if ( $html_char !== $tag_char && strtoupper( $html_char ) !== $tag_char ) { + return false; + } + } + } + + if ( null !== $this->sought_class_name && ! $this->has_class( $this->sought_class_name ) ) { + return false; + } + + return true; + } +} diff --git a/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php new file mode 100644 index 0000000000000..6409255833c81 --- /dev/null +++ b/lib/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php @@ -0,0 +1,64 @@ +start = $start; + $this->length = $length; + $this->text = $text; + } +} diff --git a/lib/compat/wordpress-6.5/rest-api.php b/lib/compat/wordpress-6.5/rest-api.php index 3b82815c41e42..12d789fb58b86 100644 --- a/lib/compat/wordpress-6.5/rest-api.php +++ b/lib/compat/wordpress-6.5/rest-api.php @@ -91,7 +91,11 @@ function _gutenberg_get_wp_templates_author_text_field( $template_object ) { case 'site': return get_bloginfo( 'name' ); case 'user': - return get_user_by( 'id', $template_object['author'] )->get( 'display_name' ); + $author = get_user_by( 'id', $template_object['author'] ); + if ( ! $author ) { + return __( 'Unknown author', 'gutenberg' ); + } + return $author->get( 'display_name' ); } } diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index f9f2412ae5120..88e46b478389d 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -82,7 +82,10 @@ function wp_enqueue_block_view_script( $block_name, $args ) { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); -if ( $gutenberg_experiments && array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) ) { +if ( $gutenberg_experiments && ( + array_key_exists( 'gutenberg-connections', $gutenberg_experiments ) || + array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) +) ) { /** * Renders the block meta attributes. * @@ -132,9 +135,8 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the source value is not "meta_fields", skip it because the only supported - // connection source is meta (custom fields) for now. - if ( 'meta_fields' !== $attribute_value['source'] ) { + // Skip if the source value is not "meta_fields" or "pattern_attributes". + if ( 'meta_fields' !== $attribute_value['source'] && 'pattern_attributes' !== $attribute_value['source'] ) { continue; } @@ -143,16 +145,28 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst continue; } - // If the attribute does not specify the name of the custom field, skip it. - if ( ! isset( $attribute_value['value'] ) ) { - continue; + if ( 'pattern_attributes' === $attribute_value['source'] ) { + if ( ! _wp_array_get( $block_instance->attributes, array( 'metadata', 'id' ), false ) ) { + continue; + } + + $custom_value = $connection_sources[ $attribute_value['source'] ]( $block_instance ); + } else { + // If the attribute does not specify the name of the custom field, skip it. + if ( ! isset( $attribute_value['value'] ) ) { + continue; + } + + // Get the content from the connection source. + $custom_value = $connection_sources[ $attribute_value['source'] ]( + $block_instance, + $attribute_value['value'] + ); } - // Get the content from the connection source. - $custom_value = $connection_sources[ $attribute_value['source'] ]( - $block_instance, - $attribute_value['value'] - ); + if ( false === $custom_value ) { + continue; + } $tags = new WP_HTML_Tag_Processor( $block_content ); $found = $tags->next_tag( @@ -181,5 +195,6 @@ function gutenberg_render_block_connections( $block_content, $block, $block_inst return $block_content; } + add_filter( 'render_block', 'gutenberg_render_block_connections', 10, 3 ); } diff --git a/lib/experimental/connection-sources/index.php b/lib/experimental/connection-sources/index.php index b63abcad96f62..bf89ba177b6e9 100644 --- a/lib/experimental/connection-sources/index.php +++ b/lib/experimental/connection-sources/index.php @@ -6,10 +6,14 @@ */ return array( - 'name' => 'meta', - 'meta_fields' => function ( $block_instance, $meta_field ) { + 'name' => 'meta', + 'meta_fields' => function ( $block_instance, $meta_field ) { // We should probably also check if the meta field exists but for now it's okay because // if it doesn't, `get_post_meta()` will just return an empty string. return get_post_meta( $block_instance->context['postId'], $meta_field, true ); }, + 'pattern_attributes' => function ( $block_instance ) { + $block_id = $block_instance->attributes['metadata']['id']; + return _wp_array_get( $block_instance->context, array( 'pattern/overrides', $block_id ), false ); + }, ); diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 2c7d6310005bf..5f61684e8b134 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -33,6 +33,10 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-no-tinymce' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalDisableTinymce = true', 'before' ); } + + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalPatternPartialSyncing = true', 'before' ); + } } add_action( 'admin_init', 'gutenberg_enable_experiments' ); diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php index e717b2e553943..cf55a048bb9fa 100644 --- a/lib/experimental/interactivity-api/class-wp-directive-processor.php +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -20,7 +20,7 @@ * available. Please restrain from investing unnecessary time and effort trying * to improve this code. */ -class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_4 { +class WP_Directive_Processor extends Gutenberg_HTML_Tag_Processor_6_5 { /** * An array of root blocks. @@ -195,7 +195,7 @@ public function get_inner_html() { } list( $start_name, $end_name ) = $bookmarks; - $start = $this->bookmarks[ $start_name ]->end + 1; + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; $this->seek( $start_name ); // Return to original position. @@ -225,14 +225,14 @@ public function set_inner_html( $new_html ) { } list( $start_name, $end_name ) = $bookmarks; - $start = $this->bookmarks[ $start_name ]->end + 1; + $start = $this->bookmarks[ $start_name ]->start + $this->bookmarks[ $start_name ]->length + 1; $end = $this->bookmarks[ $end_name ]->start; $this->seek( $start_name ); // Return to original position. $this->release_bookmark( $start_name ); $this->release_bookmark( $end_name ); - $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html ); + $this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( $start, $end - $start, $new_html ); return true; } diff --git a/lib/experimental/interactivity-api/modules.php b/lib/experimental/interactivity-api/modules.php index 02785a152ca1f..0695da26f4b1b 100644 --- a/lib/experimental/interactivity-api/modules.php +++ b/lib/experimental/interactivity-api/modules.php @@ -16,18 +16,6 @@ function gutenberg_register_interactivity_module() { array(), defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' ) ); - - // TODO: Replace with a simpler version that only provides support for import maps. - // TODO: Load only if the browser doesn't support import maps (https://github.com/guybedford/es-module-shims/issues/371). - wp_enqueue_script( - 'es-module-shims', - gutenberg_url( '/build/modules/importmap-polyfill.min.js' ), - array(), - null, - array( - 'strategy' => 'defer', - ) - ); } add_action( 'wp_enqueue_scripts', 'gutenberg_register_interactivity_module' ); diff --git a/lib/experimental/modules/class-gutenberg-modules.php b/lib/experimental/modules/class-gutenberg-modules.php index ca74d863043ee..5f847fa8c897a 100644 --- a/lib/experimental/modules/class-gutenberg-modules.php +++ b/lib/experimental/modules/class-gutenberg-modules.php @@ -114,6 +114,26 @@ public static function print_module_preloads() { } } + /** + * Prints the necessary script to load import map polyfill for browsers that + * do not support import maps. + * + * TODO: Replace the polyfill with a simpler version that only provides + * support for import maps and load it only when the browser doesn't support + * import maps (https://github.com/guybedford/es-module-shims/issues/371). + */ + public static function print_import_map_polyfill() { + $import_map = self::get_import_map(); + if ( ! empty( $import_map['imports'] ) ) { + wp_print_script_tag( + array( + 'src' => gutenberg_url( '/build/modules/importmap-polyfill.min.js' ), + 'defer' => true, + ) + ); + } + } + /** * Gets the module's version. It either returns a timestamp (if SCRIPT_DEBUG * is true), the explicit version of the module if it is set and not false, or @@ -193,3 +213,6 @@ function gutenberg_enqueue_module( $module_identifier ) { // Prints the preloaded modules in the head tag. add_action( 'wp_head', array( 'Gutenberg_Modules', 'print_module_preloads' ) ); + +// Prints the script that loads the import map polyfill in the footer. +add_action( 'wp_footer', array( 'Gutenberg_Modules', 'print_import_map_polyfill' ), 11 ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 0bcd28b2aa2c4..b77a69b692ff1 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -138,6 +138,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-pattern-partial-syncing', + __( 'Synced patterns partial syncing', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Test partial syncing of patterns', 'gutenberg' ), + 'id' => 'gutenberg-pattern-partial-syncing', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/lib/load.php b/lib/load.php index 5da1a1126da26..59fb75541ac41 100644 --- a/lib/load.php +++ b/lib/load.php @@ -76,6 +76,10 @@ function gutenberg_is_experiment_enabled( $name ) { * always be loaded so that Gutenberg code can run the newest version of the Tag Processor. */ require __DIR__ . '/compat/wordpress-6.4/html-api/class-gutenberg-html-tag-processor-6-4.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-attribute-token-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-span-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-text-replacement-6-5.php'; +require __DIR__ . '/compat/wordpress-6.5/html-api/class-gutenberg-html-tag-processor-6-5.php'; /* * The HTML Processor appeared after WordPress 6.3. If Gutenberg is running on a version of @@ -248,6 +252,7 @@ function () { require __DIR__ . '/block-supports/shadow.php'; require __DIR__ . '/block-supports/background.php'; require __DIR__ . '/block-supports/behaviors.php'; +require __DIR__ . '/block-supports/pattern.php'; // Data views. require_once __DIR__ . '/experimental/data-views.php'; diff --git a/package-lock.json b/package-lock.json index f0a5d1cd4906f..e0c9500416a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "17.2.0-rc.1", + "version": "17.3.0-rc.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "17.2.0-rc.1", + "version": "17.3.0-rc.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -29,6 +29,7 @@ "@wordpress/customize-widgets": "file:packages/customize-widgets", "@wordpress/data": "file:packages/data", "@wordpress/data-controls": "file:packages/data-controls", + "@wordpress/dataviews": "file:packages/dataviews", "@wordpress/date": "file:packages/date", "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", @@ -15264,37 +15265,6 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, - "node_modules/@tanstack/react-table": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", - "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", - "dependencies": { - "@tanstack/table-core": "8.10.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", - "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -18315,6 +18285,10 @@ "resolved": "packages/data-controls", "link": true }, + "node_modules/@wordpress/dataviews": { + "resolved": "packages/dataviews", + "link": true + }, "node_modules/@wordpress/date": { "resolved": "packages/date", "link": true @@ -25683,15 +25657,6 @@ "node": ">=6" } }, - "node_modules/docker-compose": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.22.2.tgz", - "integrity": "sha512-iXWb5+LiYmylIMFXvGTYsjI1F+Xyx78Jm/uj1dxwwZLbWkUdH6yOXY5Nr3RjbYX15EgbGJCq78d29CmWQQQMPg==", - "dev": true, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -54678,6 +54643,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", @@ -55057,6 +55023,30 @@ "react": "^18.0.0" } }, + "packages/dataviews": { + "name": "@wordpress/dataviews", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/private-apis": "file:../private-apis", + "classnames": "^2.3.1", + "remove-accents": "^0.5.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "packages/date": { "name": "@wordpress/date", "version": "4.47.0", @@ -55278,7 +55268,6 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", @@ -55291,6 +55280,7 @@ "@wordpress/core-commands": "file:../core-commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", @@ -55390,6 +55380,7 @@ "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", + "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", "@wordpress/core-data": "file:../core-data", @@ -55455,7 +55446,7 @@ "dependencies": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", - "docker-compose": "^0.22.2", + "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", "inquirer": "^7.1.0", @@ -55484,6 +55475,18 @@ "node": ">=12" } }, + "packages/env/node_modules/docker-compose": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.2.tgz", + "integrity": "sha512-2/WLvA7UZ6A2LDLQrYW0idKipmNBWhtfvrn2yzjC5PnHDzuFVj1zAZN6MJxVMKP0zZH8uzAK6OwVZYHGuyCmTw==", + "dev": true, + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "packages/env/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -55536,6 +55539,15 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, + "packages/env/node_modules/yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "packages/env/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -55824,8 +55836,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" }, "engines": { "node": ">=12" @@ -55952,7 +55963,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "engines": { "node": ">=16.0.0" @@ -56123,7 +56135,7 @@ }, "packages/react-native-aztec": { "name": "@wordpress/react-native-aztec", - "version": "1.109.0", + "version": "1.109.2", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/element": "file:../element", @@ -56136,7 +56148,7 @@ }, "packages/react-native-bridge": { "name": "@wordpress/react-native-bridge", - "version": "1.109.0", + "version": "1.109.2", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/react-native-aztec": "file:../react-native-aztec" @@ -56147,7 +56159,7 @@ }, "packages/react-native-editor": { "name": "@wordpress/react-native-editor", - "version": "1.109.0", + "version": "1.109.2", "hasInstallScript": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -67517,19 +67529,6 @@ "resolved": "https://registry.npmjs.org/@tannin/postfix/-/postfix-1.1.0.tgz", "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==" }, - "@tanstack/react-table": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.3.tgz", - "integrity": "sha512-Qya1cJ+91arAlW7IRDWksRDnYw28O446jJ/ljkRSc663EaftJoBCAU10M+VV1K6MpCBLrXq1BD5IQc1zj/ZEjA==", - "requires": { - "@tanstack/table-core": "8.10.3" - } - }, - "@tanstack/table-core": { - "version": "8.10.3", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.3.tgz", - "integrity": "sha512-hJ55YfJlWbfzRROfcyA/kC1aZr/shsLA8XNAwN8jXylhYWGLnPmiJJISrUfj4dMMWRiFi0xBlnlC7MLH+zSrcw==" - }, "@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -70097,6 +70096,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", @@ -70370,6 +70370,22 @@ "@wordpress/deprecated": "file:../deprecated" } }, + "@wordpress/dataviews": { + "version": "file:packages/dataviews", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/private-apis": "file:../private-apis", + "classnames": "^2.3.1", + "remove-accents": "^0.5.0" + } + }, "@wordpress/date": { "version": "file:packages/date", "requires": { @@ -70512,7 +70528,6 @@ "version": "file:packages/edit-site", "requires": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", @@ -70525,6 +70540,7 @@ "@wordpress/core-commands": "file:../core-commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", @@ -70606,6 +70622,7 @@ "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", + "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", "@wordpress/core-data": "file:../core-data", @@ -70656,7 +70673,7 @@ "requires": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", - "docker-compose": "^0.22.2", + "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", "inquirer": "^7.1.0", @@ -70679,6 +70696,14 @@ "wrap-ansi": "^7.0.0" } }, + "docker-compose": { + "version": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.2.tgz", + "integrity": "sha512-2/WLvA7UZ6A2LDLQrYW0idKipmNBWhtfvrn2yzjC5PnHDzuFVj1zAZN6MJxVMKP0zZH8uzAK6OwVZYHGuyCmTw==", + "dev": true, + "requires": { + "yaml": "^2.2.2" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -70719,6 +70744,12 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, + "yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "dev": true + }, "yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -70885,8 +70916,7 @@ "version": "file:packages/keycodes", "requires": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" } }, "@wordpress/lazy-import": { @@ -70962,7 +70992,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" } }, "@wordpress/plugins": { @@ -76934,12 +76965,6 @@ "@leichtgewicht/ip-codec": "^2.0.1" } }, - "docker-compose": { - "version": "0.22.2", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.22.2.tgz", - "integrity": "sha512-iXWb5+LiYmylIMFXvGTYsjI1F+Xyx78Jm/uj1dxwwZLbWkUdH6yOXY5Nr3RjbYX15EgbGJCq78d29CmWQQQMPg==", - "dev": true - }, "doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", diff --git a/package.json b/package.json index bc4f43a47d03f..cab3288450cd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "17.2.0-rc.1", + "version": "17.3.0-rc.1", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -41,6 +41,7 @@ "@wordpress/customize-widgets": "file:packages/customize-widgets", "@wordpress/data": "file:packages/data", "@wordpress/data-controls": "file:packages/data-controls", + "@wordpress/dataviews": "file:packages/dataviews", "@wordpress/date": "file:packages/date", "@wordpress/deprecated": "file:packages/deprecated", "@wordpress/dom": "file:packages/dom", @@ -321,6 +322,7 @@ "test:e2e:storybook": "playwright test --config test/storybook-playwright/playwright.config.ts", "test:e2e:watch": "npm run test:e2e -- --watch", "test:native": "cross-env NODE_ENV=test jest --config test/native/jest.config.js", + "test:native:watch": "npm run test:native -- --watch", "test:native:clean": "jest --clearCache --config test/native/jest.config.js; rm -rf $TMPDIR/jest_*", "test:native:debug": "cross-env NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand --verbose --config test/native/jest.config.js", "test:native:perf": "cross-env TEST_RUNNER_ARGS='--runInBand --config test/native/jest.config.js --testMatch \"**/performance/*.native.[jt]s?(x)\"' reassure", diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 536d07ec4da34..7d1fd0796f109 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -128,7 +128,7 @@ $z-layers: ( ".block-editor-block-rename-modal": 1000001, ".edit-site-list__rename-modal": 1000001, ".dataviews-action-modal": 1000001, - ".edit-site-swap-template-modal": 1000001, + ".editor-post-template__swap-template-modal": 1000001, ".edit-site-template-panel__replace-template-modal": 1000001, // Note: The ConfirmDialog component's z-index is being set to 1000001 in packages/components/src/confirm-dialog/styles.ts @@ -156,9 +156,6 @@ $z-layers: ( // Show tooltips above NUX tips, wp-admin menus, submenus, and sidebar: ".components-tooltip": 1000002, - // Keep template popover underneath 'Create custom template' modal overlay. - ".edit-post-post-template__dialog": 99999, - // Make sure corner handles are above side handles for ResizableBox component ".components-resizable-box__handle": 2, ".components-resizable-box__side-handle": 2, diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 2d6a5627a52a4..6c39b5dcc44b4 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -19,7 +19,6 @@ import { useState } from 'react'; import { BlockEditorProvider, BlockList, - BlockTools, WritingFlow, } from '@wordpress/block-editor'; @@ -32,9 +31,7 @@ function MyEditorComponent() { onInput={ ( blocks ) => updateBlocks( blocks ) } onChange={ ( blocks ) => updateBlocks( blocks ) } > - - - + ); } @@ -280,10 +277,18 @@ _Returns_ ### BlockToolbar +Renders the block toolbar. + _Related_ - +_Parameters_ + +- _props_ `Object`: Components props. +- _props.hideDragHandle_ `boolean`: Show or hide the Drag Handle for drag and drop functionality. +- _props.variant_ `string`: Style variant of the toolbar, also passed to the Dropdowns rendered from Block Toolbar Buttons. + ### BlockTools Renders block tools (the block toolbar, select/navigation mode toolbar, the insertion point and a slot for the inline rich text toolbar). Must be wrapped around the block content and editor styles wrapper or iframe. diff --git a/packages/block-editor/src/components/block-canvas/index.js b/packages/block-editor/src/components/block-canvas/index.js index 97aec461df7d8..7d64897690721 100644 --- a/packages/block-editor/src/components/block-canvas/index.js +++ b/packages/block-editor/src/components/block-canvas/index.js @@ -2,11 +2,13 @@ * WordPress dependencies */ import { useMergeRefs } from '@wordpress/compose'; +import { useRef } from '@wordpress/element'; /** * Internal dependencies */ import BlockList from '../block-list'; +import BlockTools from '../block-tools'; import EditorStyles from '../editor-styles'; import Iframe from '../iframe'; import WritingFlow from '../writing-flow'; @@ -23,11 +25,15 @@ export function ExperimentalBlockCanvas( { } ) { const resetTypingRef = useMouseMoveTypingReset(); const clearerRef = useBlockSelectionClearer(); - const contentRef = useMergeRefs( [ contentRefProp, clearerRef ] ); + const localRef = useRef(); + const contentRef = useMergeRefs( [ contentRefProp, clearerRef, localRef ] ); if ( ! shouldIframe ) { return ( - <> + { children } - + ); } return ( - + + ); } diff --git a/packages/block-editor/src/components/block-caption/README.md b/packages/block-editor/src/components/block-caption/README.md index ca354b83f5a20..acb53fd4aa4c9 100644 --- a/packages/block-editor/src/components/block-caption/README.md +++ b/packages/block-editor/src/components/block-caption/README.md @@ -16,7 +16,7 @@ The `BlockCaption` component renders block-level UI for adding and editing capti Renders an editable caption field designed specifically for block-level use. ```jsx -import { BlockCaption } from '@wordpress/block-editor'; +import { BlockCaption, RichText } from '@wordpress/block-editor'; const MyBlockCaption = ( clientId, @@ -29,7 +29,7 @@ const MyBlockCaption = ( clientId={ clientId } accessible={ true } accessibilityLabelCreator={ ( caption ) => - ! caption + RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty caption. */ 'Caption. Empty' : sprintf( diff --git a/packages/block-editor/src/components/block-card/index.js b/packages/block-editor/src/components/block-card/index.js index 48f08f06f368c..fd89697a54c5d 100644 --- a/packages/block-editor/src/components/block-card/index.js +++ b/packages/block-editor/src/components/block-card/index.js @@ -62,9 +62,11 @@ function BlockCard( { title, icon, description, blockType, className } ) {

{ title }

- - { description } - + { description && ( + + { description } + + ) }
); diff --git a/packages/block-editor/src/components/block-card/style.scss b/packages/block-editor/src/components/block-card/style.scss index 1b2156c19fa79..282161f0ec519 100644 --- a/packages/block-editor/src/components/block-card/style.scss +++ b/packages/block-editor/src/components/block-card/style.scss @@ -1,24 +1,28 @@ .block-editor-block-card { - display: flex; align-items: flex-start; + color: $gray-900; + display: flex; + padding: $grid-unit-20; } .block-editor-block-card__content { flex-grow: 1; - margin-bottom: $grid-unit-05; } .block-editor-block-card__title { font-weight: 500; &.block-editor-block-card__title { + font-size: $default-font-size; line-height: $button-size-small; - margin: 0 0 $grid-unit-05; + margin: 0; } } .block-editor-block-card__description { + display: block; font-size: $default-font-size; + margin-top: $grid-unit-05; } .block-editor-block-card .block-editor-block-icon { diff --git a/packages/block-editor/src/components/block-controls/hook.js b/packages/block-editor/src/components/block-controls/hook.js index 18a38e245e58a..4e9592471cb29 100644 --- a/packages/block-editor/src/components/block-controls/hook.js +++ b/packages/block-editor/src/components/block-controls/hook.js @@ -1,45 +1,23 @@ /** * WordPress dependencies */ -import { store as blocksStore } from '@wordpress/blocks'; -import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ import groups from './groups'; -import { store as blockEditorStore } from '../../store'; -import { useBlockEditContext } from '../block-edit/context'; -import useDisplayBlockControls from '../use-display-block-controls'; +import { + useBlockEditContext, + mayDisplayControlsKey, + mayDisplayParentControlsKey, +} from '../block-edit/context'; export default function useBlockControlsFill( group, shareWithChildBlocks ) { - const isDisplayed = useDisplayBlockControls(); - const { clientId } = useBlockEditContext(); - const isParentDisplayed = useSelect( - ( select ) => { - if ( ! shareWithChildBlocks ) { - return false; - } - - const { getBlockName, hasSelectedInnerBlock } = - select( blockEditorStore ); - const { hasBlockSupport } = select( blocksStore ); - - return ( - hasBlockSupport( - getBlockName( clientId ), - '__experimentalExposeControlsToChildren', - false - ) && hasSelectedInnerBlock( clientId ) - ); - }, - [ shareWithChildBlocks, clientId ] - ); - - if ( isDisplayed ) { + const context = useBlockEditContext(); + if ( context[ mayDisplayControlsKey ] ) { return groups[ group ]?.Fill; } - if ( isParentDisplayed ) { + if ( context[ mayDisplayParentControlsKey ] && shareWithChildBlocks ) { return groups.parent.Fill; } return null; diff --git a/packages/block-editor/src/components/block-controls/test/index.js b/packages/block-editor/src/components/block-controls/test/index.js index cbfcb3c1873a7..bbd987cbeb20e 100644 --- a/packages/block-editor/src/components/block-controls/test/index.js +++ b/packages/block-editor/src/components/block-controls/test/index.js @@ -59,7 +59,7 @@ describe( 'BlockControls', () => { it( 'should render a dynamic toolbar of controls', () => { render( - +

Child

@@ -84,7 +84,7 @@ describe( 'BlockControls', () => { it( 'should render its children', () => { render( - +

Child

@@ -99,7 +99,7 @@ describe( 'BlockControls', () => { it( 'should a dynamic toolbar when passed as children', () => { render( - + diff --git a/packages/block-editor/src/components/block-edit/context.js b/packages/block-editor/src/components/block-edit/context.js index d8b9c82948410..6b0b1af9ea22d 100644 --- a/packages/block-editor/src/components/block-edit/context.js +++ b/packages/block-editor/src/components/block-edit/context.js @@ -3,6 +3,9 @@ */ import { createContext, useContext } from '@wordpress/element'; +export const mayDisplayControlsKey = Symbol( 'mayDisplayControls' ); +export const mayDisplayParentControlsKey = Symbol( 'mayDisplayParentControls' ); + export const DEFAULT_BLOCK_EDIT_CONTEXT = { name: '', isSelected: false, diff --git a/packages/block-editor/src/components/block-edit/index.js b/packages/block-editor/src/components/block-edit/index.js index 6faefbc626192..bbef47b27c579 100644 --- a/packages/block-editor/src/components/block-edit/index.js +++ b/packages/block-editor/src/components/block-edit/index.js @@ -8,7 +8,12 @@ import { hasBlockSupport } from '@wordpress/blocks'; * Internal dependencies */ import Edit from './edit'; -import { BlockEditContextProvider, useBlockEditContext } from './context'; +import { + BlockEditContextProvider, + useBlockEditContext, + mayDisplayControlsKey, + mayDisplayParentControlsKey, +} from './context'; /** * The `useBlockEditContext` hook provides information about the block this hook is being used in. @@ -20,7 +25,13 @@ import { BlockEditContextProvider, useBlockEditContext } from './context'; */ export { useBlockEditContext }; -export default function BlockEdit( props ) { +export default function BlockEdit( { + mayDisplayControls, + mayDisplayParentControls, + // The remaining props are passed through the BlockEdit filters and are thus + // public API! + ...props +} ) { const { name, isSelected, @@ -32,19 +43,34 @@ export default function BlockEdit( props ) { const layoutSupport = hasBlockSupport( name, 'layout', false ) || hasBlockSupport( name, '__experimentalLayout', false ); - const context = { - name, - isSelected, - clientId, - layout: layoutSupport ? layout : null, - __unstableLayoutClassNames, - }; return ( context, Object.values( context ) ) } + value={ useMemo( + () => ( { + name, + isSelected, + clientId, + layout: layoutSupport ? layout : null, + __unstableLayoutClassNames, + // We use symbols in favour of an __unstable prefix to avoid + // usage outside of the package (this context is exposed). + [ mayDisplayControlsKey ]: mayDisplayControls, + [ mayDisplayParentControlsKey ]: mayDisplayParentControls, + } ), + [ + name, + isSelected, + clientId, + layoutSupport, + layout, + __unstableLayoutClassNames, + mayDisplayControls, + mayDisplayParentControls, + ] + ) } > diff --git a/packages/block-editor/src/components/block-info-slot-fill/index.js b/packages/block-editor/src/components/block-info-slot-fill/index.js index db7919b6ef5ea..8c9503313d754 100644 --- a/packages/block-editor/src/components/block-info-slot-fill/index.js +++ b/packages/block-editor/src/components/block-info-slot-fill/index.js @@ -7,14 +7,17 @@ import { privateApis as componentsPrivateApis } from '@wordpress/components'; * Internal dependencies */ import { unlock } from '../../lock-unlock'; -import useDisplayBlockControls from '../use-display-block-controls'; +import { + useBlockEditContext, + mayDisplayControlsKey, +} from '../block-edit/context'; const { createPrivateSlotFill } = unlock( componentsPrivateApis ); const { Fill, Slot } = createPrivateSlotFill( 'BlockInformation' ); const BlockInfo = ( props ) => { - const isDisplayed = useDisplayBlockControls(); - if ( ! isDisplayed ) { + const context = useBlockEditContext(); + if ( ! context[ mayDisplayControlsKey ] ) { return null; } return ; diff --git a/packages/block-editor/src/components/block-inspector/style.scss b/packages/block-editor/src/components/block-inspector/style.scss index e57b54bd32c82..cf7131722722c 100644 --- a/packages/block-editor/src/components/block-inspector/style.scss +++ b/packages/block-editor/src/components/block-inspector/style.scss @@ -34,10 +34,6 @@ // Ensures this PanelBody is treated like the ToolsPanel, removing double borders. margin-top: -$border-width; } - - .block-editor-block-card { - padding: $grid-unit-20; - } } .block-editor-block-inspector__no-blocks, diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index a95075c6f9b42..b38dcf3ef1f2e 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -15,6 +15,7 @@ import { switchToBlockType, getDefaultBlockName, isUnmodifiedBlock, + store as blocksStore, } from '@wordpress/blocks'; import { withFilters } from '@wordpress/components'; import { @@ -53,10 +54,18 @@ function mergeWrapperProps( propsA, propsB ) { ...propsB, }; - if ( propsA?.className && propsB?.className ) { + // May be set to undefined, so check if the property is set! + if ( + propsA?.hasOwnProperty( 'className' ) && + propsB?.hasOwnProperty( 'className' ) + ) { newProps.className = classnames( propsA.className, propsB.className ); } - if ( propsA?.style && propsB?.style ) { + + if ( + propsA?.hasOwnProperty( 'style' ) && + propsB?.hasOwnProperty( 'style' ) + ) { newProps.style = { ...propsA.style, ...propsB.style }; } @@ -95,21 +104,40 @@ function BlockListBlock( { themeSupportsLayout, isTemporarilyEditingAsBlocks, blockEditingMode, + mayDisplayControls, + mayDisplayParentControls, } = useSelect( ( select ) => { const { getSettings, __unstableGetTemporarilyEditingAsBlocks, getBlockEditingMode, + getBlockName, + isFirstMultiSelectedBlock, + getMultiSelectedBlockClientIds, + hasSelectedInnerBlock, } = select( blockEditorStore ); + const { hasBlockSupport } = select( blocksStore ); return { themeSupportsLayout: getSettings().supportsLayout, isTemporarilyEditingAsBlocks: __unstableGetTemporarilyEditingAsBlocks() === clientId, blockEditingMode: getBlockEditingMode( clientId ), + mayDisplayControls: + isSelected || + ( isFirstMultiSelectedBlock( clientId ) && + getMultiSelectedBlockClientIds().every( + ( id ) => getBlockName( id ) === name + ) ), + mayDisplayParentControls: + hasBlockSupport( + getBlockName( clientId ), + '__experimentalExposeControlsToChildren', + false + ) && hasSelectedInnerBlock( clientId ), }; }, - [ clientId ] + [ clientId, isSelected, name ] ); const { removeBlock } = useDispatch( blockEditorStore ); const onRemove = useCallback( () => removeBlock( clientId ), [ clientId ] ); @@ -137,6 +165,8 @@ function BlockListBlock( { __unstableParentLayout={ Object.keys( parentLayout ).length ? parentLayout : undefined } + mayDisplayControls={ mayDisplayControls } + mayDisplayParentControls={ mayDisplayParentControls } /> ); @@ -161,6 +191,10 @@ function BlockListBlock( { !! wrapperProps[ 'data-align' ] && ! themeSupportsLayout; + // Support for sticky position in classic themes with alignment wrappers. + + const isSticky = className?.includes( 'is-position-sticky' ); + // For aligned blocks, provide a wrapper element so the block can be // positioned relative to the block column. // This is only kept for classic themes that don't support layout @@ -172,7 +206,7 @@ function BlockListBlock( { if ( isAligned ) { blockEdit = (
{ blockEdit } @@ -221,7 +255,7 @@ function BlockListBlock( { isTemporarilyEditingAsBlocks, }, dataAlign && themeSupportsLayout && `align${ dataAlign }`, - className + ! ( dataAlign && isSticky ) && className ), wrapperProps: restWrapperProps, isAligned, diff --git a/packages/block-editor/src/components/block-list/block.native.js b/packages/block-editor/src/components/block-list/block.native.js index 03a84d530ba12..70a66c445f58f 100644 --- a/packages/block-editor/src/components/block-list/block.native.js +++ b/packages/block-editor/src/components/block-list/block.native.js @@ -148,6 +148,7 @@ function BlockListBlock( { isDescendentBlockSelected, isParentSelected, order, + mayDisplayControls, } = useSelect( ( select ) => { const { @@ -158,6 +159,9 @@ function BlockListBlock( { getSelectedBlockClientId, getSettings, hasSelectedInnerBlock, + getBlockName, + isFirstMultiSelectedBlock, + getMultiSelectedBlockClientIds, } = select( blockEditorStore ); const currentBlockType = getBlockType( name || 'core/missing' ); const currentBlockCategory = currentBlockType?.category; @@ -205,6 +209,12 @@ function BlockListBlock( { isDescendentBlockSelected: descendentBlockSelected, isParentSelected: parentSelected, order: blockOrder, + mayDisplayControls: + isSelected || + ( isFirstMultiSelectedBlock( clientId ) && + getMultiSelectedBlockClientIds().every( + ( id ) => getBlockName( id ) === name + ) ), }; }, [ clientId, isSelected, name, rootClientId ] @@ -243,10 +253,13 @@ function BlockListBlock( { ); // Block level styles. - const wrapperProps = getWrapperProps( - attributes, - blockType.getEditWrapperProps - ); + let wrapperProps = {}; + if ( blockType?.getEditWrapperProps ) { + wrapperProps = getWrapperProps( + attributes, + blockType.getEditWrapperProps + ); + } // Inherited styles merged with block level styles. const mergedStyle = useMemo( () => { @@ -346,6 +359,7 @@ function BlockListBlock( { : undefined } wrapperProps={ wrapperProps } + mayDisplayControls={ mayDisplayControls } /> diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index d5f4e7608255d..593beafa06d83 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -11,6 +11,8 @@ import { __, sprintf } from '@wordpress/i18n'; import { __unstableGetBlockProps as getBlockProps, getBlockType, + isReusableBlock, + getBlockDefaultClassName, store as blocksStore, } from '@wordpress/blocks'; import { useMergeRefs, useDisabled } from '@wordpress/compose'; @@ -25,17 +27,12 @@ import { BlockListBlockContext } from '../block-list-block-context'; import { useFocusFirstElement } from './use-focus-first-element'; import { useIsHovered } from './use-is-hovered'; import { useBlockEditContext } from '../../block-edit/context'; -import { useBlockClassNames } from './use-block-class-names'; -import { useBlockDefaultClassName } from './use-block-default-class-name'; -import { useBlockCustomClassName } from './use-block-custom-class-name'; -import { useBlockMovingModeClassNames } from './use-block-moving-mode-class-names'; import { useFocusHandler } from './use-focus-handler'; import { useEventHandlers } from './use-selected-block-event-handlers'; import { useNavModeExit } from './use-nav-mode-exit'; import { useBlockRefProvider } from './use-block-refs'; import { useIntersectionObserver } from './use-intersection-observer'; import { store as blockEditorStore } from '../../../store'; -import useBlockOverlayActive from '../../block-content-overlay'; import { unlock } from '../../../lock-unlock'; /** @@ -99,10 +96,15 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { name, blockApiVersion, blockTitle, + isSelected, isPartOfSelection, adjustScrolling, enableAnimation, isSubtreeDisabled, + isOutlineEnabled, + hasOverlay, + initialPosition, + classNames, } = useSelect( ( select ) => { const { @@ -117,9 +119,21 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { isAncestorMultiSelected, isFirstMultiSelectedBlock, isBlockSubtreeDisabled, + getSettings, + isBlockHighlighted, + __unstableIsFullySelected, + __unstableSelectionHasUnmergeableBlock, + isBlockBeingDragged, + hasSelectedInnerBlock, + hasBlockMovingClientId, + canInsertBlockType, + getBlockRootClientId, + __unstableHasActiveBlockOverlayActive, + __unstableGetEditorMode, + getSelectedBlocksInitialCaretPosition, } = unlock( select( blockEditorStore ) ); const { getActiveBlockVariation } = select( blocksStore ); - const isSelected = isBlockSelected( clientId ); + const _isSelected = isBlockSelected( clientId ); const isPartOfMultiSelection = isBlockMultiSelected( clientId ) || isAncestorMultiSelected( clientId ); @@ -127,6 +141,16 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { const blockType = getBlockType( blockName ); const attributes = getBlockAttributes( clientId ); const match = getActiveBlockVariation( blockName, attributes ); + const { outlineMode } = getSettings(); + const isMultiSelected = isBlockMultiSelected( clientId ); + const checkDeep = true; + const isAncestorOfSelectedBlock = hasSelectedInnerBlock( + clientId, + checkDeep + ); + const typing = isTyping(); + const hasLightBlockWrapper = blockType?.apiVersion > 1; + const movingClientId = hasBlockMovingClientId(); return { index: getBlockIndex( clientId ), @@ -134,31 +158,62 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { name: blockName, blockApiVersion: blockType?.apiVersion || 1, blockTitle: match?.title || blockType?.title, - isPartOfSelection: isSelected || isPartOfMultiSelection, + isSelected: _isSelected, + isPartOfSelection: _isSelected || isPartOfMultiSelection, adjustScrolling: - isSelected || isFirstMultiSelectedBlock( clientId ), + _isSelected || isFirstMultiSelectedBlock( clientId ), enableAnimation: - ! isTyping() && + ! typing && getGlobalBlockCount() <= BLOCK_ANIMATION_THRESHOLD, isSubtreeDisabled: isBlockSubtreeDisabled( clientId ), + isOutlineEnabled: outlineMode, + hasOverlay: __unstableHasActiveBlockOverlayActive( clientId ), + initialPosition: + _isSelected && __unstableGetEditorMode() === 'edit' + ? getSelectedBlocksInitialCaretPosition() + : undefined, + classNames: classnames( + { + 'is-selected': _isSelected, + 'is-highlighted': isBlockHighlighted( clientId ), + 'is-multi-selected': isMultiSelected, + 'is-partially-selected': + isMultiSelected && + ! __unstableIsFullySelected() && + ! __unstableSelectionHasUnmergeableBlock(), + 'is-reusable': isReusableBlock( blockType ), + 'is-dragging': isBlockBeingDragged( clientId ), + 'has-child-selected': isAncestorOfSelectedBlock, + 'remove-outline': _isSelected && outlineMode && typing, + 'is-block-moving-mode': !! movingClientId, + 'can-insert-moving-block': + movingClientId && + canInsertBlockType( + getBlockName( movingClientId ), + getBlockRootClientId( clientId ) + ), + }, + hasLightBlockWrapper ? attributes.className : undefined, + hasLightBlockWrapper + ? getBlockDefaultClassName( blockName ) + : undefined + ), }; }, [ clientId ] ); - const hasOverlay = useBlockOverlayActive( clientId ); - // translators: %s: Type of block (i.e. Text, Image etc) const blockLabel = sprintf( __( 'Block: %s' ), blockTitle ); const htmlSuffix = mode === 'html' && ! __unstableIsHtml ? '-visual' : ''; const mergedRefs = useMergeRefs( [ props.ref, - useFocusFirstElement( clientId ), + useFocusFirstElement( { clientId, initialPosition } ), useBlockRefProvider( clientId ), useFocusHandler( clientId ), - useEventHandlers( clientId ), + useEventHandlers( { clientId, isSelected } ), useNavModeExit( clientId ), - useIsHovered(), + useIsHovered( { isEnabled: isOutlineEnabled } ), useIntersectionObserver(), useMovingAnimation( { isSelected: isPartOfSelection, @@ -190,18 +245,16 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { 'data-title': blockTitle, inert: isSubtreeDisabled ? 'true' : undefined, className: classnames( - // The wp-block className is important for editor styles. - classnames( 'block-editor-block-list__block', { + 'block-editor-block-list__block', + { + // The wp-block className is important for editor styles. 'wp-block': ! isAligned, 'has-block-overlay': hasOverlay, - } ), + }, className, props.className, wrapperProps.className, - useBlockClassNames( clientId ), - useBlockDefaultClassName( clientId ), - useBlockCustomClassName( clientId ), - useBlockMovingModeClassNames( clientId ) + classNames ), style: { ...wrapperProps.style, ...props.style }, }; diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js deleted file mode 100644 index fce94b85f9119..0000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-class-names.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { isReusableBlock, getBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; - -/** - * Returns the class names used for the different states of the block. - * - * @param {string} clientId The block client ID. - * - * @return {string} The class names. - */ -export function useBlockClassNames( clientId ) { - return useSelect( - ( select ) => { - const { - isBlockBeingDragged, - isBlockHighlighted, - isBlockSelected, - isBlockMultiSelected, - getBlockName, - getSettings, - hasSelectedInnerBlock, - isTyping, - __unstableIsFullySelected, - __unstableSelectionHasUnmergeableBlock, - } = select( blockEditorStore ); - const { outlineMode } = getSettings(); - const isDragging = isBlockBeingDragged( clientId ); - const isSelected = isBlockSelected( clientId ); - const name = getBlockName( clientId ); - const checkDeep = true; - // "ancestor" is the more appropriate label due to "deep" check. - const isAncestorOfSelectedBlock = hasSelectedInnerBlock( - clientId, - checkDeep - ); - const isMultiSelected = isBlockMultiSelected( clientId ); - return classnames( { - 'is-selected': isSelected, - 'is-highlighted': isBlockHighlighted( clientId ), - 'is-multi-selected': isMultiSelected, - 'is-partially-selected': - isMultiSelected && - ! __unstableIsFullySelected() && - ! __unstableSelectionHasUnmergeableBlock(), - 'is-reusable': isReusableBlock( getBlockType( name ) ), - 'is-dragging': isDragging, - 'has-child-selected': isAncestorOfSelectedBlock, - 'remove-outline': isSelected && outlineMode && isTyping(), - } ); - }, - [ clientId ] - ); -} diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js deleted file mode 100644 index 50372c09a042c..0000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-custom-class-name.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { getBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; - -/** - * Returns the custom class name if the block is a light block. - * - * @param {string} clientId The block client ID. - * - * @return {string} The custom class name. - */ -export function useBlockCustomClassName( clientId ) { - // It's good for this to be a separate selector because it will be executed - // on every attribute change, while the other selectors are not re-evaluated - // as much. - return useSelect( - ( select ) => { - const { getBlockName, getBlockAttributes } = - select( blockEditorStore ); - const attributes = getBlockAttributes( clientId ); - - if ( ! attributes?.className ) { - return; - } - - const blockType = getBlockType( getBlockName( clientId ) ); - const hasLightBlockWrapper = blockType?.apiVersion > 1; - - if ( ! hasLightBlockWrapper ) { - return; - } - - return attributes.className; - }, - [ clientId ] - ); -} diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js b/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js deleted file mode 100644 index 7877ceb96490c..0000000000000 --- a/packages/block-editor/src/components/block-list/use-block-props/use-block-default-class-name.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { getBlockType, getBlockDefaultClassName } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; - -/** - * Returns the default class name if the block is a light block and it supports - * `className`. - * - * @param {string} clientId The block client ID. - * - * @return {string} The class name, e.g. `wp-block-paragraph`. - */ -export function useBlockDefaultClassName( clientId ) { - return useSelect( - ( select ) => { - const name = select( blockEditorStore ).getBlockName( clientId ); - const blockType = getBlockType( name ); - const hasLightBlockWrapper = blockType?.apiVersion > 1; - - if ( ! hasLightBlockWrapper ) { - return; - } - - return getBlockDefaultClassName( name ); - }, - [ clientId ] - ); -} diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js index 21515d74b2ecd..b3f844cea5429 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js @@ -18,38 +18,6 @@ import { store as blockEditorStore } from '../../../store'; /** @typedef {import('@wordpress/element').RefObject} RefObject */ -/** - * Returns the initial position if the block needs to be focussed, `undefined` - * otherwise. The initial position is either 0 (start) or -1 (end). - * - * @param {string} clientId Block client ID. - * - * @return {number} The initial position, either 0 (start) or -1 (end). - */ -function useInitialPosition( clientId ) { - return useSelect( - ( select ) => { - const { - getSelectedBlocksInitialCaretPosition, - __unstableGetEditorMode, - isBlockSelected, - } = select( blockEditorStore ); - - if ( ! isBlockSelected( clientId ) ) { - return; - } - - if ( __unstableGetEditorMode() !== 'edit' ) { - return; - } - - // If there's no initial position, return 0 to focus the start. - return getSelectedBlocksInitialCaretPosition(); - }, - [ clientId ] - ); -} - /** * Transitions focus to the block or inner tabbable when the block becomes * selected and an initial position is set. @@ -58,9 +26,8 @@ function useInitialPosition( clientId ) { * * @return {RefObject} React ref with the block element. */ -export function useFocusFirstElement( clientId ) { +export function useFocusFirstElement( { clientId, initialPosition } ) { const ref = useRef(); - const initialPosition = useInitialPosition( clientId ); const { isBlockSelected, isMultiSelecting } = useSelect( blockEditorStore ); useEffect( () => { diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-is-hovered.js b/packages/block-editor/src/components/block-list/use-block-props/use-is-hovered.js index 653ef3049b124..08c42eb1fdb08 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-is-hovered.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-is-hovered.js @@ -1,14 +1,8 @@ /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; import { useRefEffect } from '@wordpress/compose'; -/** - * Internal dependencies - */ -import { store as blockEditorStore } from '../../../store'; - function listener( event ) { if ( event.defaultPrevented ) { return; @@ -20,16 +14,11 @@ function listener( event ) { event.currentTarget.classList[ action ]( 'is-hovered' ); } -/** +/* * Adds `is-hovered` class when the block is hovered and in navigation or * outline mode. */ -export function useIsHovered() { - const isEnabled = useSelect( ( select ) => { - const { getSettings } = select( blockEditorStore ); - return getSettings().outlineMode; - }, [] ); - +export function useIsHovered( { isEnabled } ) { return useRefEffect( ( node ) => { if ( isEnabled ) { diff --git a/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js index bd6566112263f..bf4fc55879448 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js +++ b/packages/block-editor/src/components/block-list/use-block-props/use-selected-block-event-handlers.js @@ -19,11 +19,7 @@ import { store as blockEditorStore } from '../../../store'; * * @param {string} clientId Block client ID. */ -export function useEventHandlers( clientId ) { - const isSelected = useSelect( - ( select ) => select( blockEditorStore ).isBlockSelected( clientId ), - [ clientId ] - ); +export function useEventHandlers( { clientId, isSelected } ) { const { getBlockRootClientId, getBlockIndex } = useSelect( blockEditorStore ); const { insertDefaultBlock, removeBlock } = useDispatch( blockEditorStore ); diff --git a/packages/block-editor/src/components/block-parent-selector/style.scss b/packages/block-editor/src/components/block-parent-selector/style.scss deleted file mode 100644 index c5a1869835188..0000000000000 --- a/packages/block-editor/src/components/block-parent-selector/style.scss +++ /dev/null @@ -1,11 +0,0 @@ -.block-editor-block-parent-selector { - background: $white; - border-radius: $radius-block-ui; - - .block-editor-block-parent-selector__button { - width: $grid-unit-60; - height: $grid-unit-60; - border: $border-width solid $gray-900; - border-radius: $radius-block-ui; - } -} diff --git a/packages/block-editor/src/components/block-patterns-list/index.js b/packages/block-editor/src/components/block-patterns-list/index.js index a392d2687017f..c79d6927c00f0 100644 --- a/packages/block-editor/src/components/block-patterns-list/index.js +++ b/packages/block-editor/src/components/block-patterns-list/index.js @@ -56,7 +56,7 @@ function BlockPattern( { { ( { draggable, onDragStart, onDragEnd } ) => (
- { blockNamesForPrompt.length === 1 ? ( -

{ rules[ blockNamesForPrompt[ 0 ] ] }

- ) : ( -
    - { blockNamesForPrompt.map( ( name ) => ( -
  • { rules[ name ] }
  • - ) ) } -
- ) }

- { blockNamesForPrompt.length > 1 - ? __( 'Removing these blocks is not advised.' ) - : __( 'Removing this block is not advised.' ) } + { _n( + 'Post or page content will not be displayed if you delete this block.', + 'Post or page content will not be displayed if you delete these blocks.', + blockNamesForPrompt.length + ) }

diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss index e18fa7564c51b..755445246e859 100644 --- a/packages/block-editor/src/components/inserter/style.scss +++ b/packages/block-editor/src/components/inserter/style.scss @@ -214,15 +214,14 @@ $block-inserter-tabs-height: 44px; } } +.block-editor-inserter__preview-container__popover { + top: $grid-unit-20 !important; +} + .block-editor-inserter__preview-container { display: none; - width: 300px; - background: $white; - border-radius: $radius-block-ui; - border: $border-width solid $gray-300; - position: absolute; - top: $grid-unit-20; - left: calc(100% + #{$grid-unit-20}); + width: $sidebar-width; + padding: $grid-unit-20; max-height: calc(100% - #{$grid-unit-40}); overflow-y: hidden; @@ -230,12 +229,14 @@ $block-inserter-tabs-height: 44px; display: block; } - .block-editor-block-card { - padding: $grid-unit-20; + .block-editor-block-preview__container { + height: 100%; } - .block-editor-block-card__title { - font-size: $default-font-size; + .block-editor-block-card { + padding-left: 0; + padding-right: 0; + padding-bottom: $grid-unit-05; } } @@ -271,15 +272,11 @@ $block-inserter-tabs-height: 44px; } } -.block-editor-inserter__block-patterns-tabs-container, -.block-editor-block-patterns-explorer__sidebar { +.block-editor-inserter__block-patterns-tabs-container { height: 100%; nav { height: 100%; } - .block-editor-block-patterns__source-filter select.components-select-control__input { - height: 40px; - } } .block-editor-inserter__block-patterns-tabs { @@ -366,6 +363,7 @@ $block-inserter-tabs-height: 44px; min-height: $grid-unit-60 * 3; color: $gray-700; background: $gray-100; + border-radius: $radius-block-ui; } .block-editor-inserter__tips { @@ -447,7 +445,7 @@ $block-inserter-tabs-height: 44px; .block-editor-block-patterns-explorer { &__sidebar { position: absolute; - top: $header-height + $grid-unit-20; + top: $header-height + $grid-unit-15; left: 0; bottom: 0; width: $sidebar-width; diff --git a/packages/block-editor/src/components/inspector-controls/fill.js b/packages/block-editor/src/components/inspector-controls/fill.js index f0640a9d31ddc..456b33af9137f 100644 --- a/packages/block-editor/src/components/inspector-controls/fill.js +++ b/packages/block-editor/src/components/inspector-controls/fill.js @@ -12,7 +12,10 @@ import { useEffect, useContext } from '@wordpress/element'; /** * Internal dependencies */ -import useDisplayBlockControls from '../use-display-block-controls'; +import { + useBlockEditContext, + mayDisplayControlsKey, +} from '../block-edit/context'; import groups from './groups'; export default function InspectorControlsFill( { @@ -33,13 +36,13 @@ export default function InspectorControlsFill( { group = __experimentalGroup; } - const isDisplayed = useDisplayBlockControls(); + const context = useBlockEditContext(); const Fill = groups[ group ]?.Fill; if ( ! Fill ) { warning( `Unknown InspectorControls group "${ group }" provided.` ); return null; } - if ( ! isDisplayed ) { + if ( ! context[ mayDisplayControlsKey ] ) { return null; } diff --git a/packages/block-editor/src/components/inspector-controls/fill.native.js b/packages/block-editor/src/components/inspector-controls/fill.native.js index d38d865cd15cc..98b6698721e1c 100644 --- a/packages/block-editor/src/components/inspector-controls/fill.native.js +++ b/packages/block-editor/src/components/inspector-controls/fill.native.js @@ -15,7 +15,10 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import groups from './groups'; -import useDisplayBlockControls from '../use-display-block-controls'; +import { + useBlockEditContext, + mayDisplayControlsKey, +} from '../block-edit/context'; import { BlockSettingsButton } from '../block-settings'; export default function InspectorControlsFill( { @@ -35,14 +38,14 @@ export default function InspectorControlsFill( { ); group = __experimentalGroup; } - const isDisplayed = useDisplayBlockControls(); + const context = useBlockEditContext(); const Fill = groups[ group ]?.Fill; if ( ! Fill ) { warning( `Unknown InspectorControls group "${ group }" provided.` ); return null; } - if ( ! isDisplayed ) { + if ( ! context[ mayDisplayControlsKey ] ) { return null; } diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 7b6bbff0700a3..4821359cab8fc 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -74,7 +74,7 @@ $preview-image-height: 140px; border-radius: $radius-block-ui; height: $button-size-next-default-40px; // components do not properly support unstable-large yet. margin: 0; - padding: $grid-unit-10 $grid-unit-20; + padding: $grid-unit-10 $button-size-next-default-40px $grid-unit-10 $grid-unit-20; position: relative; width: 100%; } diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index e0366a3f27ef5..32db57a55d76e 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -2140,7 +2140,7 @@ describe( 'Post types', () => { describe( 'Rich link previews', () => { const selectedLink = { id: '1', - title: 'Wordpress.org', // Customize this for differentiation in assertions. + title: 'WordPress.org', // Customize this for differentiation in assertions. url: 'https://www.wordpress.org', type: 'URL', }; diff --git a/packages/block-editor/src/components/navigable-toolbar/README.md b/packages/block-editor/src/components/navigable-toolbar/README.md index 30a4d100195f8..317be48f38faa 100644 --- a/packages/block-editor/src/components/navigable-toolbar/README.md +++ b/packages/block-editor/src/components/navigable-toolbar/README.md @@ -8,6 +8,8 @@ The component accepts the following props. Props not included in this set will b ## `focusOnMount` +_Note: this prop is deprecated._ + Whether to immediately focus when the component mounts. - Type: `Boolean` diff --git a/packages/block-editor/src/components/navigable-toolbar/index.js b/packages/block-editor/src/components/navigable-toolbar/index.js index fe216e1058f6f..8954f7e17f132 100644 --- a/packages/block-editor/src/components/navigable-toolbar/index.js +++ b/packages/block-editor/src/components/navigable-toolbar/index.js @@ -162,7 +162,7 @@ function useToolbarFocus( { const index = items.findIndex( ( item ) => item.tabIndex === 0 ); onIndexChange( index ); }; - }, [ initialIndex, initialFocusOnMount, toolbarRef ] ); + }, [ initialIndex, initialFocusOnMount, onIndexChange, toolbarRef ] ); const { lastFocus } = useSelect( ( select ) => { const { getLastFocus } = select( blockEditorStore ); @@ -210,9 +210,9 @@ export default function NavigableToolbar( { useToolbarFocus( { toolbarRef, focusOnMount, - isAccessibleToolbar, defaultIndex: initialIndex, onIndexChange, + isAccessibleToolbar, shouldUseKeyboardFocusShortcut, focusEditorOnEscape, } ); diff --git a/packages/block-editor/src/components/preview-options/README.md b/packages/block-editor/src/components/preview-options/README.md deleted file mode 100644 index baf886c71bd65..0000000000000 --- a/packages/block-editor/src/components/preview-options/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# Preview Options - -The `PreviewOptions` component displays the list of different preview options available in the editor. - -It returns a [`DropdownMenu`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/components/src/dropdown-menu) component with these different options. The options currently available in the editor are Desktop, Mobile, Tablet and "Preview in new tab". - -![Preview options dropdown menu](https://make.wordpress.org/core/files/2020/09/preview-options-dropdown-menu.png) - -## Table of contents - -1. [Development guidelines](#development-guidelines) -2. [Related components](#related-components) - -## Development guidelines - -### Usage - -Renders the previews options of the editor in a dropdown menu. - -```jsx -import { Icon, MenuGroup } from '@wordpress/components'; -import { PostPreviewButton } from '@wordpress/editor'; -import { __experimentalPreviewOptions as PreviewOptions } from '@wordpress/block-editor'; - -const MyPreviewOptions = () => ( - { ( { onClose } ) => ( - -
- - { __( 'Preview in new tab' ) } - - - } - onPreview={ onClose } - /> -
-
- ) } -
-); -``` - -### Props - -#### className - -The CSS classes added to the component. - -- Type: `String` -- Required: no - -#### isEnabled - -Wheter or not the preview options are enabled for the current post. -And example of when the preview options are not enabled is when the current post is not savable. - -- Type: `boolean` -- Required: no -- Default: true - -#### deviceType - -The device type in the preview options. It can be either Desktop or Tablet or Mobile among others. - -- Type: `String` -- Required: yes - -#### setDeviceType - -Used to set the device type that will be used to display the preview inside the editor. - -- Type: `func` -- Required: yes - -#### children - -A function that returns nodes to be rendered within the dropdown. - -- Type: `Function` -- Required: No - -## Related components - -Block Editor components are components that can be used to compose the UI of your block editor. Thus, they can only be used under a [`BlockEditorProvider`](https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/provider/README.md) in the components tree. diff --git a/packages/block-editor/src/components/preview-options/index.js b/packages/block-editor/src/components/preview-options/index.js index 8ffdd4327de27..8f540c35f6455 100644 --- a/packages/block-editor/src/components/preview-options/index.js +++ b/packages/block-editor/src/components/preview-options/index.js @@ -1,91 +1,11 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ -import { useViewportMatch } from '@wordpress/compose'; -import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { check, desktop, mobile, tablet } from '@wordpress/icons'; - -export default function PreviewOptions( { - children, - viewLabel, - className, - isEnabled = true, - deviceType, - setDeviceType, - label, - showIconLabels, -} ) { - const isMobile = useViewportMatch( 'small', '<' ); - if ( isMobile ) return null; - - const popoverProps = { - className: classnames( - className, - 'block-editor-post-preview__dropdown-content' - ), - placement: 'bottom-end', - }; - const toggleProps = { - className: 'block-editor-post-preview__button-toggle', - disabled: ! isEnabled, - __experimentalIsFocusable: ! isEnabled, - children: viewLabel, - showTooltip: ! showIconLabels, - }; - const menuProps = { - 'aria-label': __( 'View options' ), - }; - - const deviceIcons = { - mobile, - tablet, - desktop, - }; +import deprecated from '@wordpress/deprecated'; - return ( - - { ( renderProps ) => ( - <> - - setDeviceType( 'Desktop' ) } - icon={ deviceType === 'Desktop' && check } - > - { __( 'Desktop' ) } - - setDeviceType( 'Tablet' ) } - icon={ deviceType === 'Tablet' && check } - > - { __( 'Tablet' ) } - - setDeviceType( 'Mobile' ) } - icon={ deviceType === 'Mobile' && check } - > - { __( 'Mobile' ) } - - - { children?.( renderProps ) } - - ) } - - ); +export default function PreviewOptions() { + deprecated( 'wp.blockEditor.PreviewOptions', { + version: '6.5', + } ); + return null; } diff --git a/packages/block-editor/src/components/preview-options/style.scss b/packages/block-editor/src/components/preview-options/style.scss deleted file mode 100644 index fb79926ba1dee..0000000000000 --- a/packages/block-editor/src/components/preview-options/style.scss +++ /dev/null @@ -1,64 +0,0 @@ -.block-editor-post-preview__dropdown { - padding: 0; -} - -.block-editor-post-preview__button-resize.block-editor-post-preview__button-resize { - padding-left: $button-size-small + $grid-unit-10 + $grid-unit-10; - - &.has-icon { - padding-left: $grid-unit-10; - } -} - -.block-editor-post-preview__dropdown-content { - &.edit-post-post-preview-dropdown { - .components-menu-group { - &:first-child { - padding-bottom: $grid-unit-10; - } - &:last-child { - margin-bottom: 0; - } - } - } - - .components-menu-group + .components-menu-group { - padding: $grid-unit-10; - } -} - -.edit-post-header__settings, -.edit-site-header-edit-mode__actions { - @include break-small () { - .editor-post-preview { - display: none; - } - } -} - -// Reduced UI. -.edit-post-header.has-reduced-ui { - @include break-small() { - // Apply transition to first two buttons. - .edit-post-header__settings .editor-post-save-draft, - .edit-post-header__settings .editor-post-saved-state, - .edit-post-header__settings .block-editor-post-preview__button-toggle { - transition: opacity 0.1s linear; - @include reduce-motion("transition"); - } - - // Zero out opacity unless hovered. - &:not(:hover) { - .edit-post-header__settings .editor-post-save-draft, - .edit-post-header__settings .editor-post-saved-state, - .edit-post-header__settings .block-editor-post-preview__button-toggle { - opacity: 0; - } - - // ... or opened. - .edit-post-header__settings .block-editor-post-preview__button-toggle.is-opened { - opacity: 1; - } - } - } -} diff --git a/packages/block-editor/src/components/rich-text/content.js b/packages/block-editor/src/components/rich-text/content.js index 9762582f86f14..92e150fb174ed 100644 --- a/packages/block-editor/src/components/rich-text/content.js +++ b/packages/block-editor/src/components/rich-text/content.js @@ -5,36 +5,43 @@ import { RawHTML } from '@wordpress/element'; import { children as childrenSource } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; +/** + * Internal dependencies + */ +import RichText from './'; + /** * Internal dependencies */ import { getMultilineTag } from './utils'; -export const Content = ( { value, tagName: Tag, multiline, ...props } ) => { - // Handle deprecated `children` and `node` sources. - if ( Array.isArray( value ) ) { +export function Content( { + value, + tagName: Tag, + multiline, + format, + ...props +} ) { + if ( RichText.isEmpty( value ) ) { + const MultilineTag = getMultilineTag( multiline ); + value = MultilineTag ? : null; + } else if ( Array.isArray( value ) ) { deprecated( 'wp.blockEditor.RichText value prop as children type', { since: '6.1', version: '6.3', alternative: 'value prop as string', link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', } ); - - value = childrenSource.toHTML( value ); - } - - const MultilineTag = getMultilineTag( multiline ); - - if ( ! value && MultilineTag ) { - value = `<${ MultilineTag }>`; - } - - const content = { value }; - - if ( Tag ) { - const { format, ...restProps } = props; - return { content }; + value = { childrenSource.toHTML( value ) }; + } else if ( typeof value === 'string' ) { + // To do: deprecate. + value = { value }; + } else { + // To do: create a toReactComponent method on RichTextData, which we + // might in the future also use for the editable tree. See + // https://github.com/WordPress/gutenberg/pull/41655. + value = { value.toHTMLString() }; } - return content; -}; + return Tag ? { value } : value; +} diff --git a/packages/block-editor/src/components/rich-text/get-rich-text-values.js b/packages/block-editor/src/components/rich-text/get-rich-text-values.js index bd1c62ea5e6f6..ee2bc63826930 100644 --- a/packages/block-editor/src/components/rich-text/get-rich-text-values.js +++ b/packages/block-editor/src/components/rich-text/get-rich-text-values.js @@ -6,6 +6,7 @@ import { getSaveElement, __unstableGetBlockProps as getBlockProps, } from '@wordpress/blocks'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies @@ -95,5 +96,9 @@ export function getRichTextValues( blocks = [] ) { const values = []; addValuesForBlocks( values, blocks ); getBlockProps.skipFilters = false; - return values; + return values.map( ( value ) => + value instanceof RichTextData + ? value + : RichTextData.fromHTMLString( value ) + ); } diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 1a6793ca9efe7..a3b7b44e214a5 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -13,14 +13,11 @@ import { createContext, } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; -import { children as childrenSource } from '@wordpress/blocks'; -import { useInstanceId, useMergeRefs } from '@wordpress/compose'; +import { useMergeRefs } from '@wordpress/compose'; import { __unstableUseRichText as useRichText, - __unstableCreateElement, removeFormat, } from '@wordpress/rich-text'; -import deprecated from '@wordpress/deprecated'; import { Popover } from '@wordpress/components'; /** @@ -46,7 +43,7 @@ import { useFirefoxCompat } from './use-firefox-compat'; import FormatEdit from './format-edit'; import { getAllowedFormats } from './utils'; import { Content } from './content'; -import RichTextMultiline from './multiline'; +import { withDeprecations } from './with-deprecations'; export const keyboardShortcutContext = createContext(); export const inputEventContext = createContext(); @@ -387,47 +384,9 @@ export function RichTextWrapper( ); } -const ForwardedRichTextWrapper = forwardRef( RichTextWrapper ); - -function RichTextSwitcher( props, ref ) { - let value = props.value; - let onChange = props.onChange; - - // Handle deprecated format. - if ( Array.isArray( value ) ) { - deprecated( 'wp.blockEditor.RichText value prop as children type', { - since: '6.1', - version: '6.3', - alternative: 'value prop as string', - link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', - } ); - - value = childrenSource.toHTML( props.value ); - onChange = ( newValue ) => - props.onChange( - childrenSource.fromDOM( - __unstableCreateElement( document, newValue ).childNodes - ) - ); - } - - const Component = props.multiline - ? RichTextMultiline - : ForwardedRichTextWrapper; - const instanceId = useInstanceId( RichTextSwitcher ); - - return ( - - ); -} - -const ForwardedRichTextContainer = forwardRef( RichTextSwitcher ); +const ForwardedRichTextContainer = withDeprecations( + forwardRef( RichTextWrapper ) +); ForwardedRichTextContainer.Content = Content; ForwardedRichTextContainer.isEmpty = ( value ) => { diff --git a/packages/block-editor/src/components/rich-text/index.native.js b/packages/block-editor/src/components/rich-text/index.native.js index 9427962eced19..acadfb24a7221 100644 --- a/packages/block-editor/src/components/rich-text/index.native.js +++ b/packages/block-editor/src/components/rich-text/index.native.js @@ -39,17 +39,17 @@ import FormatToolbarContainer from './format-toolbar-container'; import { store as blockEditorStore } from '../../store'; import { addActiveFormats, - getMultilineTag, getAllowedFormats, createLinkInParagraph, } from './utils'; import EmbedHandlerPicker from './embed-handler-picker'; import { Content } from './content'; import RichText from './native'; +import { withDeprecations } from './with-deprecations'; const classes = 'block-editor-rich-text__editable'; -function RichTextWrapper( +export function RichTextWrapper( { children, tagName, @@ -58,7 +58,6 @@ function RichTextWrapper( value: originalValue, onChange: originalOnChange, isSelected: originalIsSelected, - multiline, inlineToolbar, wrapperClassName, autocompleters, @@ -80,7 +79,6 @@ function RichTextWrapper( disableLineBreaks, unstableOnFocus, __unstableAllowPrefixTransformations, - __unstableMultilineRootTag, // Native props. __unstableMobileNoFocusOnMount, deleteEnter, @@ -179,7 +177,6 @@ function RichTextWrapper( selectionChange, __unstableMarkAutomaticChange, } = useDispatch( blockEditorStore ); - const multilineTag = getMultilineTag( multiline ); const adjustedAllowedFormats = getAllowedFormats( { allowedFormats, disableFormats, @@ -261,10 +258,7 @@ function RichTextWrapper( if ( ! hasPastedBlocks || ! isEmpty( before ) ) { blocks.push( onSplit( - toHTMLString( { - value: before, - multilineTag, - } ), + toHTMLString( { value: before } ), ! isAfterOriginal ) ); @@ -288,13 +282,7 @@ function RichTextWrapper( : ! onSplitMiddle || ! isEmpty( after ) ) { blocks.push( - onSplit( - toHTMLString( { - value: after, - multilineTag, - } ), - isAfterOriginal - ) + onSplit( toHTMLString( { value: after } ), isAfterOriginal ) ); } @@ -308,7 +296,7 @@ function RichTextWrapper( onReplace( blocks, indexToSelect, initialPosition ); }, - [ onReplace, onSplit, multilineTag, onSplitMiddle ] + [ onReplace, onSplit, onSplitMiddle ] ); const onEnter = useCallback( @@ -370,7 +358,6 @@ function RichTextWrapper( onReplace, onSplit, __unstableMarkAutomaticChange, - multiline, splitValue, onSplitAtEnd, ] @@ -392,9 +379,6 @@ function RichTextWrapper( if ( isInternal ) { const pastedValue = create( { html, - multilineTag, - multilineWrapperTags: - multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, preserveWhiteSpace, } ); addActiveFormats( pastedValue, activeFormats ); @@ -496,7 +480,6 @@ function RichTextWrapper( onSplit, splitValue, __unstableEmbedURLOnPaste, - multilineTag, preserveWhiteSpace, pastePlainText, ] @@ -568,7 +551,6 @@ function RichTextWrapper( onPaste={ onPaste } __unstableIsSelected={ isSelected } __unstableInputRule={ inputRule } - __unstableMultilineTag={ multilineTag } __unstableOnEnterFormattedText={ enterFormattedText } __unstableOnExitFormattedText={ exitFormattedText } __unstableOnCreateUndoLevel={ __unstableMarkLastChangeAsPersistent } @@ -582,7 +564,6 @@ function RichTextWrapper( __unstableAllowPrefixTransformations={ __unstableAllowPrefixTransformations } - __unstableMultilineRootTag={ __unstableMultilineRootTag } // Native props. blockIsSelected={ originalIsSelected !== undefined @@ -675,7 +656,9 @@ function RichTextWrapper( ); } -const ForwardedRichTextContainer = forwardRef( RichTextWrapper ); +const ForwardedRichTextContainer = withDeprecations( + forwardRef( RichTextWrapper ) +); ForwardedRichTextContainer.Content = Content; diff --git a/packages/block-editor/src/components/rich-text/native/get-format-colors.native.js b/packages/block-editor/src/components/rich-text/native/get-format-colors.native.js index a54d3e10f78a0..b68186246523f 100644 --- a/packages/block-editor/src/components/rich-text/native/get-format-colors.native.js +++ b/packages/block-editor/src/components/rich-text/native/get-format-colors.native.js @@ -5,50 +5,43 @@ import { getColorObjectByAttributeValues } from '../../../components/colors'; const FORMAT_TYPE = 'core/text-color'; const REGEX_TO_MATCH = /^has-(.*)-color$/; -const TAGS_TO_SEARCH = /\ { - format.forEach( ( currentFormat ) => { - if ( currentFormat?.type === FORMAT_TYPE ) { - const className = currentFormat?.attributes?.class; - currentFormat.attributes.style = - currentFormat.attributes.style.replace( / /g, '' ); + // We are looping through a sparse array where empty indices will be + // skipped. + newFormats.forEach( ( format ) => { + format.forEach( ( currentFormat ) => { + if ( currentFormat?.type === FORMAT_TYPE ) { + const className = currentFormat?.attributes?.class; - className?.split( ' ' ).forEach( ( currentClass ) => { - const match = currentClass.match( REGEX_TO_MATCH ); - if ( match ) { - const [ , colorSlug ] = - currentClass.match( REGEX_TO_MATCH ); - const colorObject = getColorObjectByAttributeValues( - colors, - colorSlug - ); - const currentStyles = - currentFormat?.attributes?.style; - if ( - colorObject && - ( ! currentStyles || - currentStyles?.indexOf( - colorObject.color - ) === -1 ) - ) { - currentFormat.attributes.style = [ - `color: ${ colorObject.color }`, - currentStyles, - ].join( ';' ); - } + className?.split( ' ' ).forEach( ( currentClass ) => { + const match = currentClass.match( REGEX_TO_MATCH ); + if ( match ) { + const [ , colorSlug ] = + currentClass.match( REGEX_TO_MATCH ); + const colorObject = getColorObjectByAttributeValues( + colors, + colorSlug + ); + const currentStyles = currentFormat?.attributes?.style; + if ( + colorObject && + ( ! currentStyles || + currentStyles?.indexOf( colorObject.color ) === + -1 ) + ) { + currentFormat.attributes.style = [ + `color: ${ colorObject.color }`, + currentStyles, + ].join( ';' ); } - } ); - } - } ); + } + } ); + } } ); + } ); - return newFormats; - } - - return formats; + return newFormats; } diff --git a/packages/block-editor/src/components/rich-text/native/index.native.js b/packages/block-editor/src/components/rich-text/native/index.native.js index ab465b2441154..165316fdbde76 100644 --- a/packages/block-editor/src/components/rich-text/native/index.native.js +++ b/packages/block-editor/src/components/rich-text/native/index.native.js @@ -105,27 +105,11 @@ const DEFAULT_FONT_SIZE = 16; const MIN_LINE_HEIGHT = 1; export class RichText extends Component { - constructor( { - value, - selectionStart, - selectionEnd, - __unstableMultilineTag: multiline, - } ) { + constructor( { value, selectionStart, selectionEnd } ) { super( ...arguments ); - this.isMultiline = false; - if ( multiline === true || multiline === 'p' || multiline === 'li' ) { - this.multilineTag = multiline === true ? 'p' : multiline; - this.isMultiline = true; - } - - if ( this.multilineTag === 'li' ) { - this.multilineWrapperTags = [ 'ul', 'ol' ]; - } - this.isIOS = Platform.OS === 'ios'; this.createRecord = this.createRecord.bind( this ); - this.restoreParagraphTags = this.restoreParagraphTags.bind( this ); this.onChangeFromAztec = this.onChangeFromAztec.bind( this ); this.onKeyDown = this.onKeyDown.bind( this ); this.handleEnter = this.handleEnter.bind( this ); @@ -196,7 +180,7 @@ export class RichText extends Component { const { formats, replacements, text } = currentValue; const { activeFormats } = this.state; - const newFormats = getFormatColors( value, formats, colorPalette ); + const newFormats = getFormatColors( formats, colorPalette ); return { formats: newFormats, @@ -223,8 +207,6 @@ export class RichText extends Component { ...create( { html: this.value, range: null, - multilineTag: this.multilineTag, - multilineWrapperTags: this.multilineWrapperTags, preserveWhiteSpace, } ), }; @@ -235,12 +217,7 @@ export class RichText extends Component { valueToFormat( value ) { // Remove the outer root tags. - return this.removeRootTagsProducedByAztec( - toHTMLString( { - value, - multilineTag: this.multilineTag, - } ) - ); + return this.removeRootTagsProducedByAztec( toHTMLString( { value } ) ); } getActiveFormatNames( record ) { @@ -357,29 +334,15 @@ export class RichText extends Component { const contentWithoutRootTag = this.removeRootTagsProducedByAztec( unescapeSpaces( event.nativeEvent.text ) ); - let formattedContent = contentWithoutRootTag; - if ( ! this.isIOS ) { - formattedContent = this.restoreParagraphTags( - contentWithoutRootTag, - this.multilineTag - ); - } this.debounceCreateUndoLevel(); - const refresh = this.value !== formattedContent; - this.value = formattedContent; + const refresh = this.value !== contentWithoutRootTag; + this.value = contentWithoutRootTag; // We don't want to refresh if our goal is just to create a record. if ( refresh ) { - this.props.onChange( formattedContent ); - } - } - - restoreParagraphTags( value, tag ) { - if ( tag === 'p' && ( ! value || ! value.startsWith( '

' ) ) ) { - return '

' + value + '

'; + this.props.onChange( contentWithoutRootTag ); } - return value; } /* @@ -739,8 +702,6 @@ export class RichText extends Component { if ( Array.isArray( value ) ) { return create( { html: childrenBlock.toHTML( value ), - multilineTag: this.multilineTag, - multilineWrapperTags: this.multilineWrapperTags, preserveWhiteSpace, } ); } @@ -748,8 +709,6 @@ export class RichText extends Component { if ( this.props.format === 'string' ) { return create( { html: value, - multilineTag: this.multilineTag, - multilineWrapperTags: this.multilineWrapperTags, preserveWhiteSpace, } ); } @@ -1323,7 +1282,7 @@ export class RichText extends Component { fontWeight={ this.props.fontWeight } fontStyle={ this.props.fontStyle } disableEditingMenu={ disableEditingMenu } - isMultiline={ this.isMultiline } + isMultiline={ false } textAlign={ this.props.textAlign } { ...( this.isIOS ? { maxWidth } : {} ) } minWidth={ minWidth } diff --git a/packages/block-editor/src/components/rich-text/use-input-rules.js b/packages/block-editor/src/components/rich-text/use-input-rules.js index 5aa47e7c7b4d7..5640a85f5f269 100644 --- a/packages/block-editor/src/components/rich-text/use-input-rules.js +++ b/packages/block-editor/src/components/rich-text/use-input-rules.js @@ -28,7 +28,12 @@ function findSelection( blocks ) { if ( attributeKey ) { blocks[ i ].attributes[ attributeKey ] = blocks[ i ].attributes[ attributeKey - ].replace( START_OF_SELECTED_AREA, '' ); + ] + // To do: refactor this to use rich text's selection instead, so + // we no longer have to use on this hack inserting a special + // character. + .toString() + .replace( START_OF_SELECTED_AREA, '' ); return [ blocks[ i ].clientId, attributeKey, 0, 0 ]; } diff --git a/packages/block-editor/src/components/rich-text/with-deprecations.js b/packages/block-editor/src/components/rich-text/with-deprecations.js new file mode 100644 index 0000000000000..8feab2206900a --- /dev/null +++ b/packages/block-editor/src/components/rich-text/with-deprecations.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { forwardRef } from '@wordpress/element'; +import { children as childrenSource } from '@wordpress/blocks'; +import { useInstanceId } from '@wordpress/compose'; +import { __unstableCreateElement } from '@wordpress/rich-text'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import RichTextMultiline from './multiline'; + +export function withDeprecations( Component ) { + return forwardRef( ( props, ref ) => { + let value = props.value; + let onChange = props.onChange; + + // Handle deprecated format. + if ( Array.isArray( value ) ) { + deprecated( 'wp.blockEditor.RichText value prop as children type', { + since: '6.1', + version: '6.3', + alternative: 'value prop as string', + link: 'https://developer.wordpress.org/block-editor/how-to-guides/block-tutorial/introducing-attributes-and-editable-fields/', + } ); + + value = childrenSource.toHTML( props.value ); + onChange = ( newValue ) => + props.onChange( + childrenSource.fromDOM( + __unstableCreateElement( document, newValue ).childNodes + ) + ); + } + + const NewComponent = props.multiline ? RichTextMultiline : Component; + const instanceId = useInstanceId( NewComponent ); + + return ( + + ); + } ); +} diff --git a/packages/block-editor/src/components/use-block-drop-zone/index.js b/packages/block-editor/src/components/use-block-drop-zone/index.js index 25dc6ee408982..cb3c3ae6a28a3 100644 --- a/packages/block-editor/src/components/use-block-drop-zone/index.js +++ b/packages/block-editor/src/components/use-block-drop-zone/index.js @@ -20,6 +20,10 @@ import { } from '../../utils/math'; import { store as blockEditorStore } from '../../store'; +const THRESHOLD_DISTANCE = 30; +const MINIMUM_HEIGHT_FOR_THRESHOLD = 120; +const MINIMUM_WIDTH_FOR_THRESHOLD = 120; + /** @typedef {import('../../utils/math').WPPoint} WPPoint */ /** @typedef {import('../use-on-block-drop/types').WPDropOperation} WPDropOperation */ @@ -48,24 +52,86 @@ import { store as blockEditorStore } from '../../store'; * @param {WPBlockData[]} blocksData The block data list. * @param {WPPoint} position The position of the item being dragged. * @param {WPBlockListOrientation} orientation The orientation of the block list. + * @param {Object} options Additional options. * @return {[number, WPDropOperation]} The drop target position. */ export function getDropTargetPosition( blocksData, position, - orientation = 'vertical' + orientation = 'vertical', + options = {} ) { const allowedEdges = orientation === 'horizontal' ? [ 'left', 'right' ] : [ 'top', 'bottom' ]; - const isRightToLeft = isRTL(); - let nearestIndex = 0; let insertPosition = 'before'; let minDistance = Infinity; + const { + dropZoneElement, + parentBlockOrientation, + rootBlockIndex = 0, + } = options; + + // Allow before/after when dragging over the top/bottom edges of the drop zone. + if ( dropZoneElement && parentBlockOrientation !== 'horizontal' ) { + const rect = dropZoneElement.getBoundingClientRect(); + const [ distance, edge ] = getDistanceToNearestEdge( position, rect, [ + 'top', + 'bottom', + ] ); + + // If dragging over the top or bottom of the drop zone, insert the block + // before or after the parent block. This only applies to blocks that use + // a drop zone element, typically container blocks such as Group or Cover. + if ( + rect.height > MINIMUM_HEIGHT_FOR_THRESHOLD && + distance < THRESHOLD_DISTANCE + ) { + if ( edge === 'top' ) { + return [ rootBlockIndex, 'before' ]; + } + if ( edge === 'bottom' ) { + return [ rootBlockIndex + 1, 'after' ]; + } + } + } + + const isRightToLeft = isRTL(); + + // Allow before/after when dragging over the left/right edges of the drop zone. + if ( dropZoneElement && parentBlockOrientation === 'horizontal' ) { + const rect = dropZoneElement.getBoundingClientRect(); + const [ distance, edge ] = getDistanceToNearestEdge( position, rect, [ + 'left', + 'right', + ] ); + + // If dragging over the left or right of the drop zone, insert the block + // before or after the parent block. This only applies to blocks that use + // a drop zone element, typically container blocks such as Group. + if ( + rect.width > MINIMUM_WIDTH_FOR_THRESHOLD && + distance < THRESHOLD_DISTANCE + ) { + if ( + ( isRightToLeft && edge === 'right' ) || + ( ! isRightToLeft && edge === 'left' ) + ) { + return [ rootBlockIndex, 'before' ]; + } + if ( + ( isRightToLeft && edge === 'left' ) || + ( ! isRightToLeft && edge === 'right' ) + ) { + return [ rootBlockIndex + 1, 'after' ]; + } + } + } + blocksData.forEach( ( { isUnmodifiedDefaultBlock, getBoundingClientRect, blockIndex } ) => { const rect = getBoundingClientRect(); @@ -150,19 +216,27 @@ export default function useBlockDropZone( { operation: 'insert', } ); - const isDisabled = useSelect( + const { isDisabled, parentBlockClientId, rootBlockIndex } = useSelect( ( select ) => { const { __unstableIsWithinBlockOverlay, __unstableHasActiveBlockOverlayActive, + getBlockIndex, + getBlockParents, getBlockEditingMode, } = select( blockEditorStore ); const blockEditingMode = getBlockEditingMode( targetRootClientId ); - return ( - blockEditingMode !== 'default' || - __unstableHasActiveBlockOverlayActive( targetRootClientId ) || - __unstableIsWithinBlockOverlay( targetRootClientId ) - ); + return { + parentBlockClientId: + getBlockParents( targetRootClientId, true )[ 0 ] || '', + rootBlockIndex: getBlockIndex( targetRootClientId ), + isDisabled: + blockEditingMode !== 'default' || + __unstableHasActiveBlockOverlayActive( + targetRootClientId + ) || + __unstableIsWithinBlockOverlay( targetRootClientId ), + }; }, [ targetRootClientId ] ); @@ -172,9 +246,15 @@ export default function useBlockDropZone( { const { showInsertionPoint, hideInsertionPoint } = useDispatch( blockEditorStore ); - const onBlockDrop = useOnBlockDrop( targetRootClientId, dropTarget.index, { - operation: dropTarget.operation, - } ); + const onBlockDrop = useOnBlockDrop( + dropTarget.operation === 'before' || dropTarget.operation === 'after' + ? parentBlockClientId + : targetRootClientId, + dropTarget.index, + { + operation: dropTarget.operation, + } + ); const throttled = useThrottle( useCallback( ( event, ownerDocument ) => { @@ -211,7 +291,16 @@ export default function useBlockDropZone( { const [ targetIndex, operation ] = getDropTargetPosition( blocksData, { x: event.clientX, y: event.clientY }, - getBlockListSettings( targetRootClientId )?.orientation + getBlockListSettings( targetRootClientId )?.orientation, + { + dropZoneElement, + parentBlockClientId, + parentBlockOrientation: parentBlockClientId + ? getBlockListSettings( parentBlockClientId ) + ?.orientation + : undefined, + rootBlockIndex, + } ); registry.batch( () => { @@ -219,18 +308,29 @@ export default function useBlockDropZone( { index: targetIndex, operation, } ); - showInsertionPoint( targetRootClientId, targetIndex, { + + const insertionPointClientId = [ + 'before', + 'after', + ].includes( operation ) + ? parentBlockClientId + : targetRootClientId; + + showInsertionPoint( insertionPointClientId, targetIndex, { operation, } ); } ); }, [ + dropZoneElement, getBlocks, targetRootClientId, getBlockListSettings, registry, showInsertionPoint, getBlockIndex, + parentBlockClientId, + rootBlockIndex, ] ), 200 diff --git a/packages/block-editor/src/components/use-display-block-controls/index.js b/packages/block-editor/src/components/use-display-block-controls/index.js deleted file mode 100644 index 605556f295b96..0000000000000 --- a/packages/block-editor/src/components/use-display-block-controls/index.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { useBlockEditContext } from '../block-edit/context'; -import { store as blockEditorStore } from '../../store'; - -export default function useDisplayBlockControls() { - const { isSelected, clientId, name } = useBlockEditContext(); - return useSelect( - ( select ) => { - if ( isSelected ) { - return true; - } - - const { - getBlockName, - isFirstMultiSelectedBlock, - getMultiSelectedBlockClientIds, - } = select( blockEditorStore ); - - if ( isFirstMultiSelectedBlock( clientId ) ) { - return getMultiSelectedBlockClientIds().every( - ( id ) => getBlockName( id ) === name - ); - } - - return false; - }, - [ clientId, isSelected, name ] - ); -} diff --git a/packages/block-editor/src/components/use-display-block-controls/index.native.js b/packages/block-editor/src/components/use-display-block-controls/index.native.js deleted file mode 100644 index e8a198e1592e8..0000000000000 --- a/packages/block-editor/src/components/use-display-block-controls/index.native.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect } from '@wordpress/data'; -import { hasBlockSupport } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { useBlockEditContext } from '../block-edit/context'; -import { store as blockEditorStore } from '../../store'; - -export default function useDisplayBlockControls() { - const { isSelected, clientId, name } = useBlockEditContext(); - return useSelect( - ( select ) => { - const { getBlockName, getBlockRootClientId } = - select( blockEditorStore ); - - const parentId = getBlockRootClientId( clientId ); - const parentBlockName = getBlockName( parentId ); - - const hideControls = hasBlockSupport( - parentBlockName, - '__experimentalHideChildBlockControls', - false - ); - - if ( ! hideControls && isSelected ) { - return true; - } - - return false; - }, - [ clientId, isSelected, name ] - ); -} diff --git a/packages/block-editor/src/components/use-on-block-drop/index.js b/packages/block-editor/src/components/use-on-block-drop/index.js index 72ea6a698c343..ab0da8ad99e2a 100644 --- a/packages/block-editor/src/components/use-on-block-drop/index.js +++ b/packages/block-editor/src/components/use-on-block-drop/index.js @@ -292,9 +292,10 @@ export default function useOnBlockDrop( operation, getBlockOrder, getBlocksByClientId, - insertBlocks, moveBlocksToPosition, + registry, removeBlocks, + replaceBlocks, targetBlockIndex, targetRootClientId, ] diff --git a/packages/block-editor/src/components/use-resize-canvas/README.md b/packages/block-editor/src/components/use-resize-canvas/README.md index 18d28df7b3e3a..ce8f06adea5d8 100644 --- a/packages/block-editor/src/components/use-resize-canvas/README.md +++ b/packages/block-editor/src/components/use-resize-canvas/README.md @@ -1,6 +1,6 @@ # useResizeCanvas -This React hook generates inline CSS suitable for resizing a container to fit a device's dimensions. It adjusts the CSS according to the current device dimensions. It has no effect on desktop. +This React hook generates inline CSS suitable for resizing a container to fit a device's dimensions. It adjusts the CSS according to the current device dimensions. On-page CSS media queries are also updated to match the width of the device. @@ -14,14 +14,14 @@ Note that this is currently experimental, and is available as `__experimentalUse ### Usage -The hook returns a style object which can be applied to a container. It is passed the current device type, which can be obtained from `__experimentalGetPreviewDeviceType`. +The hook returns a style object which can be applied to a container. It is passed the current device type, which can be obtained from `getDeviceType`. ```jsx import { __experimentalUseResizeCanvas as useResizeCanvas } from '@wordpress/block-editor'; function ResizedContainer() { const deviceType = useSelect( ( select ) => { - return select( 'core/edit-post' ).__experimentalGetPreviewDeviceType(); + return select( 'core/editor' ).getDeviceType(); }, [] ); const inlineStyles = useResizeCanvas( deviceType ); diff --git a/packages/block-editor/src/components/use-resize-canvas/index.js b/packages/block-editor/src/components/use-resize-canvas/index.js index fab0b7a15e2af..a843f16005636 100644 --- a/packages/block-editor/src/components/use-resize-canvas/index.js +++ b/packages/block-editor/src/components/use-resize-canvas/index.js @@ -67,7 +67,10 @@ export default function useResizeCanvas( deviceType ) { overflowY: 'auto', }; default: - return null; + return { + marginLeft: marginHorizontal, + marginRight: marginHorizontal, + }; } }; diff --git a/packages/block-editor/src/components/use-settings/index.js b/packages/block-editor/src/components/use-settings/index.js index 36be1f914baf2..6f791a42bf1fe 100644 --- a/packages/block-editor/src/components/use-settings/index.js +++ b/packages/block-editor/src/components/use-settings/index.js @@ -103,7 +103,7 @@ const removeCustomPrefixes = ( path ) => { * @param {Object} value Object to merge * @return {Array} Array of merged items */ -function mergeOrigins( value ) { +export function mergeOrigins( value ) { let result = mergeCache.get( value ); if ( ! result ) { result = [ 'default', 'theme', 'custom' ].flatMap( @@ -115,6 +115,20 @@ function mergeOrigins( value ) { } const mergeCache = new WeakMap(); +/** + * For settings like `color.palette`, which have a value that is an object + * with `default`, `theme`, `custom`, with field values that are arrays of + * items, see if any of the three origins have values. + * + * @param {Object} value Object to check + * @return {boolean} Whether the object has values in any of the three origins + */ +export function hasMergedOrigins( value ) { + return [ 'default', 'theme', 'custom' ].some( + ( key ) => value?.[ key ]?.length + ); +} + /** * Hook that retrieves the given settings for the block instance in use. * diff --git a/packages/block-editor/src/hooks/align.js b/packages/block-editor/src/hooks/align.js index 563c7bae6cde9..189f82ccf429f 100644 --- a/packages/block-editor/src/hooks/align.js +++ b/packages/block-editor/src/hooks/align.js @@ -6,7 +6,6 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { createHigherOrderComponent } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { getBlockSupport, @@ -108,9 +107,9 @@ export function addAttribute( settings ) { return settings; } -function BlockEditAlignmentToolbarControls( { - blockName, - attributes, +function BlockEditAlignmentToolbarControlsPure( { + name: blockName, + align, setAttributes, } ) { // Compute the block valid alignments by taking into account, @@ -144,7 +143,7 @@ function BlockEditAlignmentToolbarControls( { return ( @@ -152,80 +151,30 @@ function BlockEditAlignmentToolbarControls( { ); } -/** - * Override the default edit UI to include new toolbar controls for block - * alignment, if block defines support. - * - * @param {Function} BlockEdit Original component. - * - * @return {Function} Wrapped component. - */ -export const withAlignmentControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const hasAlignmentSupport = hasBlockSupport( - props.name, - 'align', - false - ); - - return ( - <> - { hasAlignmentSupport && ( - - ) } - - - ); +export default { + shareWithChildBlocks: true, + edit: BlockEditAlignmentToolbarControlsPure, + useBlockProps, + attributeKeys: [ 'align' ], + hasSupport( name ) { + return hasBlockSupport( name, 'align', false ); }, - 'withAlignmentControls' -); +}; -function BlockListBlockWithDataAlign( { block: BlockListBlock, props } ) { - const { name, attributes } = props; - const { align } = attributes; +function useBlockProps( { name, align } ) { const blockAllowedAlignments = getValidAlignments( getBlockSupport( name, 'align' ), hasBlockSupport( name, 'alignWide', true ) ); const validAlignments = useAvailableAlignments( blockAllowedAlignments ); - let wrapperProps = props.wrapperProps; if ( validAlignments.some( ( alignment ) => alignment.name === align ) ) { - wrapperProps = { ...wrapperProps, 'data-align': align }; + return { 'data-align': align }; } - return ; + return {}; } -/** - * Override the default block element to add alignment wrapper props. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -export const withDataAlign = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - // If an alignment is not assigned, there's no need to go through the - // effort to validate or assign its value. - if ( props.attributes.align === undefined ) { - return ; - } - - return ( - - ); - }, - 'withDataAlign' -); - /** * Override props assigned to save component to inject alignment class name if * block supports it. @@ -260,16 +209,6 @@ addFilter( 'core/editor/align/addAttribute', addAttribute ); -addFilter( - 'editor.BlockListBlock', - 'core/editor/align/with-data-align', - withDataAlign -); -addFilter( - 'editor.BlockEdit', - 'core/editor/align/with-toolbar-controls', - withAlignmentControls -); addFilter( 'blocks.getSaveContent.extraProps', 'core/editor/align/addAssignedAlign', diff --git a/packages/block-editor/src/hooks/align.native.js b/packages/block-editor/src/hooks/align.native.js index 1bf375b654ad4..7a229e79870a8 100644 --- a/packages/block-editor/src/hooks/align.native.js +++ b/packages/block-editor/src/hooks/align.native.js @@ -8,6 +8,7 @@ import { WIDE_ALIGNMENTS } from '@wordpress/components'; const ALIGNMENTS = [ 'left', 'center', 'right' ]; export * from './align.js'; +export { default } from './align.js'; // Used to filter out blocks that don't support wide/full alignment on mobile addFilter( diff --git a/packages/block-editor/src/hooks/anchor.js b/packages/block-editor/src/hooks/anchor.js index 3d404c4a86811..882820678aa87 100644 --- a/packages/block-editor/src/hooks/anchor.js +++ b/packages/block-editor/src/hooks/anchor.js @@ -5,7 +5,6 @@ import { addFilter } from '@wordpress/hooks'; import { PanelBody, TextControl, ExternalLink } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { hasBlockSupport } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; import { Platform } from '@wordpress/element'; /** @@ -52,7 +51,11 @@ export function addAttribute( settings ) { return settings; } -function BlockEditAnchorControl( { blockName, attributes, setAttributes } ) { +function BlockEditAnchorControlPure( { + name: blockName, + anchor, + setAttributes, +} ) { const blockEditingMode = useBlockEditingMode(); const isWeb = Platform.OS === 'web'; @@ -79,7 +82,7 @@ function BlockEditAnchorControl( { blockName, attributes, setAttributes } ) { ) } } - value={ attributes.anchor || '' } + value={ anchor || '' } placeholder={ ! isWeb ? __( 'Add an anchor' ) : null } onChange={ ( nextValue ) => { nextValue = nextValue.replace( ANCHOR_REGEX, '-' ); @@ -116,31 +119,13 @@ function BlockEditAnchorControl( { blockName, attributes, setAttributes } ) { ); } -/** - * Override the default edit UI to include a new block inspector control for - * assigning the anchor ID, if block supports anchor. - * - * @param {Component} BlockEdit Original component. - * - * @return {Component} Wrapped component. - */ -export const withAnchorControls = createHigherOrderComponent( ( BlockEdit ) => { - return ( props ) => { - return ( - <> - - { props.isSelected && - hasBlockSupport( props.name, 'anchor' ) && ( - - ) } - - ); - }; -}, 'withAnchorControls' ); +export default { + edit: BlockEditAnchorControlPure, + attributeKeys: [ 'anchor' ], + hasSupport( name ) { + return hasBlockSupport( name, 'anchor' ); + }, +}; /** * Override props assigned to save component to inject anchor ID, if block @@ -162,11 +147,6 @@ export function addSaveProps( extraProps, blockType, attributes ) { } addFilter( 'blocks.registerBlockType', 'core/anchor/attribute', addAttribute ); -addFilter( - 'editor.BlockEdit', - 'core/editor/anchor/with-inspector-controls', - withAnchorControls -); addFilter( 'blocks.getSaveContent.extraProps', 'core/editor/anchor/save-props', diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index b0f93fa8b2e06..b75dc95b75241 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -24,6 +24,7 @@ import { Platform, useCallback, useRef } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { getFilename } from '@wordpress/url'; +import { pure } from '@wordpress/compose'; /** * Internal dependencies @@ -41,13 +42,13 @@ export const IMAGE_BACKGROUND_TYPE = 'image'; * Checks if there is a current value in the background image block support * attributes. * - * @param {Object} props Block props. + * @param {Object} style Style attribute. * @return {boolean} Whether or not the block has a background image value set. */ -export function hasBackgroundImageValue( props ) { +export function hasBackgroundImageValue( style ) { const hasValue = - !! props.attributes.style?.background?.backgroundImage?.id || - !! props.attributes.style?.background?.backgroundImage?.url; + !! style?.background?.backgroundImage?.id || + !! style?.background?.backgroundImage?.url; return hasValue; } @@ -82,13 +83,10 @@ export function hasBackgroundSupport( blockName, feature = 'any' ) { * Resets the background image block support attributes. This can be used when disabling * the background image controls for a block via a `ToolsPanel`. * - * @param {Object} props Block props. - * @param {Object} props.attributes Block's attributes. - * @param {Object} props.setAttributes Function to set block's attributes. + * @param {Object} style Style attribute. + * @param {Function} setAttributes Function to set block's attributes. */ -export function resetBackgroundImage( { attributes = {}, setAttributes } ) { - const { style = {} } = attributes; - +export function resetBackgroundImage( style = {}, setAttributes ) { setAttributes( { style: cleanEmptyObject( { ...style, @@ -145,11 +143,13 @@ function InspectorImagePreview( { label, filename, url: imgUrl } ) { ); } -function BackgroundImagePanelItem( props ) { - const { attributes, clientId, setAttributes } = props; - - const { id, title, url } = - attributes.style?.background?.backgroundImage || {}; +function BackgroundImagePanelItem( { clientId, setAttributes } ) { + const style = useSelect( + ( select ) => + select( blockEditorStore ).getBlockAttributes( clientId )?.style, + [ clientId ] + ); + const { id, title, url } = style?.background?.backgroundImage || {}; const replaceContainerRef = useRef(); @@ -167,9 +167,9 @@ function BackgroundImagePanelItem( props ) { const onSelectMedia = ( media ) => { if ( ! media || ! media.url ) { const newStyle = { - ...attributes.style, + ...style, background: { - ...attributes.style?.background, + ...style?.background, backgroundImage: undefined, }, }; @@ -201,9 +201,9 @@ function BackgroundImagePanelItem( props ) { } const newStyle = { - ...attributes.style, + ...style, background: { - ...attributes.style?.background, + ...style?.background, backgroundImage: { url: media.url, id: media.id, @@ -244,14 +244,14 @@ function BackgroundImagePanelItem( props ) { }; }, [] ); - const hasValue = hasBackgroundImageValue( props ); + const hasValue = hasBackgroundImageValue( style ); return ( hasValue } label={ __( 'Background image' ) } - onDeselect={ () => resetBackgroundImage( props ) } + onDeselect={ () => resetBackgroundImage( style, setAttributes ) } isShownByDefault={ true } resetAllFilter={ resetAllFilter } panelId={ clientId } @@ -286,7 +286,7 @@ function BackgroundImagePanelItem( props ) { // closed and focus is redirected to the dropdown toggle button. toggleButton?.focus(); toggleButton?.click(); - resetBackgroundImage( props ); + resetBackgroundImage( style, setAttributes ); } } > { __( 'Reset ' ) } @@ -302,7 +302,7 @@ function BackgroundImagePanelItem( props ) { ); } -export function BackgroundImagePanel( props ) { +function BackgroundImagePanelPure( props ) { const [ backgroundImage ] = useSettings( 'background.backgroundImage' ); if ( ! backgroundImage || @@ -317,3 +317,8 @@ export function BackgroundImagePanel( props ) { ); } + +// We don't want block controls to re-render when typing inside a block. `pure` +// will prevent re-renders unless props change, so only pass the needed props +// and not the whole attributes object. +export const BackgroundImagePanel = pure( BackgroundImagePanelPure ); diff --git a/packages/block-editor/src/hooks/block-hooks.js b/packages/block-editor/src/hooks/block-hooks.js index 0d75999192e5b..a1640c72f4b2b 100644 --- a/packages/block-editor/src/hooks/block-hooks.js +++ b/packages/block-editor/src/hooks/block-hooks.js @@ -2,14 +2,12 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { addFilter } from '@wordpress/hooks'; import { Fragment, useMemo } from '@wordpress/element'; import { __experimentalHStack as HStack, PanelBody, ToggleControl, } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; import { createBlock, store as blocksStore } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; @@ -21,7 +19,7 @@ import { store as blockEditorStore } from '../store'; const EMPTY_OBJECT = {}; -function BlockHooksControl( props ) { +function BlockHooksControlPure( { name, clientId } ) { const blockTypes = useSelect( ( select ) => select( blocksStore ).getBlockTypes(), [] @@ -30,10 +28,9 @@ function BlockHooksControl( props ) { const hookedBlocksForCurrentBlock = useMemo( () => blockTypes?.filter( - ( { blockHooks } ) => - blockHooks && props.blockName in blockHooks + ( { blockHooks } ) => blockHooks && name in blockHooks ), - [ blockTypes, props.blockName ] + [ blockTypes, name ] ); const { blockIndex, rootClientId, innerBlocksLength } = useSelect( @@ -42,13 +39,12 @@ function BlockHooksControl( props ) { select( blockEditorStore ); return { - blockIndex: getBlockIndex( props.clientId ), - innerBlocksLength: getBlock( props.clientId )?.innerBlocks - ?.length, - rootClientId: getBlockRootClientId( props.clientId ), + blockIndex: getBlockIndex( clientId ), + innerBlocksLength: getBlock( clientId )?.innerBlocks?.length, + rootClientId: getBlockRootClientId( clientId ), }; }, - [ props.clientId ] + [ clientId ] ); const hookedBlockClientIds = useSelect( @@ -65,8 +61,7 @@ function BlockHooksControl( props ) { return clientIds; } - const relativePosition = - block?.blockHooks?.[ props.blockName ]; + const relativePosition = block?.blockHooks?.[ name ]; let candidates; switch ( relativePosition ) { @@ -83,12 +78,12 @@ function BlockHooksControl( props ) { // Any of the current block's child blocks (with the right block type) qualifies // as a hooked first or last child block, as the block might've been automatically // inserted and then moved around a bit by the user. - candidates = getBlock( props.clientId ).innerBlocks; + candidates = getBlock( clientId ).innerBlocks; break; } const hookedBlock = candidates?.find( - ( { name } ) => name === block.name + ( candidate ) => name === candidate.name ); // If the block exists in the designated location, we consider it hooked @@ -118,12 +113,7 @@ function BlockHooksControl( props ) { return EMPTY_OBJECT; }, - [ - hookedBlocksForCurrentBlock, - props.blockName, - props.clientId, - rootClientId, - ] + [ hookedBlocksForCurrentBlock, name, clientId, rootClientId ] ); const { insertBlock, removeBlock } = useDispatch( blockEditorStore ); @@ -169,7 +159,7 @@ function BlockHooksControl( props ) { block, // TODO: It'd be great if insertBlock() would accept negative indices for insertion. relativePosition === 'first_child' ? 0 : innerBlocksLength, - props.clientId, // Insert as a child of the current block. + clientId, // Insert as a child of the current block. false ); break; @@ -207,9 +197,7 @@ function BlockHooksControl( props ) { if ( ! checked ) { // Create and insert block. const relativePosition = - block.blockHooks[ - props.blockName - ]; + block.blockHooks[ name ]; insertBlockIntoDesignatedLocation( createBlock( block.name ), relativePosition @@ -218,11 +206,12 @@ function BlockHooksControl( props ) { } // Remove block. - const clientId = + removeBlock( hookedBlockClientIds[ block.name - ]; - removeBlock( clientId, false ); + ], + false + ); } } /> ); @@ -235,27 +224,9 @@ function BlockHooksControl( props ) { ); } -export const withBlockHooksControls = createHigherOrderComponent( - ( BlockEdit ) => { - return ( props ) => { - return ( - <> - - { props.isSelected && ( - - ) } - - ); - }; +export default { + edit: BlockHooksControlPure, + hasSupport() { + return true; }, - 'withBlockHooksControls' -); - -addFilter( - 'editor.BlockEdit', - 'core/editor/block-hooks/with-inspector-controls', - withBlockHooksControls -); +}; diff --git a/packages/block-editor/src/hooks/block-renaming.js b/packages/block-editor/src/hooks/block-renaming.js index 48e3b801d4eb9..9b8beb3a5e4be 100644 --- a/packages/block-editor/src/hooks/block-renaming.js +++ b/packages/block-editor/src/hooks/block-renaming.js @@ -3,7 +3,6 @@ */ import { addFilter } from '@wordpress/hooks'; import { hasBlockSupport } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; import { TextControl } from '@wordpress/components'; @@ -11,7 +10,6 @@ import { TextControl } from '@wordpress/components'; * Internal dependencies */ import { InspectorControls } from '../components'; -import { useBlockRename } from '../components/block-rename'; /** * Filters registered block settings, adding an `__experimentalLabel` callback if one does not already exist. @@ -47,43 +45,31 @@ export function addLabelCallback( settings ) { return settings; } -export const withBlockRenameControl = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const { name, attributes, setAttributes, isSelected } = props; - - const { canRename } = useBlockRename( name ); +function BlockRenameControlPure( { metadata, setAttributes } ) { + return ( + + { + setAttributes( { + metadata: { ...metadata, name: newName }, + } ); + } } + /> + + ); +} - return ( - <> - { isSelected && canRename && ( - - { - setAttributes( { - metadata: { - ...attributes?.metadata, - name: newName, - }, - } ); - } } - /> - - ) } - - - ); +export default { + edit: BlockRenameControlPure, + attributeKeys: [ 'metadata' ], + hasSupport( name ) { + return hasBlockSupport( name, 'renaming', true ); }, - 'withToolbarControls' -); - -addFilter( - 'editor.BlockEdit', - 'core/block-rename-ui/with-block-rename-control', - withBlockRenameControl -); +}; addFilter( 'blocks.registerBlockType', diff --git a/packages/block-editor/src/hooks/border.js b/packages/block-editor/src/hooks/border.js index c2d30d5501576..c6947eeaa18e3 100644 --- a/packages/block-editor/src/hooks/border.js +++ b/packages/block-editor/src/hooks/border.js @@ -8,9 +8,10 @@ import classnames from 'classnames'; */ import { getBlockSupport } from '@wordpress/blocks'; import { __experimentalHasSplitBorders as hasSplitBorders } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; +import { pure } from '@wordpress/compose'; import { Platform, useCallback, useMemo } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -18,15 +19,12 @@ import { addFilter } from '@wordpress/hooks'; import { getColorClassName } from '../components/colors'; import InspectorControls from '../components/inspector-controls'; import useMultipleOriginColorsAndGradients from '../components/colors-gradients/use-multiple-origin-colors-and-gradients'; -import { - cleanEmptyObject, - shouldSkipSerialization, - useBlockSettings, -} from './utils'; +import { cleanEmptyObject, shouldSkipSerialization } from './utils'; import { useHasBorderPanel, BorderPanel as StylesBorderPanel, } from '../components/global-styles'; +import { store as blockEditorStore } from '../store'; export const BORDER_SUPPORT_KEY = '__experimentalBorder'; @@ -135,16 +133,17 @@ function BordersInspectorControl( { children, resetAllFilter } ) { ); } -export function BorderPanel( props ) { - const { clientId, name, attributes, setAttributes } = props; - const settings = useBlockSettings( name ); +function BorderPanelPure( { clientId, name, setAttributes, settings } ) { const isEnabled = useHasBorderPanel( settings ); + function selector( select ) { + const { style, borderColor } = + select( blockEditorStore ).getBlockAttributes( clientId ) || {}; + return { style, borderColor }; + } + const { style, borderColor } = useSelect( selector, [ clientId ] ); const value = useMemo( () => { - return attributesToStyle( { - style: attributes.style, - borderColor: attributes.borderColor, - } ); - }, [ attributes.style, attributes.borderColor ] ); + return attributesToStyle( { style, borderColor } ); + }, [ style, borderColor ] ); const onChange = ( newStyle ) => { setAttributes( styleToAttributes( newStyle ) ); @@ -154,7 +153,7 @@ export function BorderPanel( props ) { return null; } - const defaultControls = getBlockSupport( props.name, [ + const defaultControls = getBlockSupport( name, [ BORDER_SUPPORT_KEY, '__experimentalDefaultControls', ] ); @@ -171,6 +170,11 @@ export function BorderPanel( props ) { ); } +// We don't want block controls to re-render when typing inside a block. `pure` +// will prevent re-renders unless props change, so only pass the needed props +// and not the whole attributes object. +export const BorderPanel = pure( BorderPanelPure ); + /** * Determine whether there is block support for border properties. * @@ -254,16 +258,16 @@ function addAttributes( settings ) { /** * Override props assigned to save component to inject border color. * - * @param {Object} props Additional props applied to save element. - * @param {Object} blockType Block type definition. - * @param {Object} attributes Block's attributes. + * @param {Object} props Additional props applied to save element. + * @param {Object|string} blockNameOrType Block type definition. + * @param {Object} attributes Block's attributes. * * @return {Object} Filtered props to apply to save element. */ -function addSaveProps( props, blockType, attributes ) { +function addSaveProps( props, blockNameOrType, attributes ) { if ( - ! hasBorderSupport( blockType, 'color' ) || - shouldSkipSerialization( blockType, BORDER_SUPPORT_KEY, 'color' ) + ! hasBorderSupport( blockNameOrType, 'color' ) || + shouldSkipSerialization( blockNameOrType, BORDER_SUPPORT_KEY, 'color' ) ) { return props; } @@ -296,102 +300,59 @@ export function getBorderClasses( attributes ) { } ); } -/** - * Filters the registered block settings to apply border color styles and - * classnames to the block edit wrapper. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -function addEditProps( settings ) { +function useBlockProps( { name, borderColor, style } ) { + const { colors } = useMultipleOriginColorsAndGradients(); + if ( - ! hasBorderSupport( settings, 'color' ) || - shouldSkipSerialization( settings, BORDER_SUPPORT_KEY, 'color' ) + ! hasBorderSupport( name, 'color' ) || + shouldSkipSerialization( name, BORDER_SUPPORT_KEY, 'color' ) ) { - return settings; + return {}; } - const existingGetEditWrapperProps = settings.getEditWrapperProps; - settings.getEditWrapperProps = ( attributes ) => { - let props = {}; + const { color: borderColorValue } = getMultiOriginColor( { + colors, + namedColor: borderColor, + } ); + const { color: borderTopColor } = getMultiOriginColor( { + colors, + namedColor: getColorSlugFromVariable( style?.border?.top?.color ), + } ); + const { color: borderRightColor } = getMultiOriginColor( { + colors, + namedColor: getColorSlugFromVariable( style?.border?.right?.color ), + } ); - if ( existingGetEditWrapperProps ) { - props = existingGetEditWrapperProps( attributes ); - } + const { color: borderBottomColor } = getMultiOriginColor( { + colors, + namedColor: getColorSlugFromVariable( style?.border?.bottom?.color ), + } ); + const { color: borderLeftColor } = getMultiOriginColor( { + colors, + namedColor: getColorSlugFromVariable( style?.border?.left?.color ), + } ); - return addSaveProps( props, settings, attributes ); + const extraStyles = { + borderTopColor: borderTopColor || borderColorValue, + borderRightColor: borderRightColor || borderColorValue, + borderBottomColor: borderBottomColor || borderColorValue, + borderLeftColor: borderLeftColor || borderColorValue, }; - return settings; + return addSaveProps( + { style: cleanEmptyObject( extraStyles ) || {} }, + name, + { borderColor, style } + ); } -/** - * This adds inline styles for color palette colors. - * Ideally, this is not needed and themes should load their palettes on the editor. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -export const withBorderColorPaletteStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const { name, attributes } = props; - const { borderColor, style } = attributes; - const { colors } = useMultipleOriginColorsAndGradients(); - - if ( - ! hasBorderSupport( name, 'color' ) || - shouldSkipSerialization( name, BORDER_SUPPORT_KEY, 'color' ) - ) { - return ; - } - - const { color: borderColorValue } = getMultiOriginColor( { - colors, - namedColor: borderColor, - } ); - const { color: borderTopColor } = getMultiOriginColor( { - colors, - namedColor: getColorSlugFromVariable( style?.border?.top?.color ), - } ); - const { color: borderRightColor } = getMultiOriginColor( { - colors, - namedColor: getColorSlugFromVariable( style?.border?.right?.color ), - } ); - - const { color: borderBottomColor } = getMultiOriginColor( { - colors, - namedColor: getColorSlugFromVariable( - style?.border?.bottom?.color - ), - } ); - const { color: borderLeftColor } = getMultiOriginColor( { - colors, - namedColor: getColorSlugFromVariable( style?.border?.left?.color ), - } ); - - const extraStyles = { - borderTopColor: borderTopColor || borderColorValue, - borderRightColor: borderRightColor || borderColorValue, - borderBottomColor: borderBottomColor || borderColorValue, - borderLeftColor: borderLeftColor || borderColorValue, - }; - const cleanedExtraStyles = cleanEmptyObject( extraStyles ) || {}; - - let wrapperProps = props.wrapperProps; - wrapperProps = { - ...props.wrapperProps, - style: { - ...props.wrapperProps?.style, - ...cleanedExtraStyles, - }, - }; - - return ; +export default { + useBlockProps, + attributeKeys: [ 'borderColor', 'style' ], + hasSupport( name ) { + return hasBorderSupport( name, 'color' ); }, - 'withBorderColorPaletteStyles' -); +}; addFilter( 'blocks.registerBlockType', @@ -404,15 +365,3 @@ addFilter( 'core/border/addSaveProps', addSaveProps ); - -addFilter( - 'blocks.registerBlockType', - 'core/border/addEditProps', - addEditProps -); - -addFilter( - 'editor.BlockListBlock', - 'core/border/with-border-color-palette-styles', - withBorderColorPaletteStyles -); diff --git a/packages/block-editor/src/hooks/color.js b/packages/block-editor/src/hooks/color.js index 19fe4b0ea5ecd..db6c3dc8fd86c 100644 --- a/packages/block-editor/src/hooks/color.js +++ b/packages/block-editor/src/hooks/color.js @@ -9,7 +9,8 @@ import classnames from 'classnames'; import { addFilter } from '@wordpress/hooks'; import { getBlockSupport } from '@wordpress/blocks'; import { useMemo, Platform, useCallback } from '@wordpress/element'; -import { createHigherOrderComponent } from '@wordpress/compose'; +import { pure } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -23,7 +24,6 @@ import { cleanEmptyObject, transformStyles, shouldSkipSerialization, - useBlockSettings, } from './utils'; import { useSettings } from '../components/use-settings'; import InspectorControls from '../components/inspector-controls'; @@ -32,11 +32,12 @@ import { default as StylesColorPanel, } from '../components/global-styles/color-panel'; import BlockColorContrastChecker from './contrast-checker'; +import { store as blockEditorStore } from '../store'; export const COLOR_SUPPORT_KEY = 'color'; -const hasColorSupport = ( blockType ) => { - const colorSupport = getBlockSupport( blockType, COLOR_SUPPORT_KEY ); +const hasColorSupport = ( blockNameOrType ) => { + const colorSupport = getBlockSupport( blockNameOrType, COLOR_SUPPORT_KEY ); return ( colorSupport && ( colorSupport.link === true || @@ -60,8 +61,8 @@ const hasLinkColorSupport = ( blockType ) => { ); }; -const hasGradientSupport = ( blockType ) => { - const colorSupport = getBlockSupport( blockType, COLOR_SUPPORT_KEY ); +const hasGradientSupport = ( blockNameOrType ) => { + const colorSupport = getBlockSupport( blockNameOrType, COLOR_SUPPORT_KEY ); return ( colorSupport !== null && @@ -125,27 +126,31 @@ function addAttributes( settings ) { /** * Override props assigned to save component to inject colors classnames. * - * @param {Object} props Additional props applied to save element. - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. + * @param {Object} props Additional props applied to save element. + * @param {Object|string} blockNameOrType Block type. + * @param {Object} attributes Block attributes. * * @return {Object} Filtered props applied to save element. */ -export function addSaveProps( props, blockType, attributes ) { +export function addSaveProps( props, blockNameOrType, attributes ) { if ( - ! hasColorSupport( blockType ) || - shouldSkipSerialization( blockType, COLOR_SUPPORT_KEY ) + ! hasColorSupport( blockNameOrType ) || + shouldSkipSerialization( blockNameOrType, COLOR_SUPPORT_KEY ) ) { return props; } - const hasGradient = hasGradientSupport( blockType ); + const hasGradient = hasGradientSupport( blockNameOrType ); // I'd have preferred to avoid the "style" attribute usage here const { backgroundColor, textColor, gradient, style } = attributes; const shouldSerialize = ( feature ) => - ! shouldSkipSerialization( blockType, COLOR_SUPPORT_KEY, feature ); + ! shouldSkipSerialization( + blockNameOrType, + COLOR_SUPPORT_KEY, + feature + ); // Primary color classes must come before the `has-text-color`, // `has-background` and `has-link-color` classes to maintain backwards @@ -191,33 +196,6 @@ export function addSaveProps( props, blockType, attributes ) { return props; } -/** - * Filters registered block settings to extend the block edit wrapper - * to apply the desired styles and classnames properly. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -export function addEditProps( settings ) { - if ( - ! hasColorSupport( settings ) || - shouldSkipSerialization( settings, COLOR_SUPPORT_KEY ) - ) { - return settings; - } - const existingGetEditWrapperProps = settings.getEditWrapperProps; - settings.getEditWrapperProps = ( attributes ) => { - let props = {}; - if ( existingGetEditWrapperProps ) { - props = existingGetEditWrapperProps( attributes ); - } - return addSaveProps( props, settings, attributes ); - }; - - return settings; -} - function styleToAttributes( style ) { const textColorValue = style?.color?.text; const textColorSlug = textColorValue?.startsWith( 'var:preset|color|' ) @@ -289,23 +267,25 @@ function ColorInspectorControl( { children, resetAllFilter } ) { ); } -export function ColorEdit( props ) { - const { clientId, name, attributes, setAttributes } = props; - const settings = useBlockSettings( name ); +function ColorEditPure( { clientId, name, setAttributes, settings } ) { const isEnabled = useHasColorPanel( settings ); + function selector( select ) { + const { style, textColor, backgroundColor, gradient } = + select( blockEditorStore ).getBlockAttributes( clientId ) || {}; + return { style, textColor, backgroundColor, gradient }; + } + const { style, textColor, backgroundColor, gradient } = useSelect( + selector, + [ clientId ] + ); const value = useMemo( () => { return attributesToStyle( { - style: attributes.style, - textColor: attributes.textColor, - backgroundColor: attributes.backgroundColor, - gradient: attributes.gradient, + style, + textColor, + backgroundColor, + gradient, } ); - }, [ - attributes.style, - attributes.textColor, - attributes.backgroundColor, - attributes.gradient, - ] ); + }, [ style, textColor, backgroundColor, gradient ] ); const onChange = ( newStyle ) => { setAttributes( styleToAttributes( newStyle ) ); @@ -315,7 +295,7 @@ export function ColorEdit( props ) { return null; } - const defaultControls = getBlockSupport( props.name, [ + const defaultControls = getBlockSupport( name, [ COLOR_SUPPORT_KEY, '__experimentalDefaultControls', ] ); @@ -328,7 +308,7 @@ export function ColorEdit( props ) { // Deactivating it requires `enableContrastChecker` to have // an explicit value of `false`. false !== - getBlockSupport( props.name, [ + getBlockSupport( name, [ COLOR_SUPPORT_KEY, 'enableContrastChecker', ] ); @@ -343,7 +323,7 @@ export function ColorEdit( props ) { defaultControls={ defaultControls } enableContrastChecker={ false !== - getBlockSupport( props.name, [ + getBlockSupport( name, [ COLOR_SUPPORT_KEY, 'enableContrastChecker', ] ) @@ -356,72 +336,72 @@ export function ColorEdit( props ) { ); } -/** - * This adds inline styles for color palette colors. - * Ideally, this is not needed and themes should load their palettes on the editor. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -export const withColorPaletteStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const { name, attributes } = props; - const { backgroundColor, textColor } = attributes; - const [ userPalette, themePalette, defaultPalette ] = useSettings( - 'color.palette.custom', - 'color.palette.theme', - 'color.palette.default' - ); +// We don't want block controls to re-render when typing inside a block. `pure` +// will prevent re-renders unless props change, so only pass the needed props +// and not the whole attributes object. +export const ColorEdit = pure( ColorEditPure ); + +function useBlockProps( { + name, + backgroundColor, + textColor, + gradient, + style, +} ) { + const [ userPalette, themePalette, defaultPalette ] = useSettings( + 'color.palette.custom', + 'color.palette.theme', + 'color.palette.default' + ); - const colors = useMemo( - () => [ - ...( userPalette || [] ), - ...( themePalette || [] ), - ...( defaultPalette || [] ), - ], - [ userPalette, themePalette, defaultPalette ] - ); - if ( - ! hasColorSupport( name ) || - shouldSkipSerialization( name, COLOR_SUPPORT_KEY ) - ) { - return ; - } - const extraStyles = {}; - - if ( - textColor && - ! shouldSkipSerialization( name, COLOR_SUPPORT_KEY, 'text' ) - ) { - extraStyles.color = getColorObjectByAttributeValues( - colors, - textColor - )?.color; - } - if ( - backgroundColor && - ! shouldSkipSerialization( name, COLOR_SUPPORT_KEY, 'background' ) - ) { - extraStyles.backgroundColor = getColorObjectByAttributeValues( - colors, - backgroundColor - )?.color; - } + const colors = useMemo( + () => [ + ...( userPalette || [] ), + ...( themePalette || [] ), + ...( defaultPalette || [] ), + ], + [ userPalette, themePalette, defaultPalette ] + ); + if ( + ! hasColorSupport( name ) || + shouldSkipSerialization( name, COLOR_SUPPORT_KEY ) + ) { + return {}; + } + const extraStyles = {}; - let wrapperProps = props.wrapperProps; - wrapperProps = { - ...props.wrapperProps, - style: { - ...extraStyles, - ...props.wrapperProps?.style, - }, - }; + if ( + textColor && + ! shouldSkipSerialization( name, COLOR_SUPPORT_KEY, 'text' ) + ) { + extraStyles.color = getColorObjectByAttributeValues( + colors, + textColor + )?.color; + } + if ( + backgroundColor && + ! shouldSkipSerialization( name, COLOR_SUPPORT_KEY, 'background' ) + ) { + extraStyles.backgroundColor = getColorObjectByAttributeValues( + colors, + backgroundColor + )?.color; + } - return ; - }, - 'withColorPaletteStyles' -); + return addSaveProps( { style: extraStyles }, name, { + textColor, + backgroundColor, + gradient, + style, + } ); +} + +export default { + useBlockProps, + attributeKeys: [ 'backgroundColor', 'textColor', 'gradient', 'style' ], + hasSupport: hasColorSupport, +}; const MIGRATION_PATHS = { linkColor: [ [ 'style', 'elements', 'link', 'color', 'text' ] ], @@ -463,18 +443,6 @@ addFilter( addSaveProps ); -addFilter( - 'blocks.registerBlockType', - 'core/color/addEditProps', - addEditProps -); - -addFilter( - 'editor.BlockListBlock', - 'core/color/with-color-palette-styles', - withColorPaletteStyles -); - addFilter( 'blocks.switchToBlockType.transformedBlock', 'core/color/addTransforms', diff --git a/packages/block-editor/src/hooks/content-lock-ui.js b/packages/block-editor/src/hooks/content-lock-ui.js index 5d277d6a516d2..1bcd0d7ce8016 100644 --- a/packages/block-editor/src/hooks/content-lock-ui.js +++ b/packages/block-editor/src/hooks/content-lock-ui.js @@ -2,9 +2,7 @@ * WordPress dependencies */ import { ToolbarButton, MenuItem } from '@wordpress/components'; -import { createHigherOrderComponent } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; -import { addFilter } from '@wordpress/hooks'; import { __ } from '@wordpress/i18n'; import { useEffect, useRef, useCallback } from '@wordpress/element'; @@ -37,129 +35,119 @@ function StopEditingAsBlocksOnOutsideSelect( { return null; } -export const withContentLockControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const { getBlockListSettings, getSettings } = - useSelect( blockEditorStore ); - const focusModeToRevert = useRef(); - const { templateLock, isLockedByParent, isEditingAsBlocks } = useSelect( - ( select ) => { - const { - __unstableGetContentLockingParent, - getTemplateLock, - __unstableGetTemporarilyEditingAsBlocks, - } = select( blockEditorStore ); - return { - templateLock: getTemplateLock( props.clientId ), - isLockedByParent: !! __unstableGetContentLockingParent( - props.clientId - ), - isEditingAsBlocks: - __unstableGetTemporarilyEditingAsBlocks() === - props.clientId, - }; - }, - [ props.clientId ] - ); +function ContentLockControlsPure( { clientId, isSelected } ) { + const { getBlockListSettings, getSettings } = useSelect( blockEditorStore ); + const focusModeToRevert = useRef(); + const { templateLock, isLockedByParent, isEditingAsBlocks } = useSelect( + ( select ) => { + const { + __unstableGetContentLockingParent, + getTemplateLock, + __unstableGetTemporarilyEditingAsBlocks, + } = select( blockEditorStore ); + return { + templateLock: getTemplateLock( clientId ), + isLockedByParent: + !! __unstableGetContentLockingParent( clientId ), + isEditingAsBlocks: + __unstableGetTemporarilyEditingAsBlocks() === clientId, + }; + }, + [ clientId ] + ); - const { - updateSettings, - updateBlockListSettings, - __unstableSetTemporarilyEditingAsBlocks, - } = useDispatch( blockEditorStore ); - const isContentLocked = - ! isLockedByParent && templateLock === 'contentOnly'; - const { - __unstableMarkNextChangeAsNotPersistent, - updateBlockAttributes, - } = useDispatch( blockEditorStore ); + const { + updateSettings, + updateBlockListSettings, + __unstableSetTemporarilyEditingAsBlocks, + } = useDispatch( blockEditorStore ); + const isContentLocked = + ! isLockedByParent && templateLock === 'contentOnly'; + const { __unstableMarkNextChangeAsNotPersistent, updateBlockAttributes } = + useDispatch( blockEditorStore ); - const stopEditingAsBlock = useCallback( () => { - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( props.clientId, { - templateLock: 'contentOnly', - } ); - updateBlockListSettings( props.clientId, { - ...getBlockListSettings( props.clientId ), - templateLock: 'contentOnly', - } ); - updateSettings( { focusMode: focusModeToRevert.current } ); - __unstableSetTemporarilyEditingAsBlocks(); - }, [ - props.clientId, - updateSettings, - updateBlockListSettings, - getBlockListSettings, - __unstableMarkNextChangeAsNotPersistent, - updateBlockAttributes, - __unstableSetTemporarilyEditingAsBlocks, - ] ); + const stopEditingAsBlock = useCallback( () => { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( clientId, { + templateLock: 'contentOnly', + } ); + updateBlockListSettings( clientId, { + ...getBlockListSettings( clientId ), + templateLock: 'contentOnly', + } ); + updateSettings( { focusMode: focusModeToRevert.current } ); + __unstableSetTemporarilyEditingAsBlocks(); + }, [ + clientId, + updateSettings, + updateBlockListSettings, + getBlockListSettings, + __unstableMarkNextChangeAsNotPersistent, + updateBlockAttributes, + __unstableSetTemporarilyEditingAsBlocks, + ] ); - if ( ! isContentLocked && ! isEditingAsBlocks ) { - return ; - } + if ( ! isContentLocked && ! isEditingAsBlocks ) { + return null; + } - const showStopEditingAsBlocks = isEditingAsBlocks && ! isContentLocked; - const showStartEditingAsBlocks = - ! isEditingAsBlocks && isContentLocked && props.isSelected; + const showStopEditingAsBlocks = isEditingAsBlocks && ! isContentLocked; + const showStartEditingAsBlocks = + ! isEditingAsBlocks && isContentLocked && isSelected; - return ( - <> - { showStopEditingAsBlocks && ( - <> - - - { - stopEditingAsBlock(); - } } - > - { __( 'Done' ) } - - - - ) } - { showStartEditingAsBlocks && ( - - { ( { onClose } ) => ( - { - __unstableMarkNextChangeAsNotPersistent(); - updateBlockAttributes( props.clientId, { - templateLock: undefined, - } ); - updateBlockListSettings( props.clientId, { - ...getBlockListSettings( - props.clientId - ), - templateLock: false, - } ); - focusModeToRevert.current = - getSettings().focusMode; - updateSettings( { focusMode: true } ); - __unstableSetTemporarilyEditingAsBlocks( - props.clientId - ); - onClose(); - } } - > - { __( 'Modify' ) } - - ) } - - ) } - - - ); - }, - 'withContentLockControls' -); + return ( + <> + { showStopEditingAsBlocks && ( + <> + + + { + stopEditingAsBlock(); + } } + > + { __( 'Done' ) } + + + + ) } + { showStartEditingAsBlocks && ( + + { ( { onClose } ) => ( + { + __unstableMarkNextChangeAsNotPersistent(); + updateBlockAttributes( clientId, { + templateLock: undefined, + } ); + updateBlockListSettings( clientId, { + ...getBlockListSettings( clientId ), + templateLock: false, + } ); + focusModeToRevert.current = + getSettings().focusMode; + updateSettings( { focusMode: true } ); + __unstableSetTemporarilyEditingAsBlocks( + clientId + ); + onClose(); + } } + > + { __( 'Modify' ) } + + ) } + + ) } + + ); +} -addFilter( - 'editor.BlockEdit', - 'core/content-lock-ui/with-block-controls', - withContentLockControls -); +export default { + edit: ContentLockControlsPure, + hasSupport() { + return true; + }, +}; diff --git a/packages/block-editor/src/hooks/custom-class-name.js b/packages/block-editor/src/hooks/custom-class-name.js index 8a3becc869142..331edd9ef214a 100644 --- a/packages/block-editor/src/hooks/custom-class-name.js +++ b/packages/block-editor/src/hooks/custom-class-name.js @@ -10,7 +10,6 @@ import { addFilter } from '@wordpress/hooks'; import { TextControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { hasBlockSupport } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; /** * Internal dependencies @@ -39,7 +38,7 @@ export function addAttribute( settings ) { return settings; } -function CustomClassNameControls( { attributes, setAttributes } ) { +function CustomClassNameControlsPure( { className, setAttributes } ) { const blockEditingMode = useBlockEditingMode(); if ( blockEditingMode !== 'default' ) { return null; @@ -52,7 +51,7 @@ function CustomClassNameControls( { attributes, setAttributes } ) { __next40pxDefaultSize autoComplete="off" label={ __( 'Additional CSS class(es)' ) } - value={ attributes.className || '' } + value={ className || '' } onChange={ ( nextValue ) => { setAttributes( { className: nextValue !== '' ? nextValue : undefined, @@ -64,39 +63,13 @@ function CustomClassNameControls( { attributes, setAttributes } ) { ); } -/** - * Override the default edit UI to include a new block inspector control for - * assigning the custom class name, if block supports custom class name. - * The control is displayed within the Advanced panel in the block inspector. - * - * @param {Component} BlockEdit Original component. - * - * @return {Component} Wrapped component. - */ -export const withCustomClassNameControls = createHigherOrderComponent( - ( BlockEdit ) => { - return ( props ) => { - const hasCustomClassName = hasBlockSupport( - props.name, - 'customClassName', - true - ); - - return ( - <> - - { hasCustomClassName && props.isSelected && ( - - ) } - - ); - }; +export default { + edit: CustomClassNameControlsPure, + attributeKeys: [ 'className' ], + hasSupport( name ) { + return hasBlockSupport( name, 'customClassName', true ); }, - 'withCustomClassNameControls' -); +}; /** * Override props assigned to save component to inject the className, if block @@ -167,11 +140,6 @@ addFilter( 'core/editor/custom-class-name/attribute', addAttribute ); -addFilter( - 'editor.BlockEdit', - 'core/editor/custom-class-name/with-inspector-controls', - withCustomClassNameControls -); addFilter( 'blocks.getSaveContent.extraProps', 'core/editor/custom-class-name/save-props', diff --git a/packages/block-editor/src/hooks/custom-fields.js b/packages/block-editor/src/hooks/custom-fields.js index adb9df15824a7..9b677933adc13 100644 --- a/packages/block-editor/src/hooks/custom-fields.js +++ b/packages/block-editor/src/hooks/custom-fields.js @@ -5,7 +5,6 @@ import { addFilter } from '@wordpress/hooks'; import { PanelBody, TextControl } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { hasBlockSupport } from '@wordpress/blocks'; -import { createHigherOrderComponent } from '@wordpress/compose'; /** * Internal dependencies @@ -34,7 +33,7 @@ function addAttribute( settings ) { return settings; } -function CustomFieldsControl( props ) { +function CustomFieldsControlPure( { name, connections, setAttributes } ) { const blockEditingMode = useBlockEditingMode(); if ( blockEditingMode !== 'default' ) { return null; @@ -44,8 +43,8 @@ function CustomFieldsControl( props ) { // attribute to use for the connection. Only the `content` attribute // of the paragraph block and the `url` attribute of the image block are supported. let attributeName; - if ( props.name === 'core/paragraph' ) attributeName = 'content'; - if ( props.name === 'core/image' ) attributeName = 'url'; + if ( name === 'core/paragraph' ) attributeName = 'content'; + if ( name === 'core/image' ) attributeName = 'url'; return ( @@ -55,19 +54,17 @@ function CustomFieldsControl( props ) { autoComplete="off" label={ __( 'Custom field meta_key' ) } value={ - props.attributes?.connections?.attributes?.[ - attributeName - ]?.value || '' + connections?.attributes?.[ attributeName ]?.value || '' } onChange={ ( nextValue ) => { if ( nextValue === '' ) { - props.setAttributes( { + setAttributes( { connections: undefined, [ attributeName ]: undefined, placeholder: undefined, } ); } else { - props.setAttributes( { + setAttributes( { connections: { attributes: { // The attributeName will be either `content` or `url`. @@ -93,50 +90,26 @@ function CustomFieldsControl( props ) { ); } -/** - * Override the default edit UI to include a new block inspector control for - * assigning a connection to blocks that has support for connections. - * Currently, only the `core/paragraph` block is supported and there is only a relation - * between paragraph content and a custom field. - * - * @param {Component} BlockEdit Original component. - * - * @return {Component} Wrapped component. - */ -const withCustomFieldsControls = createHigherOrderComponent( ( BlockEdit ) => { - return ( props ) => { - const hasCustomFieldsSupport = hasBlockSupport( - props.name, - '__experimentalConnections', - false - ); - - // Check if the current block is a paragraph or image block. - // Currently, only these two blocks are supported. - if ( ! [ 'core/paragraph', 'core/image' ].includes( props.name ) ) { - return ; - } - +export default { + edit: CustomFieldsControlPure, + attributeKeys: [ 'connections' ], + hasSupport( name ) { return ( - <> - - { hasCustomFieldsSupport && props.isSelected && ( - - ) } - + hasBlockSupport( name, '__experimentalConnections', false ) && + // Check if the current block is a paragraph or image block. + // Currently, only these two blocks are supported. + [ 'core/paragraph', 'core/image' ].includes( name ) ); - }; -}, 'withCustomFieldsControls' ); + }, +}; -if ( window.__experimentalConnections ) { +if ( + window.__experimentalConnections || + window.__experimentalPatternPartialSyncing +) { addFilter( 'blocks.registerBlockType', 'core/editor/connections/attribute', addAttribute ); - addFilter( - 'editor.BlockEdit', - 'core/editor/connections/with-inspector-controls', - withCustomFieldsControls - ); } diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js index 084763f0c21b1..4dcba5c4abef6 100644 --- a/packages/block-editor/src/hooks/dimensions.js +++ b/packages/block-editor/src/hooks/dimensions.js @@ -2,9 +2,10 @@ * WordPress dependencies */ import { useState, useEffect, useCallback } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { getBlockSupport } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; +import { pure } from '@wordpress/compose'; /** * Internal dependencies @@ -19,7 +20,7 @@ import { PaddingVisualizer } from './padding'; import { store as blockEditorStore } from '../store'; import { unlock } from '../lock-unlock'; -import { cleanEmptyObject, useBlockSettings } from './utils'; +import { cleanEmptyObject } from './utils'; export const DIMENSIONS_SUPPORT_KEY = 'dimensions'; export const SPACING_SUPPORT_KEY = 'spacing'; @@ -65,17 +66,13 @@ function DimensionsInspectorControl( { children, resetAllFilter } ) { ); } -export function DimensionsPanel( props ) { - const { - clientId, - name, - attributes, - setAttributes, - __unstableParentLayout, - } = props; - const settings = useBlockSettings( name, __unstableParentLayout ); +function DimensionsPanelPure( { clientId, name, setAttributes, settings } ) { const isEnabled = useHasDimensionsPanel( settings ); - const value = attributes.style; + const value = useSelect( + ( select ) => + select( blockEditorStore ).getBlockAttributes( clientId )?.style, + [ clientId ] + ); const [ visualizedProperty, setVisualizedProperty ] = useVisualizer(); const onChange = ( newStyle ) => { setAttributes( { @@ -87,11 +84,11 @@ export function DimensionsPanel( props ) { return null; } - const defaultDimensionsControls = getBlockSupport( props.name, [ + const defaultDimensionsControls = getBlockSupport( name, [ DIMENSIONS_SUPPORT_KEY, '__experimentalDefaultControls', ] ); - const defaultSpacingControls = getBlockSupport( props.name, [ + const defaultSpacingControls = getBlockSupport( name, [ SPACING_SUPPORT_KEY, '__experimentalDefaultControls', ] ); @@ -114,19 +111,26 @@ export function DimensionsPanel( props ) { { !! settings?.spacing?.padding && ( ) } { !! settings?.spacing?.margin && ( ) } ); } +// We don't want block controls to re-render when typing inside a block. `pure` +// will prevent re-renders unless props change, so only pass the needed props +// and not the whole attributes object. +export const DimensionsPanel = pure( DimensionsPanelPure ); + /** * @deprecated */ diff --git a/packages/block-editor/src/hooks/duotone.js b/packages/block-editor/src/hooks/duotone.js index 5442e394e68c7..0df0d50d64457 100644 --- a/packages/block-editor/src/hooks/duotone.js +++ b/packages/block-editor/src/hooks/duotone.js @@ -1,7 +1,6 @@ /** * External dependencies */ -import classnames from 'classnames'; import { extend } from 'colord'; import namesPlugin from 'colord/plugins/names'; @@ -13,7 +12,7 @@ import { getBlockType, hasBlockSupport, } from '@wordpress/blocks'; -import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; +import { useInstanceId } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { useMemo, useEffect } from '@wordpress/element'; @@ -95,8 +94,7 @@ export function getDuotonePresetFromColors( colors, duotonePalette ) { return preset ? `var:preset|duotone|${ preset.slug }` : undefined; } -function DuotonePanel( { attributes, setAttributes, name } ) { - const style = attributes?.style; +function DuotonePanelPure( { style, setAttributes, name } ) { const duotoneStyle = style?.color?.duotone; const settings = useBlockSettings( name ); const blockEditingMode = useBlockEditingMode(); @@ -176,6 +174,16 @@ function DuotonePanel( { attributes, setAttributes, name } ) { ); } +export default { + shareWithChildBlocks: true, + edit: DuotonePanelPure, + useBlockProps, + attributeKeys: [ 'style' ], + hasSupport( name ) { + return hasBlockSupport( name, 'filter.duotone' ); + }, +}; + /** * Filters registered block settings, extending attributes to include * the `duotone` attribute. @@ -204,38 +212,7 @@ function addDuotoneAttributes( settings ) { return settings; } -/** - * Override the default edit UI to include toolbar controls for duotone if the - * block supports duotone. - * - * @param {Function} BlockEdit Original component. - * - * @return {Function} Wrapped component. - */ -const withDuotoneControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - // Previous `color.__experimentalDuotone` support flag is migrated via - // block_type_metadata_settings filter in `lib/block-supports/duotone.php`. - const hasDuotoneSupport = hasBlockSupport( - props.name, - 'filter.duotone' - ); - - // CAUTION: code added before this line will be executed - // for all blocks, not just those that support duotone. Code added - // above this line should be carefully evaluated for its impact on - // performance. - return ( - <> - { hasDuotoneSupport && } - - - ); - }, - 'withDuotoneControls' -); - -function DuotoneStyles( { +function useDuotoneStyles( { clientId, id: filterId, selector: duotoneSelector, @@ -333,103 +310,69 @@ function DuotoneStyles( { blockElement.style.display = display; } }, [ isValidFilter, blockElement ] ); - - return null; } -/** - * Override the default block element to include duotone styles. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -const withDuotoneStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const id = useInstanceId( BlockListBlock ); - - const selector = useMemo( () => { - const blockType = getBlockType( props.name ); - - if ( blockType ) { - // Backwards compatibility for `supports.color.__experimentalDuotone` - // is provided via the `block_type_metadata_settings` filter. If - // `supports.filter.duotone` has not been set and the - // experimental property has been, the experimental property - // value is copied into `supports.filter.duotone`. - const duotoneSupport = getBlockSupport( - blockType, - 'filter.duotone', - false - ); - if ( ! duotoneSupport ) { - return null; - } - - // If the experimental duotone support was set, that value is - // to be treated as a selector and requires scoping. - const experimentalDuotone = getBlockSupport( - blockType, - 'color.__experimentalDuotone', - false - ); - if ( experimentalDuotone ) { - const rootSelector = getBlockCSSSelector( blockType ); - return typeof experimentalDuotone === 'string' - ? scopeSelector( rootSelector, experimentalDuotone ) - : rootSelector; - } - - // Regular filter.duotone support uses filter.duotone selectors with fallbacks. - return getBlockCSSSelector( blockType, 'filter.duotone', { - fallback: true, - } ); +function useBlockProps( { name, style } ) { + const id = useInstanceId( useBlockProps ); + const selector = useMemo( () => { + const blockType = getBlockType( name ); + + if ( blockType ) { + // Backwards compatibility for `supports.color.__experimentalDuotone` + // is provided via the `block_type_metadata_settings` filter. If + // `supports.filter.duotone` has not been set and the + // experimental property has been, the experimental property + // value is copied into `supports.filter.duotone`. + const duotoneSupport = getBlockSupport( + blockType, + 'filter.duotone', + false + ); + if ( ! duotoneSupport ) { + return null; } - }, [ props.name ] ); - - const attribute = props?.attributes?.style?.color?.duotone; - - const filterClass = `wp-duotone-${ id }`; - - const shouldRender = selector && attribute; - - const className = shouldRender - ? classnames( props?.className, filterClass ) - : props?.className; - - // CAUTION: code added before this line will be executed - // for all blocks, not just those that support duotone. Code added - // above this line should be carefully evaluated for its impact on - // performance. - return ( - <> - { shouldRender && ( - - ) } - - - ); - }, - 'withDuotoneStyles' -); + + // If the experimental duotone support was set, that value is + // to be treated as a selector and requires scoping. + const experimentalDuotone = getBlockSupport( + blockType, + 'color.__experimentalDuotone', + false + ); + if ( experimentalDuotone ) { + const rootSelector = getBlockCSSSelector( blockType ); + return typeof experimentalDuotone === 'string' + ? scopeSelector( rootSelector, experimentalDuotone ) + : rootSelector; + } + + // Regular filter.duotone support uses filter.duotone selectors with fallbacks. + return getBlockCSSSelector( blockType, 'filter.duotone', { + fallback: true, + } ); + } + }, [ name ] ); + + const attribute = style?.color?.duotone; + + const filterClass = `wp-duotone-${ id }`; + + const shouldRender = selector && attribute; + + useDuotoneStyles( { + clientId: id, + id: filterClass, + selector, + attribute, + } ); + + return { + className: shouldRender ? filterClass : '', + }; +} addFilter( 'blocks.registerBlockType', 'core/editor/duotone/add-attributes', addDuotoneAttributes ); -addFilter( - 'editor.BlockEdit', - 'core/editor/duotone/with-editor-controls', - withDuotoneControls -); -addFilter( - 'editor.BlockListBlock', - 'core/editor/duotone/with-styles', - withDuotoneStyles -); diff --git a/packages/block-editor/src/hooks/font-family.js b/packages/block-editor/src/hooks/font-family.js index 0988b285564d3..36266d59adcf2 100644 --- a/packages/block-editor/src/hooks/font-family.js +++ b/packages/block-editor/src/hooks/font-family.js @@ -74,31 +74,18 @@ function addSaveProps( props, blockType, attributes ) { return props; } -/** - * Filters registered block settings to expand the block edit wrapper - * by applying the desired styles and classnames. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -function addEditProps( settings ) { - if ( ! hasBlockSupport( settings, FONT_FAMILY_SUPPORT_KEY ) ) { - return settings; - } - - const existingGetEditWrapperProps = settings.getEditWrapperProps; - settings.getEditWrapperProps = ( attributes ) => { - let props = {}; - if ( existingGetEditWrapperProps ) { - props = existingGetEditWrapperProps( attributes ); - } - return addSaveProps( props, settings, attributes ); - }; - - return settings; +function useBlockProps( { name, fontFamily } ) { + return addSaveProps( {}, name, { fontFamily } ); } +export default { + useBlockProps, + attributeKeys: [ 'fontFamily' ], + hasSupport( name ) { + return hasBlockSupport( name, FONT_FAMILY_SUPPORT_KEY ); + }, +}; + /** * Resets the font family block support attribute. This can be used when * disabling the font family support controls for a block via a progressive @@ -122,9 +109,3 @@ addFilter( 'core/fontFamily/addSaveProps', addSaveProps ); - -addFilter( - 'blocks.registerBlockType', - 'core/fontFamily/addEditProps', - addEditProps -); diff --git a/packages/block-editor/src/hooks/font-size.js b/packages/block-editor/src/hooks/font-size.js index 146abe1d1f72f..b30fcc82d9946 100644 --- a/packages/block-editor/src/hooks/font-size.js +++ b/packages/block-editor/src/hooks/font-size.js @@ -4,7 +4,6 @@ import { addFilter } from '@wordpress/hooks'; import { hasBlockSupport } from '@wordpress/blocks'; import TokenList from '@wordpress/token-list'; -import { createHigherOrderComponent } from '@wordpress/compose'; import { select } from '@wordpress/data'; /** @@ -59,19 +58,23 @@ function addAttributes( settings ) { /** * Override props assigned to save component to inject font size. * - * @param {Object} props Additional props applied to save element. - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. + * @param {Object} props Additional props applied to save element. + * @param {Object} blockNameOrType Block type. + * @param {Object} attributes Block attributes. * * @return {Object} Filtered props applied to save element. */ -function addSaveProps( props, blockType, attributes ) { - if ( ! hasBlockSupport( blockType, FONT_SIZE_SUPPORT_KEY ) ) { +function addSaveProps( props, blockNameOrType, attributes ) { + if ( ! hasBlockSupport( blockNameOrType, FONT_SIZE_SUPPORT_KEY ) ) { return props; } if ( - shouldSkipSerialization( blockType, TYPOGRAPHY_SUPPORT_KEY, 'fontSize' ) + shouldSkipSerialization( + blockNameOrType, + TYPOGRAPHY_SUPPORT_KEY, + 'fontSize' + ) ) { return props; } @@ -85,31 +88,6 @@ function addSaveProps( props, blockType, attributes ) { return props; } -/** - * Filters registered block settings to expand the block edit wrapper - * by applying the desired styles and classnames. - * - * @param {Object} settings Original block settings. - * - * @return {Object} Filtered block settings. - */ -function addEditProps( settings ) { - if ( ! hasBlockSupport( settings, FONT_SIZE_SUPPORT_KEY ) ) { - return settings; - } - - const existingGetEditWrapperProps = settings.getEditWrapperProps; - settings.getEditWrapperProps = ( attributes ) => { - let props = {}; - if ( existingGetEditWrapperProps ) { - props = existingGetEditWrapperProps( attributes ); - } - return addSaveProps( props, settings, attributes ); - }; - - return settings; -} - /** * Inspector control panel containing the font size related configuration * @@ -175,62 +153,69 @@ export function useIsFontSizeDisabled( { name: blockName } = {} ) { ); } -/** - * Add inline styles for font sizes. - * Ideally, this is not needed and themes load the font-size classes on the - * editor. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -const withFontSizeInlineStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const [ fontSizes ] = useSettings( 'typography.fontSizes' ); - const { - name: blockName, - attributes: { fontSize, style }, - wrapperProps, - } = props; +function useBlockProps( { name, fontSize, style } ) { + const [ fontSizes ] = useSettings( 'typography.fontSizes' ); - // Only add inline styles if the block supports font sizes, - // doesn't skip serialization of font sizes, - // doesn't already have an inline font size, - // and does have a class to extract the font size from. - if ( - ! hasBlockSupport( blockName, FONT_SIZE_SUPPORT_KEY ) || - shouldSkipSerialization( - blockName, - TYPOGRAPHY_SUPPORT_KEY, - 'fontSize' - ) || - ! fontSize || - style?.typography?.fontSize - ) { - return ; - } + // Only add inline styles if the block supports font sizes, + // doesn't skip serialization of font sizes, + // doesn't already have an inline font size, + // and does have a class to extract the font size from. + if ( + ! hasBlockSupport( name, FONT_SIZE_SUPPORT_KEY ) || + shouldSkipSerialization( name, TYPOGRAPHY_SUPPORT_KEY, 'fontSize' ) || + ! fontSize + ) { + return; + } - const fontSizeValue = getFontSize( - fontSizes, - fontSize, - style?.typography?.fontSize - ).size; + let props = {}; - const newProps = { - ...props, - wrapperProps: { - ...wrapperProps, - style: { - fontSize: fontSizeValue, - ...wrapperProps?.style, - }, + if ( ! style?.typography?.fontSize ) { + props = { + style: { + fontSize: getFontSize( + fontSizes, + fontSize, + style?.typography?.fontSize + ).size, }, }; + } + + // TODO: This sucks! We should be using useSetting( 'typography.fluid' ) + // or even useSelect( blockEditorStore ). We can't do either here + // because getEditWrapperProps is a plain JavaScript function called by + // BlockListBlock and not a React component rendered within + // BlockListContext.Provider. If we set fontSize using editor. + // BlockListBlock instead of using getEditWrapperProps then the value is + // clobbered when the core/style/addEditProps filter runs. + + // TODO: We can do the thing above now. + const fluidTypographySettings = getFluidTypographyOptionsFromSettings( + select( blockEditorStore ).getSettings().__experimentalFeatures + ); + + if ( fontSize ) { + props = { + style: { + fontSize: getTypographyFontSizeValue( + { size: fontSize }, + fluidTypographySettings + ), + }, + }; + } - return ; + return addSaveProps( props, name, { fontSize } ); +} + +export default { + useBlockProps, + attributeKeys: [ 'fontSize', 'style' ], + hasSupport( name ) { + return hasBlockSupport( name, FONT_SIZE_SUPPORT_KEY ); }, - 'withFontSizeInlineStyles' -); +}; const MIGRATION_PATHS = { fontSize: [ [ 'fontSize' ], [ 'style', 'typography', 'fontSize' ] ], @@ -254,70 +239,6 @@ function addTransforms( result, source, index, results ) { ); } -/** - * Allow custom font sizes to appear fluid when fluid typography is enabled at - * the theme level. - * - * Adds a custom getEditWrapperProps() callback to all block types that support - * font sizes. Then, if fluid typography is enabled, this callback will swap any - * custom font size in style.fontSize with a fluid font size (i.e. one that uses - * clamp()). - * - * It's important that this hook runs after 'core/style/addEditProps' sets - * style.fontSize as otherwise fontSize will be overwritten. - * - * @param {Object} blockType Block settings object. - */ -function addEditPropsForFluidCustomFontSizes( blockType ) { - if ( - ! hasBlockSupport( blockType, FONT_SIZE_SUPPORT_KEY ) || - shouldSkipSerialization( blockType, TYPOGRAPHY_SUPPORT_KEY, 'fontSize' ) - ) { - return blockType; - } - - const existingGetEditWrapperProps = blockType.getEditWrapperProps; - - blockType.getEditWrapperProps = ( attributes ) => { - const wrapperProps = existingGetEditWrapperProps - ? existingGetEditWrapperProps( attributes ) - : {}; - - const fontSize = wrapperProps?.style?.fontSize; - - // TODO: This sucks! We should be using useSetting( 'typography.fluid' ) - // or even useSelect( blockEditorStore ). We can't do either here - // because getEditWrapperProps is a plain JavaScript function called by - // BlockListBlock and not a React component rendered within - // BlockListContext.Provider. If we set fontSize using editor. - // BlockListBlock instead of using getEditWrapperProps then the value is - // clobbered when the core/style/addEditProps filter runs. - const fluidTypographySettings = getFluidTypographyOptionsFromSettings( - select( blockEditorStore ).getSettings().__experimentalFeatures - ); - const newFontSize = fontSize - ? getTypographyFontSizeValue( - { size: fontSize }, - fluidTypographySettings - ) - : null; - - if ( newFontSize === null ) { - return wrapperProps; - } - - return { - ...wrapperProps, - style: { - ...wrapperProps?.style, - fontSize: newFontSize, - }, - }; - }; - - return blockType; -} - addFilter( 'blocks.registerBlockType', 'core/font/addAttribute', @@ -330,25 +251,8 @@ addFilter( addSaveProps ); -addFilter( 'blocks.registerBlockType', 'core/font/addEditProps', addEditProps ); - -addFilter( - 'editor.BlockListBlock', - 'core/font-size/with-font-size-inline-styles', - withFontSizeInlineStyles -); - addFilter( 'blocks.switchToBlockType.transformedBlock', 'core/font-size/addTransforms', addTransforms ); - -addFilter( - 'blocks.registerBlockType', - 'core/font-size/addEditPropsForFluidCustomFontSizes', - addEditPropsForFluidCustomFontSizes, - // Run after 'core/style/addEditProps' so that the style object has already - // been translated into inline CSS. - 11 -); diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index c088216c0645c..ec0dba5efb2b6 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -1,27 +1,55 @@ /** * Internal dependencies */ +import { createBlockEditFilter, createBlockListBlockFilter } from './utils'; import './compat'; -import './align'; +import align from './align'; import './lock'; -import './anchor'; +import anchor from './anchor'; import './aria-label'; -import './custom-class-name'; +import customClassName from './custom-class-name'; import './generated-class-name'; -import './style'; +import style from './style'; import './settings'; -import './color'; -import './duotone'; -import './font-family'; -import './font-size'; -import './border'; -import './position'; -import './layout'; +import color from './color'; +import duotone from './duotone'; +import fontFamily from './font-family'; +import fontSize from './font-size'; +import border from './border'; +import position from './position'; +import layout from './layout'; +import childLayout from './layout-child'; import './content-lock-ui'; import './metadata'; -import './custom-fields'; -import './block-hooks'; -import './block-renaming'; +import customFields from './custom-fields'; +import blockHooks from './block-hooks'; +import blockRenaming from './block-renaming'; + +createBlockEditFilter( + [ + align, + anchor, + customClassName, + style, + duotone, + position, + layout, + window.__experimentalConnections ? customFields : null, + blockHooks, + blockRenaming, + ].filter( Boolean ) +); +createBlockListBlockFilter( [ + align, + style, + color, + duotone, + fontFamily, + fontSize, + border, + position, + childLayout, +] ); export { useCustomSides } from './dimensions'; export { useLayoutClasses, useLayoutStyles } from './layout'; diff --git a/packages/block-editor/src/hooks/index.native.js b/packages/block-editor/src/hooks/index.native.js index 42bda25bfe1cc..3f1a4473c1389 100644 --- a/packages/block-editor/src/hooks/index.native.js +++ b/packages/block-editor/src/hooks/index.native.js @@ -1,16 +1,19 @@ /** * Internal dependencies */ +import { createBlockEditFilter } from './utils'; import './compat'; -import './align'; -import './anchor'; +import align from './align'; +import anchor from './anchor'; import './custom-class-name'; import './generated-class-name'; -import './style'; +import style from './style'; import './color'; import './font-size'; import './layout'; +createBlockEditFilter( [ align, anchor, style ] ); + export { getBorderClassesAndStyles, useBorderProps } from './use-border-props'; export { getColorClassesAndStyles, useColorProps } from './use-color-props'; export { getSpacingClassesAndStyles } from './use-spacing-props'; diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js new file mode 100644 index 0000000000000..58a75a568c40d --- /dev/null +++ b/packages/block-editor/src/hooks/layout-child.js @@ -0,0 +1,53 @@ +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../store'; +import { useStyleOverride } from './utils'; + +function useBlockPropsChildLayoutStyles( { style } ) { + const shouldRenderChildLayoutStyles = useSelect( ( select ) => { + return ! select( blockEditorStore ).getSettings().disableLayoutStyles; + } ); + const layout = style?.layout ?? {}; + const { selfStretch, flexSize } = layout; + const id = useInstanceId( useBlockPropsChildLayoutStyles ); + const selector = `.wp-container-content-${ id }`; + + let css = ''; + if ( shouldRenderChildLayoutStyles ) { + if ( selfStretch === 'fixed' && flexSize ) { + css = `${ selector } { + flex-basis: ${ flexSize }; + box-sizing: border-box; + }`; + } else if ( selfStretch === 'fill' ) { + css = `${ selector } { + flex-grow: 1; + }`; + } + } + + useStyleOverride( { css } ); + + // Only attach a container class if there is generated CSS to be attached. + if ( ! css ) { + return; + } + + // Attach a `wp-container-content` id-based classname. + return { className: `wp-container-content-${ id }` }; +} + +export default { + useBlockProps: useBlockPropsChildLayoutStyles, + attributeKeys: [ 'style' ], + hasSupport() { + return true; + }, +}; diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index 8120de137f979..18bb46a87a1b8 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -133,12 +133,11 @@ export function useLayoutStyles( blockAttributes = {}, blockName, selector ) { return css; } -function LayoutPanel( { setAttributes, attributes, name: blockName } ) { +function LayoutPanelPure( { layout, setAttributes, name: blockName } ) { const settings = useBlockSettings( blockName ); // Block settings come from theme.json under settings.[blockName]. const { layout: layoutSettings } = settings; // Layout comes from block attributes. - const { layout } = attributes; const [ defaultThemeLayout ] = useSettings( 'layout' ); const { themeSupportsLayout } = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); @@ -287,6 +286,15 @@ function LayoutPanel( { setAttributes, attributes, name: blockName } ) { ); } +export default { + shareWithChildBlocks: true, + edit: LayoutPanelPure, + attributeKeys: [ 'layout' ], + hasSupport( name ) { + return hasLayoutBlockSupport( name ); + }, +}; + function LayoutTypeSwitcher( { type, onChange } ) { return ( @@ -328,25 +336,6 @@ export function addAttribute( settings ) { return settings; } -/** - * Override the default edit UI to include layout controls - * - * @param {Function} BlockEdit Original component. - * - * @return {Function} Wrapped component. - */ -export const withLayoutControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const supportLayout = hasLayoutBlockSupport( props.name ); - - return [ - supportLayout && , - , - ]; - }, - 'withLayoutControls' -); - function BlockWithLayoutStyles( { block: BlockListBlock, props } ) { const { name, attributes } = props; const id = useInstanceId( BlockListBlock ); @@ -359,8 +348,9 @@ function BlockWithLayoutStyles( { block: BlockListBlock, props } ) { : layout || defaultBlockLayout || {}; const layoutClasses = useLayoutClasses( attributes, name ); + const selectorPrefix = `wp-container-${ kebabCase( name ) }-layout-`; // Higher specificity to override defaults from theme.json. - const selector = `.wp-container-${ id }.wp-container-${ id }`; + const selector = `.${ selectorPrefix }${ id }.${ selectorPrefix }${ id }`; const [ blockGapSupport ] = useSettings( 'spacing.blockGap' ); const hasBlockGapSupport = blockGapSupport !== null; @@ -378,7 +368,7 @@ function BlockWithLayoutStyles( { block: BlockListBlock, props } ) { // Attach a `wp-container-` id-based class name as well as a layout class name such as `is-layout-flex`. const layoutClassNames = classnames( { - [ `wp-container-${ id }` ]: !! css, // Only attach a container class if there is generated CSS to be attached. + [ `${ selectorPrefix }${ id }` ]: !! css, // Only attach a container class if there is generated CSS to be attached. }, layoutClasses ); @@ -427,75 +417,6 @@ export const withLayoutStyles = createHigherOrderComponent( 'withLayoutStyles' ); -function BlockWithChildLayoutStyles( { block: BlockListBlock, props } ) { - const layout = props.attributes.style?.layout ?? {}; - const { selfStretch, flexSize } = layout; - - const id = useInstanceId( BlockListBlock ); - const selector = `.wp-container-content-${ id }`; - - let css = ''; - if ( selfStretch === 'fixed' && flexSize ) { - css = `${ selector } { - flex-basis: ${ flexSize }; - box-sizing: border-box; - }`; - } else if ( selfStretch === 'fill' ) { - css = `${ selector } { - flex-grow: 1; - }`; - } - - // Attach a `wp-container-content` id-based classname. - const className = classnames( props.className, { - [ `wp-container-content-${ id }` ]: !! css, // Only attach a container class if there is generated CSS to be attached. - } ); - - useStyleOverride( { css } ); - - return ; -} - -/** - * Override the default block element to add the child layout styles. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -export const withChildLayoutStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const layout = props.attributes.style?.layout ?? {}; - const { selfStretch, flexSize } = layout; - const hasChildLayout = selfStretch || flexSize; - - const shouldRenderChildLayoutStyles = useSelect( - ( select ) => { - // The callback returns early to avoid block editor subscription. - if ( ! hasChildLayout ) { - return false; - } - - return ! select( blockEditorStore ).getSettings() - .disableLayoutStyles; - }, - [ hasChildLayout ] - ); - - if ( ! shouldRenderChildLayoutStyles ) { - return ; - } - - return ( - - ); - }, - 'withChildLayoutStyles' -); - addFilter( 'blocks.registerBlockType', 'core/layout/addAttribute', @@ -506,13 +427,3 @@ addFilter( 'core/editor/layout/with-layout-styles', withLayoutStyles ); -addFilter( - 'editor.BlockListBlock', - 'core/editor/layout/with-child-layout-styles', - withChildLayoutStyles -); -addFilter( - 'editor.BlockEdit', - 'core/editor/layout/with-inspector-controls', - withLayoutControls -); diff --git a/packages/block-editor/src/hooks/padding.js b/packages/block-editor/src/hooks/padding.js index b6e4e50e30f9c..ca4436153d122 100644 --- a/packages/block-editor/src/hooks/padding.js +++ b/packages/block-editor/src/hooks/padding.js @@ -16,11 +16,11 @@ function getComputedCSS( element, property ) { .getPropertyValue( property ); } -export function PaddingVisualizer( { clientId, attributes, forceShow } ) { +export function PaddingVisualizer( { clientId, value, forceShow } ) { const blockElement = useBlockElement( clientId ); const [ style, setStyle ] = useState(); - const padding = attributes?.style?.spacing?.padding; + const padding = value?.spacing?.padding; useEffect( () => { if ( diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js index 710dbfaf5ace0..5017cb34fc18b 100644 --- a/packages/block-editor/src/hooks/position.js +++ b/packages/block-editor/src/hooks/position.js @@ -12,10 +12,9 @@ import { BaseControl, privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; +import { useInstanceId } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { useMemo, Platform } from '@wordpress/element'; -import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies @@ -207,14 +206,12 @@ export function useIsPositionDisabled( { name: blockName } = {} ) { * * @return {Element} Position panel. */ -export function PositionPanel( props ) { - const { - attributes: { style = {} }, - clientId, - name: blockName, - setAttributes, - } = props; - +export function PositionPanelPure( { + style = {}, + clientId, + name: blockName, + setAttributes, +} ) { const allowFixed = hasFixedPositionSupport( blockName ); const allowSticky = hasStickyPositionSupport( blockName ); const value = style?.position?.type; @@ -316,89 +313,52 @@ export function PositionPanel( props ) { } ); } -/** - * Override the default edit UI to include position controls. - * - * @param {Function} BlockEdit Original component. - * - * @return {Function} Wrapped component. - */ -export const withPositionControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const { name: blockName } = props; - const positionSupport = hasBlockSupport( - blockName, - POSITION_SUPPORT_KEY - ); +export default { + edit: function Edit( props ) { const isPositionDisabled = useIsPositionDisabled( props ); - const showPositionControls = positionSupport && ! isPositionDisabled; - - return [ - showPositionControls && ( - - ), - , - ]; + if ( isPositionDisabled ) { + return null; + } + return ; + }, + useBlockProps, + attributeKeys: [ 'style' ], + hasSupport( name ) { + return hasBlockSupport( name, POSITION_SUPPORT_KEY ); }, - 'withPositionControls' -); +}; -/** - * Override the default block element to add the position styles. - * - * @param {Function} BlockListBlock Original component. - * - * @return {Function} Wrapped component. - */ -export const withPositionStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const { name, attributes } = props; - const hasPositionBlockSupport = hasBlockSupport( - name, - POSITION_SUPPORT_KEY - ); - const isPositionDisabled = useIsPositionDisabled( props ); - const allowPositionStyles = - hasPositionBlockSupport && ! isPositionDisabled; - - const id = useInstanceId( BlockListBlock ); - - // Higher specificity to override defaults in editor UI. - const positionSelector = `.wp-container-${ id }.wp-container-${ id }`; - - // Get CSS string for the current position values. - let css; - if ( allowPositionStyles ) { - css = - getPositionCSS( { - selector: positionSelector, - style: attributes?.style, - } ) || ''; - } +function useBlockProps( { name, style } ) { + const hasPositionBlockSupport = hasBlockSupport( + name, + POSITION_SUPPORT_KEY + ); + const isPositionDisabled = useIsPositionDisabled( { name } ); + const allowPositionStyles = hasPositionBlockSupport && ! isPositionDisabled; + + const id = useInstanceId( useBlockProps ); + + // Higher specificity to override defaults in editor UI. + const positionSelector = `.wp-container-${ id }.wp-container-${ id }`; + + // Get CSS string for the current position values. + let css; + if ( allowPositionStyles ) { + css = + getPositionCSS( { + selector: positionSelector, + style, + } ) || ''; + } - // Attach a `wp-container-` id-based class name. - const className = classnames( props?.className, { - [ `wp-container-${ id }` ]: allowPositionStyles && !! css, // Only attach a container class if there is generated CSS to be attached. - [ `is-position-${ attributes?.style?.position?.type }` ]: - allowPositionStyles && - !! css && - !! attributes?.style?.position?.type, - } ); + // Attach a `wp-container-` id-based class name. + const className = classnames( { + [ `wp-container-${ id }` ]: allowPositionStyles && !! css, // Only attach a container class if there is generated CSS to be attached. + [ `is-position-${ style?.position?.type }` ]: + allowPositionStyles && !! css && !! style?.position?.type, + } ); - useStyleOverride( { css } ); + useStyleOverride( { css } ); - return ; - }, - 'withPositionStyles' -); - -addFilter( - 'editor.BlockListBlock', - 'core/editor/position/with-position-styles', - withPositionStyles -); -addFilter( - 'editor.BlockEdit', - 'core/editor/position/with-inspector-controls', - withPositionControls -); + return { className }; +} diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index d74e10b0208f1..b6098969bebb5 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - /** * WordPress dependencies */ @@ -13,7 +8,7 @@ import { hasBlockSupport, __EXPERIMENTAL_ELEMENTS as ELEMENTS, } from '@wordpress/blocks'; -import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; +import { useInstanceId } from '@wordpress/compose'; import { getCSSRules, compileCSS } from '@wordpress/style-engine'; /** @@ -32,8 +27,11 @@ import { SPACING_SUPPORT_KEY, DimensionsPanel, } from './dimensions'; -import useDisplayBlockControls from '../components/use-display-block-controls'; -import { shouldSkipSerialization, useStyleOverride } from './utils'; +import { + shouldSkipSerialization, + useStyleOverride, + useBlockSettings, +} from './utils'; import { scopeSelector } from '../components/global-styles/utils'; import { useBlockEditingMode } from '../components/block-editing-mode'; @@ -271,20 +269,20 @@ export function omitStyle( style, paths, preserveReference = false ) { /** * Override props assigned to save component to inject the CSS variables definition. * - * @param {Object} props Additional props applied to save element. - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. - * @param {?Record} skipPaths An object of keys and paths to skip serialization. + * @param {Object} props Additional props applied to save element. + * @param {Object|string} blockNameOrType Block type. + * @param {Object} attributes Block attributes. + * @param {?Record} skipPaths An object of keys and paths to skip serialization. * * @return {Object} Filtered props applied to save element. */ export function addSaveProps( props, - blockType, + blockNameOrType, attributes, skipPaths = skipSerializationPathsSave ) { - if ( ! hasStyleSupport( blockType ) ) { + if ( ! hasStyleSupport( blockNameOrType ) ) { return props; } @@ -292,7 +290,7 @@ export function addSaveProps( Object.entries( skipPaths ).forEach( ( [ indicator, path ] ) => { const skipSerialization = skipSerializationPathsSaveChecks[ indicator ] || - getBlockSupport( blockType, indicator ); + getBlockSupport( blockNameOrType, indicator ); if ( skipSerialization === true ) { style = omitStyle( style, path ); @@ -314,71 +312,40 @@ export function addSaveProps( return props; } -/** - * Filters registered block settings to extend the block edit wrapper - * to apply the desired styles and classnames properly. - * - * @param {Object} settings Original block settings. - * - * @return {Object}.Filtered block settings. - */ -export function addEditProps( settings ) { - if ( ! hasStyleSupport( settings ) ) { - return settings; - } - - const existingGetEditWrapperProps = settings.getEditWrapperProps; - settings.getEditWrapperProps = ( attributes ) => { - let props = {}; - if ( existingGetEditWrapperProps ) { - props = existingGetEditWrapperProps( attributes ); - } - - return addSaveProps( - props, - settings, - attributes, - skipSerializationPathsEdit - ); +function BlockStyleControls( { + clientId, + name, + setAttributes, + __unstableParentLayout, +} ) { + const settings = useBlockSettings( name, __unstableParentLayout ); + const blockEditingMode = useBlockEditingMode(); + const passedProps = { + clientId, + name, + setAttributes, + settings, }; - - return settings; + if ( blockEditingMode !== 'default' ) { + return null; + } + return ( + <> + + + + + + + ); } -/** - * Override the default edit UI to include new inspector controls for - * all the custom styles configs. - * - * @param {Function} BlockEdit Original component. - * - * @return {Function} Wrapped component. - */ -export const withBlockStyleControls = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - if ( ! hasStyleSupport( props.name ) ) { - return ; - } - - const shouldDisplayControls = useDisplayBlockControls(); - const blockEditingMode = useBlockEditingMode(); - - return ( - <> - { shouldDisplayControls && blockEditingMode === 'default' && ( - <> - - - - - - - ) } - - - ); - }, - 'withBlockStyleControls' -); +export default { + edit: BlockStyleControls, + hasSupport: hasStyleSupport, + attributeKeys: [ 'style' ], + useBlockProps, +}; // Defines which element types are supported, including their hover styles or // any other elements that have been included under a single element type @@ -392,115 +359,96 @@ const elementTypes = [ }, ]; -/** - * Override the default block element to include elements styles. - * - * @param {Function} BlockListBlock Original component - * @return {Function} Wrapped component - */ -const withElementsStyles = createHigherOrderComponent( - ( BlockListBlock ) => ( props ) => { - const blockElementsContainerIdentifier = `wp-elements-${ useInstanceId( - BlockListBlock - ) }`; - - // The .editor-styles-wrapper selector is required on elements styles. As it is - // added to all other editor styles, not providing it causes reset and global - // styles to override element styles because of higher specificity. - const baseElementSelector = `.editor-styles-wrapper .${ blockElementsContainerIdentifier }`; - const blockElementStyles = props.attributes.style?.elements; - - const styles = useMemo( () => { - if ( ! blockElementStyles ) { +function useBlockProps( { name, style } ) { + const blockElementsContainerIdentifier = `wp-elements-${ useInstanceId( + useBlockProps + ) }`; + + // The .editor-styles-wrapper selector is required on elements styles. As it is + // added to all other editor styles, not providing it causes reset and global + // styles to override element styles because of higher specificity. + const baseElementSelector = `.editor-styles-wrapper .${ blockElementsContainerIdentifier }`; + const blockElementStyles = style?.elements; + + const styles = useMemo( () => { + if ( ! blockElementStyles ) { + return; + } + + const elementCSSRules = []; + + elementTypes.forEach( ( { elementType, pseudo, elements } ) => { + const skipSerialization = shouldSkipSerialization( + name, + COLOR_SUPPORT_KEY, + elementType + ); + + if ( skipSerialization ) { return; } - const elementCSSRules = []; + const elementStyles = blockElementStyles?.[ elementType ]; - elementTypes.forEach( ( { elementType, pseudo, elements } ) => { - const skipSerialization = shouldSkipSerialization( - props.name, - COLOR_SUPPORT_KEY, - elementType + // Process primary element type styles. + if ( elementStyles ) { + const selector = scopeSelector( + baseElementSelector, + ELEMENTS[ elementType ] ); - if ( skipSerialization ) { - return; - } - - const elementStyles = blockElementStyles?.[ elementType ]; - - // Process primary element type styles. - if ( elementStyles ) { - const selector = scopeSelector( - baseElementSelector, - ELEMENTS[ elementType ] - ); - - elementCSSRules.push( - compileCSS( elementStyles, { selector } ) - ); - - // Process any interactive states for the element type. - if ( pseudo ) { - pseudo.forEach( ( pseudoSelector ) => { - if ( elementStyles[ pseudoSelector ] ) { - elementCSSRules.push( - compileCSS( - elementStyles[ pseudoSelector ], - { - selector: scopeSelector( - baseElementSelector, - `${ ELEMENTS[ elementType ] }${ pseudoSelector }` - ), - } - ) - ); - } - } ); - } - } + elementCSSRules.push( + compileCSS( elementStyles, { selector } ) + ); - // Process related elements e.g. h1-h6 for headings - if ( elements ) { - elements.forEach( ( element ) => { - if ( blockElementStyles[ element ] ) { + // Process any interactive states for the element type. + if ( pseudo ) { + pseudo.forEach( ( pseudoSelector ) => { + if ( elementStyles[ pseudoSelector ] ) { elementCSSRules.push( - compileCSS( blockElementStyles[ element ], { + compileCSS( elementStyles[ pseudoSelector ], { selector: scopeSelector( baseElementSelector, - ELEMENTS[ element ] + `${ ELEMENTS[ elementType ] }${ pseudoSelector }` ), } ) ); } } ); } - } ); + } - return elementCSSRules.length > 0 - ? elementCSSRules.join( '' ) - : undefined; - }, [ baseElementSelector, blockElementStyles, props.name ] ); - - useStyleOverride( { css: styles } ); - - return ( - - ); - }, - 'withElementsStyles' -); + // Process related elements e.g. h1-h6 for headings + if ( elements ) { + elements.forEach( ( element ) => { + if ( blockElementStyles[ element ] ) { + elementCSSRules.push( + compileCSS( blockElementStyles[ element ], { + selector: scopeSelector( + baseElementSelector, + ELEMENTS[ element ] + ), + } ) + ); + } + } ); + } + } ); + + return elementCSSRules.length > 0 + ? elementCSSRules.join( '' ) + : undefined; + }, [ baseElementSelector, blockElementStyles, name ] ); + + useStyleOverride( { css: styles } ); + + return addSaveProps( + { className: blockElementsContainerIdentifier }, + name, + { style }, + skipSerializationPathsEdit + ); +} addFilter( 'blocks.registerBlockType', @@ -513,21 +461,3 @@ addFilter( 'core/style/addSaveProps', addSaveProps ); - -addFilter( - 'blocks.registerBlockType', - 'core/style/addEditProps', - addEditProps -); - -addFilter( - 'editor.BlockEdit', - 'core/style/with-block-controls', - withBlockStyleControls -); - -addFilter( - 'editor.BlockListBlock', - 'core/editor/with-elements-styles', - withElementsStyles -); diff --git a/packages/block-editor/src/hooks/test/align.js b/packages/block-editor/src/hooks/test/align.js index b928e6eafc8b2..73c55133a4b29 100644 --- a/packages/block-editor/src/hooks/test/align.js +++ b/packages/block-editor/src/hooks/test/align.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; - /** * WordPress dependencies */ @@ -12,20 +7,11 @@ import { registerBlockType, unregisterBlockType, } from '@wordpress/blocks'; -import { SlotFillProvider } from '@wordpress/components'; /** * Internal dependencies */ -import BlockControls from '../../components/block-controls'; -import BlockEdit from '../../components/block-edit'; -import BlockEditorProvider from '../../components/provider'; -import { - getValidAlignments, - withAlignmentControls, - withDataAlign, - addAssignedAlign, -} from '../align'; +import { getValidAlignments, addAssignedAlign } from '../align'; const noop = () => {}; @@ -157,169 +143,6 @@ describe( 'align', () => { } ); } ); - describe( 'withAlignControls', () => { - const componentProps = { - name: 'core/foo', - attributes: {}, - isSelected: true, - }; - - it( 'should do nothing if no valid alignments', () => { - registerBlockType( 'core/foo', blockSettings ); - - const EnhancedComponent = withAlignmentControls( - ( { wrapperProps } ) =>
- ); - - render( - - - - - - - ); - - expect( - screen.queryByRole( 'button', { - name: 'Align', - expanded: false, - } ) - ).not.toBeInTheDocument(); - } ); - - it( 'should render toolbar controls if valid alignments', () => { - registerBlockType( 'core/foo', { - ...blockSettings, - supports: { - align: true, - alignWide: false, - }, - } ); - - const EnhancedComponent = withAlignmentControls( - ( { wrapperProps } ) =>
- ); - - render( - - - - - - - ); - - expect( - screen.getAllByRole( 'button', { - name: 'Align', - expanded: false, - } ) - ).toHaveLength( 2 ); - } ); - } ); - - describe( 'withDataAlign', () => { - it( 'should render with wrapper props', () => { - registerBlockType( 'core/foo', { - ...blockSettings, - supports: { - align: true, - alignWide: true, - }, - } ); - - const EnhancedComponent = withDataAlign( ( { wrapperProps } ) => ( -
); diff --git a/packages/block-library/src/audio/edit.native.js b/packages/block-library/src/audio/edit.native.js index cbd7f9ff02f8f..cccb692e789c5 100644 --- a/packages/block-library/src/audio/edit.native.js +++ b/packages/block-library/src/audio/edit.native.js @@ -23,6 +23,7 @@ import { MediaPlaceholder, MediaUpload, MediaUploadProgress, + RichText, store as blockEditorStore, } from '@wordpress/block-editor'; import { __, _x, sprintf } from '@wordpress/i18n'; @@ -227,7 +228,7 @@ function AudioEdit( { - ! caption + RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty Audio caption. */ __( 'Audio caption. Empty' ) : sprintf( diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 979ae04c62282..e86ed9b59c62b 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -6,11 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - useEntityBlockEditor, - useEntityProp, - useEntityRecord, -} from '@wordpress/core-data'; +import { useRegistry, useSelect, useDispatch } from '@wordpress/data'; +import { useRef, useMemo, useEffect } from '@wordpress/element'; +import { useEntityProp, useEntityRecord } from '@wordpress/core-data'; import { Placeholder, Spinner, @@ -27,8 +25,9 @@ import { useBlockProps, Warning, privateApis as blockEditorPrivateApis, + store as blockEditorStore, } from '@wordpress/block-editor'; -import { useRef, useMemo } from '@wordpress/element'; +import { getBlockSupport, parse } from '@wordpress/blocks'; /** * Internal dependencies @@ -36,6 +35,24 @@ import { useRef, useMemo } from '@wordpress/element'; import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); + +function isPartiallySynced( block ) { + return ( + !! getBlockSupport( block.name, '__experimentalConnections', false ) && + !! block.attributes.connections?.attributes && + Object.values( block.attributes.connections.attributes ).some( + ( connection ) => connection.source === 'pattern_attributes' + ) + ); +} +function getPartiallySyncedAttributes( block ) { + return Object.entries( block.attributes.connections.attributes ) + .filter( + ( [ , connection ] ) => connection.source === 'pattern_attributes' + ) + .map( ( [ attributeKey ] ) => attributeKey ); +} + const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; const useInferredLayout = ( blocks, parentLayout ) => { @@ -67,11 +84,61 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; +function applyInitialOverrides( blocks, overrides = {}, defaultValues ) { + return blocks.map( ( block ) => { + const innerBlocks = applyInitialOverrides( + block.innerBlocks, + overrides, + defaultValues + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) + return { ...block, innerBlocks }; + const attributes = getPartiallySyncedAttributes( block ); + const newAttributes = { ...block.attributes }; + for ( const attributeKey of attributes ) { + defaultValues[ blockId ] = block.attributes[ attributeKey ]; + if ( overrides[ blockId ] ) { + newAttributes[ attributeKey ] = overrides[ blockId ]; + } + } + return { + ...block, + attributes: newAttributes, + innerBlocks, + }; + } ); +} + +function getOverridesFromBlocks( blocks, defaultValues ) { + /** @type {Record} */ + const overrides = {}; + for ( const block of blocks ) { + Object.assign( + overrides, + getOverridesFromBlocks( block.innerBlocks, defaultValues ) + ); + const blockId = block.attributes.metadata?.id; + if ( ! isPartiallySynced( block ) || ! blockId ) continue; + const attributes = getPartiallySyncedAttributes( block ); + for ( const attributeKey of attributes ) { + if ( + block.attributes[ attributeKey ] !== defaultValues[ blockId ] + ) { + overrides[ blockId ] = block.attributes[ attributeKey ]; + } + } + } + return Object.keys( overrides ).length > 0 ? overrides : undefined; +} + export default function ReusableBlockEdit( { name, - attributes: { ref }, + attributes: { ref, overrides }, __unstableParentLayout: parentLayout, + clientId: patternClientId, } ) { + const registry = useRegistry(); const hasAlreadyRendered = useHasRecursion( ref ); const { record, hasResolved } = useEntityRecord( 'postType', @@ -79,11 +146,46 @@ export default function ReusableBlockEdit( { ref ); const isMissing = hasResolved && ! record; + const initialOverrides = useRef( overrides ); + const defaultValuesRef = useRef( {} ); + const { + replaceInnerBlocks, + __unstableMarkNextChangeAsNotPersistent, + setBlockEditingMode, + } = useDispatch( blockEditorStore ); + const { getBlockEditingMode } = useSelect( blockEditorStore ); - const [ blocks, onInput, onChange ] = useEntityBlockEditor( - 'postType', - 'wp_block', - { id: ref } + useEffect( () => { + if ( ! record?.content?.raw ) return; + const initialBlocks = parse( record.content.raw ); + + const editingMode = getBlockEditingMode( patternClientId ); + registry.batch( () => { + setBlockEditingMode( patternClientId, 'default' ); + __unstableMarkNextChangeAsNotPersistent(); + replaceInnerBlocks( + patternClientId, + applyInitialOverrides( + initialBlocks, + initialOverrides.current, + defaultValuesRef.current + ) + ); + setBlockEditingMode( patternClientId, editingMode ); + } ); + }, [ + __unstableMarkNextChangeAsNotPersistent, + patternClientId, + record, + replaceInnerBlocks, + registry, + getBlockEditingMode, + setBlockEditingMode, + ] ); + + const innerBlocks = useSelect( + ( select ) => select( blockEditorStore ).getBlocks( patternClientId ), + [ patternClientId ] ); const [ title, setTitle ] = useEntityProp( @@ -93,7 +195,10 @@ export default function ReusableBlockEdit( { ref ); - const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const { alignment, layout } = useInferredLayout( + innerBlocks, + parentLayout + ); const layoutClasses = useLayoutClasses( { layout }, name ); const blockProps = useBlockProps( { @@ -105,16 +210,38 @@ export default function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - value: blocks, layout, - onInput, - onChange, - renderAppender: blocks?.length + renderAppender: innerBlocks?.length ? undefined : InnerBlocks.ButtonBlockAppender, } ); + // Sync the `overrides` attribute from the updated blocks. + // `syncDerivedBlockAttributes` is an action that just like `updateBlockAttributes` + // but won't create an undo level. + // This can be abstracted into a `useSyncDerivedAttributes` hook if needed. + useEffect( () => { + const { getBlocks } = registry.select( blockEditorStore ); + const { syncDerivedBlockAttributes } = unlock( + registry.dispatch( blockEditorStore ) + ); + let prevBlocks = getBlocks( patternClientId ); + return registry.subscribe( () => { + const blocks = getBlocks( patternClientId ); + if ( blocks !== prevBlocks ) { + prevBlocks = blocks; + syncDerivedBlockAttributes( patternClientId, { + overrides: getOverridesFromBlocks( + blocks, + defaultValuesRef.current + ), + } ); + } + }, blockEditorStore ); + }, [ patternClientId, registry ] ); + let children = null; + if ( hasAlreadyRendered ) { children = ( diff --git a/packages/block-library/src/block/index.js b/packages/block-library/src/block/index.js index 95e090f0afd6a..0d117e6f3938a 100644 --- a/packages/block-library/src/block/index.js +++ b/packages/block-library/src/block/index.js @@ -8,14 +8,15 @@ import { symbol as icon } from '@wordpress/icons'; */ import initBlock from '../utils/init-block'; import metadata from './block.json'; -import edit from './edit'; +import editV1 from './v1/edit'; +import editV2 from './edit'; const { name } = metadata; export { metadata, name }; export const settings = { - edit, + edit: window.__experimentalPatternPartialSyncing ? editV2 : editV1, icon, }; diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index d51b35d68b23d..54b54fad139ff 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -46,8 +46,31 @@ function render_block_core_block( $attributes ) { $content = $wp_embed->run_shortcode( $reusable_block->post_content ); $content = $wp_embed->autoembed( $content ); + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $has_partial_synced_overrides = $gutenberg_experiments + && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) + && isset( $attributes['overrides'] ); + + /** + * We set the `pattern/overrides` context through the `render_block_context` + * filter so that it is available when a pattern's inner blocks are + * rendering via do_blocks given it only receives the inner content. + */ + if ( $has_partial_synced_overrides ) { + $filter_block_context = static function ( $context ) use ( $attributes ) { + $context['pattern/overrides'] = $attributes['overrides']; + return $context; + }; + add_filter( 'render_block_context', $filter_block_context, 1 ); + } + $content = do_blocks( $content ); unset( $seen_refs[ $attributes['ref'] ] ); + + if ( $has_partial_synced_overrides ) { + remove_filter( 'render_block_context', $filter_block_context, 1 ); + } + return $content; } @@ -63,3 +86,28 @@ function register_block_core_block() { ); } add_action( 'init', 'register_block_core_block' ); + +$gutenberg_experiments = get_option( 'gutenberg-experiments' ); +if ( $gutenberg_experiments && array_key_exists( 'gutenberg-pattern-partial-syncing', $gutenberg_experiments ) ) { + /** + * Registers the overrides attribute for core/block. + * + * @param array $args Array of arguments for registering a block type. + * @param string $block_name Block name including namespace. + * @return array $args + */ + function register_block_core_block_args( $args, $block_name ) { + if ( 'core/block' === $block_name ) { + $args['attributes'] = array_merge( + $args['attributes'], + array( + 'overrides' => array( + 'type' => 'object', + ), + ) + ); + } + return $args; + } + add_filter( 'register_block_type_args', 'register_block_core_block_args', 10, 2 ); +} diff --git a/packages/block-library/src/block/v1/edit.js b/packages/block-library/src/block/v1/edit.js new file mode 100644 index 0000000000000..5975711376c65 --- /dev/null +++ b/packages/block-library/src/block/v1/edit.js @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + useEntityBlockEditor, + useEntityProp, + useEntityRecord, +} from '@wordpress/core-data'; +import { + Placeholder, + Spinner, + TextControl, + PanelBody, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { + useInnerBlocksProps, + __experimentalRecursionProvider as RecursionProvider, + __experimentalUseHasRecursion as useHasRecursion, + InnerBlocks, + InspectorControls, + useBlockProps, + Warning, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; +import { useRef, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; + +const { useLayoutClasses } = unlock( blockEditorPrivateApis ); +const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; + +const useInferredLayout = ( blocks, parentLayout ) => { + const initialInferredAlignmentRef = useRef(); + + return useMemo( () => { + // Exit early if the pattern's blocks haven't loaded yet. + if ( ! blocks?.length ) { + return {}; + } + + let alignment = initialInferredAlignmentRef.current; + + // Only track the initial alignment so that temporarily removed + // alignments can be reapplied. + if ( alignment === undefined ) { + const isConstrained = parentLayout?.type === 'constrained'; + const hasFullAlignment = blocks.some( ( block ) => + fullAlignments.includes( block.attributes.align ) + ); + + alignment = isConstrained && hasFullAlignment ? 'full' : null; + initialInferredAlignmentRef.current = alignment; + } + + const layout = alignment ? parentLayout : undefined; + + return { alignment, layout }; + }, [ blocks, parentLayout ] ); +}; + +export default function ReusableBlockEdit( { + name, + attributes: { ref }, + __unstableParentLayout: parentLayout, +} ) { + const hasAlreadyRendered = useHasRecursion( ref ); + const { record, hasResolved } = useEntityRecord( + 'postType', + 'wp_block', + ref + ); + const isMissing = hasResolved && ! record; + + const [ blocks, onInput, onChange ] = useEntityBlockEditor( + 'postType', + 'wp_block', + { id: ref } + ); + + const [ title, setTitle ] = useEntityProp( + 'postType', + 'wp_block', + 'title', + ref + ); + + const { alignment, layout } = useInferredLayout( blocks, parentLayout ); + const layoutClasses = useLayoutClasses( { layout }, name ); + + const blockProps = useBlockProps( { + className: classnames( + 'block-library-block__reusable-block-container', + layout && layoutClasses, + { [ `align${ alignment }` ]: alignment } + ), + } ); + + const innerBlocksProps = useInnerBlocksProps( blockProps, { + value: blocks, + layout, + onInput, + onChange, + renderAppender: blocks?.length + ? undefined + : InnerBlocks.ButtonBlockAppender, + } ); + + let children = null; + + if ( hasAlreadyRendered ) { + children = ( + + { __( 'Block cannot be rendered inside itself.' ) } + + ); + } + + if ( isMissing ) { + children = ( + + { __( 'Block has been deleted or is unavailable.' ) } + + ); + } + + if ( ! hasResolved ) { + children = ( + + + + ); + } + + return ( + + + + + + + { children === null ? ( +
+ ) : ( +
{ children }
+ ) } + + ); +} diff --git a/packages/block-library/src/block/edit.native.js b/packages/block-library/src/block/v1/edit.native.js similarity index 98% rename from packages/block-library/src/block/edit.native.js rename to packages/block-library/src/block/v1/edit.native.js index 9ab6ccf86a1e1..3a649921b3dda 100644 --- a/packages/block-library/src/block/edit.native.js +++ b/packages/block-library/src/block/v1/edit.native.js @@ -42,8 +42,8 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import styles from './editor.scss'; -import EditTitle from './edit-title'; +import styles from '../editor.scss'; +import EditTitle from '../edit-title'; export default function ReusableBlockEdit( { attributes: { ref }, diff --git a/packages/block-library/src/button/block.json b/packages/block-library/src/button/block.json index eec327b4ca48e..3c232700a876e 100644 --- a/packages/block-library/src/button/block.json +++ b/packages/block-library/src/button/block.json @@ -36,8 +36,8 @@ "__experimentalRole": "content" }, "text": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "a,button", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/button/save.js b/packages/block-library/src/button/save.js index a7f286270f8fc..e12936e8c9245 100644 --- a/packages/block-library/src/button/save.js +++ b/packages/block-library/src/button/save.js @@ -30,7 +30,7 @@ export default function save( { attributes, className } ) { width, } = attributes; - if ( ! text ) { + if ( RichText.isEmpty( text ) ) { return null; } diff --git a/packages/block-library/src/code/block.json b/packages/block-library/src/code/block.json index 80df74b5062b5..bd5db3c918b96 100644 --- a/packages/block-library/src/code/block.json +++ b/packages/block-library/src/code/block.json @@ -8,8 +8,8 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "code", "__unstablePreserveWhiteSpace": true } diff --git a/packages/block-library/src/code/edit.native.js b/packages/block-library/src/code/edit.native.js index 3353dbc3c25a0..d348a6968b40d 100644 --- a/packages/block-library/src/code/edit.native.js +++ b/packages/block-library/src/code/edit.native.js @@ -6,7 +6,7 @@ import { View } from 'react-native'; /** * WordPress dependencies */ -import { PlainText } from '@wordpress/block-editor'; +import { RichText } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; import { usePreferredColorSchemeStyle } from '@wordpress/compose'; import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; @@ -20,14 +20,11 @@ import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; */ import styles from './theme.scss'; -// Note: styling is applied directly to the (nested) PlainText component. Web-side components -// apply it to the container 'div' but we don't have a proper proposal for cascading styling yet. export function CodeEdit( props ) { const { attributes, setAttributes, - onFocus, - onBlur, + onRemove, style, insertBlocksAfter, mergeBlocks, @@ -37,30 +34,31 @@ export function CodeEdit( props ) { styles.blockCode, styles.blockCodeDark ), - ...( style?.fontSize && { fontSize: style.fontSize } ), }; + const textStyle = style?.fontSize ? { fontSize: style.fontSize } : {}; + const placeholderStyle = usePreferredColorSchemeStyle( styles.placeholder, styles.placeholderDark ); return ( - - + <RichText + tagName="pre" value={ attributes.content } identifier="content" - style={ codeStyle } - multiline={ true } + style={ textStyle } underlineColorAndroid="transparent" onChange={ ( content ) => setAttributes( { content } ) } onMerge={ mergeBlocks } + onRemove={ onRemove } placeholder={ __( 'Write code…' ) } aria-label={ __( 'Code' ) } - isSelected={ props.isSelected } - onFocus={ onFocus } - onBlur={ onBlur } placeholderTextColor={ placeholderStyle.color } + preserveWhiteSpace + __unstablePastePlainText __unstableOnSplitAtDoubleLineEnd={ () => insertBlocksAfter( createBlock( getDefaultBlockName() ) ) } diff --git a/packages/block-library/src/code/save.js b/packages/block-library/src/code/save.js index 7dd355f3855a8..5bb9f68767b5e 100644 --- a/packages/block-library/src/code/save.js +++ b/packages/block-library/src/code/save.js @@ -13,7 +13,10 @@ export default function save( { attributes } ) { <pre { ...useBlockProps.save() }> <RichText.Content tagName="code" - value={ escape( attributes.content ) } + // To do: `escape` encodes characters in shortcodes and URLs to + // prevent embedding in PHP. Ideally checks for the code block, + // or pre/code tags, should be made on the PHP side? + value={ escape( attributes.content.toString() ) } /> </pre> ); diff --git a/packages/block-library/src/code/test/edit.native.js b/packages/block-library/src/code/test/edit.native.js index 0f693fa2136ce..be43e398d03a3 100644 --- a/packages/block-library/src/code/test/edit.native.js +++ b/packages/block-library/src/code/test/edit.native.js @@ -49,7 +49,7 @@ describe( 'Code', () => { const screen = await initializeEditor( { initialHtml, } ); - const { getByDisplayValue } = screen; + const { findByPlaceholderText } = screen; // Get block const codeBlock = await getBlock( screen, 'Code' ); @@ -57,7 +57,7 @@ describe( 'Code', () => { fireEvent.press( codeBlock ); // Get initial text - const codeBlockText = getByDisplayValue( 'Sample text' ); + const codeBlockText = await findByPlaceholderText( 'Write code…' ); expect( codeBlockText ).toBeVisible(); expect( getEditorHtml() ).toMatchSnapshot(); diff --git a/packages/block-library/src/cover/test/edit.native.js b/packages/block-library/src/cover/test/edit.native.js index 3ca2755ee1aeb..6925cfe3f6221 100644 --- a/packages/block-library/src/cover/test/edit.native.js +++ b/packages/block-library/src/cover/test/edit.native.js @@ -80,7 +80,13 @@ const MEDIA_OPTIONS = [ // Simplified tree to render Cover edit within slot. const CoverEdit = ( props ) => ( <SlotFillProvider> - <BlockEdit isSelected name={ cover.name } clientId={ 0 } { ...props } /> + <BlockEdit + isSelected + mayDisplayControls + name={ cover.name } + clientId={ 0 } + { ...props } + /> <BottomSheetSettings isVisible /> </SlotFillProvider> ); diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index d449d42e1e10c..a71d3af2a5ed3 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -13,8 +13,8 @@ "default": false }, "summary": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "summary" } }, diff --git a/packages/block-library/src/embed/block.json b/packages/block-library/src/embed/block.json index 9ca54db871db1..5aac8bbd6b8ca 100644 --- a/packages/block-library/src/embed/block.json +++ b/packages/block-library/src/embed/block.json @@ -12,8 +12,8 @@ "__experimentalRole": "content" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/embed/embed-preview.native.js b/packages/block-library/src/embed/embed-preview.native.js index 87abc38348d56..f4baf97871059 100644 --- a/packages/block-library/src/embed/embed-preview.native.js +++ b/packages/block-library/src/embed/embed-preview.native.js @@ -10,6 +10,7 @@ import classnames from 'classnames/dedupe'; import { View } from '@wordpress/primitives'; import { BlockCaption, + RichText, store as blockEditorStore, } from '@wordpress/block-editor'; import { __, sprintf } from '@wordpress/i18n'; @@ -51,7 +52,7 @@ const EmbedPreview = ( { styles[ `embed-preview__sandbox--align-${ align }` ]; function accessibilityLabelCreator( caption ) { - return ! caption + return RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty Embed caption. */ __( 'Embed caption. Empty' ) : sprintf( diff --git a/packages/block-library/src/embed/icons.js b/packages/block-library/src/embed/icons.js index c65bcd69bb60c..21a3b20ae278a 100644 --- a/packages/block-library/src/embed/icons.js +++ b/packages/block-library/src/embed/icons.js @@ -137,7 +137,7 @@ export const embedAnimotoIcon = ( export const embedDailymotionIcon = ( <SVG viewBox="0 0 24 24"> <Path - d="m12.1479 18.5957c-2.4949 0-4.28131-1.7558-4.28131-4.0658 0-2.2176 1.78641-4.0965 4.09651-4.0965 2.2793 0 4.0349 1.7864 4.0349 4.1581 0 2.2794-1.7556 4.0042-3.8501 4.0042zm8.3521-18.5957-4.5329 1v7c-1.1088-1.41691-2.8028-1.8787-4.8049-1.8787-2.09443 0-3.97329.76993-5.5133 2.27917-1.72483 1.66323-2.6489 3.78863-2.6489 6.16033 0 2.5873.98562 4.8049 2.89526 6.499 1.44763 1.2936 3.17251 1.9402 5.17454 1.9402 1.9713 0 3.4498-.5236 4.8973-1.9402v1.9402h4.5329c0-7.6359 0-15.3641 0-23z" + d="M11.903 16.568c-1.82 0-3.124-1.281-3.124-2.967a2.987 2.987 0 0 1 2.989-2.989c1.663 0 2.944 1.304 2.944 3.034 0 1.663-1.281 2.922-2.81 2.922ZM17.997 3l-3.308.73v5.107c-.809-1.034-2.045-1.37-3.505-1.37-1.529 0-2.9.561-4.023 1.662-1.259 1.214-1.933 2.764-1.933 4.495 0 1.888.72 3.506 2.113 4.742 1.056.944 2.314 1.415 3.775 1.415 1.438 0 2.517-.382 3.573-1.415v1.415h3.308V3Z" fill="#333436" /> </SVG> diff --git a/packages/block-library/src/file/block.json b/packages/block-library/src/file/block.json index 0cc20b3f501e9..9dc6677e4adce 100644 --- a/packages/block-library/src/file/block.json +++ b/packages/block-library/src/file/block.json @@ -21,8 +21,8 @@ "attribute": "id" }, "fileName": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "a:not([download])" }, "textLinkHref": { @@ -42,8 +42,8 @@ "default": true }, "downloadButtonText": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "a[download]" }, "displayPreview": { diff --git a/packages/block-library/src/file/edit.js b/packages/block-library/src/file/edit.js index e3328fd9851c3..de318aaa35e41 100644 --- a/packages/block-library/src/file/edit.js +++ b/packages/block-library/src/file/edit.js @@ -102,7 +102,7 @@ function FileEdit( { attributes, isSelected, setAttributes, clientId } ) { revokeBlobURL( href ); } - if ( downloadButtonText === undefined ) { + if ( RichText.isEmpty( downloadButtonText ) ) { setAttributes( { downloadButtonText: _x( 'Download', 'button label' ), } ); diff --git a/packages/block-library/src/file/edit.native.js b/packages/block-library/src/file/edit.native.js index c4217c6026306..9e3d2ef98176d 100644 --- a/packages/block-library/src/file/edit.native.js +++ b/packages/block-library/src/file/edit.native.js @@ -97,7 +97,7 @@ export class FileEdit extends Component { const { attributes, setAttributes } = this.props; const { downloadButtonText } = attributes; - if ( downloadButtonText === undefined || downloadButtonText === '' ) { + if ( RichText.isEmpty( downloadButtonText ) ) { setAttributes( { downloadButtonText: _x( 'Download', 'button label' ), } ); diff --git a/packages/block-library/src/file/save.js b/packages/block-library/src/file/save.js index 6d0684ac76b8e..f5eb1ce3c2b14 100644 --- a/packages/block-library/src/file/save.js +++ b/packages/block-library/src/file/save.js @@ -25,7 +25,11 @@ export default function save( { attributes } ) { previewHeight, } = attributes; - const pdfEmbedLabel = RichText.isEmpty( fileName ) ? 'PDF embed' : fileName; + const pdfEmbedLabel = RichText.isEmpty( fileName ) + ? 'PDF embed' + : // To do: use toPlainText, but we need ensure it's RichTextData. See + // https://github.com/WordPress/gutenberg/pull/56710. + fileName.toString(); const hasFilename = ! RichText.isEmpty( fileName ); diff --git a/packages/block-library/src/form-input/block.json b/packages/block-library/src/form-input/block.json index 067b7ac69430c..53aa0be6744cb 100644 --- a/packages/block-library/src/form-input/block.json +++ b/packages/block-library/src/form-input/block.json @@ -19,10 +19,10 @@ "type": "string" }, "label": { - "type": "string", + "type": "rich-text", "default": "Label", "selector": ".wp-block-form-input__label-content", - "source": "html", + "source": "rich-text", "__experimentalRole": "content" }, "inlineLabel": { diff --git a/packages/block-library/src/form-input/deprecated.js b/packages/block-library/src/form-input/deprecated.js new file mode 100644 index 0000000000000..bb1fdf6e40204 --- /dev/null +++ b/packages/block-library/src/form-input/deprecated.js @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; +import removeAccents from 'remove-accents'; + +/** + * WordPress dependencies + */ +import { + RichText, + __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, + __experimentalGetColorClassesAndStyles as getColorClassesAndStyles, +} from '@wordpress/block-editor'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; + +const getNameFromLabelV1 = ( content ) => { + return ( + removeAccents( stripHTML( content ) ) + // Convert anything that's not a letter or number to a hyphen. + .replace( /[^\p{L}\p{N}]+/gu, '-' ) + // Convert to lowercase + .toLowerCase() + // Remove any remaining leading or trailing hyphens. + .replace( /(^-+)|(-+$)/g, '' ) + ); +}; + +// Version without wrapper div in saved markup +// See: https://github.com/WordPress/gutenberg/pull/56507 +const v1 = { + attributes: { + type: { + type: 'string', + default: 'text', + }, + name: { + type: 'string', + }, + label: { + type: 'string', + default: 'Label', + selector: '.wp-block-form-input__label-content', + source: 'html', + __experimentalRole: 'content', + }, + inlineLabel: { + type: 'boolean', + default: false, + }, + required: { + type: 'boolean', + default: false, + selector: '.wp-block-form-input__input', + source: 'attribute', + attribute: 'required', + }, + placeholder: { + type: 'string', + selector: '.wp-block-form-input__input', + source: 'attribute', + attribute: 'placeholder', + __experimentalRole: 'content', + }, + value: { + type: 'string', + default: '', + selector: 'input', + source: 'attribute', + attribute: 'value', + }, + visibilityPermissions: { + type: 'string', + default: 'all', + }, + }, + supports: { + className: false, + anchor: true, + reusable: false, + spacing: { + margin: [ 'top', 'bottom' ], + }, + __experimentalBorder: { + radius: true, + __experimentalSkipSerialization: true, + __experimentalDefaultControls: { + radius: true, + }, + }, + }, + save( { attributes } ) { + const { type, name, label, inlineLabel, required, placeholder, value } = + attributes; + + const borderProps = getBorderClassesAndStyles( attributes ); + const colorProps = getColorClassesAndStyles( attributes ); + + const inputStyle = { + ...borderProps.style, + ...colorProps.style, + }; + + const inputClasses = classNames( + 'wp-block-form-input__input', + colorProps.className, + borderProps.className + ); + const TagName = type === 'textarea' ? 'textarea' : 'input'; + + if ( 'hidden' === type ) { + return <input type={ type } name={ name } value={ value } />; + } + + /* eslint-disable jsx-a11y/label-has-associated-control */ + return ( + <label + className={ classNames( 'wp-block-form-input__label', { + 'is-label-inline': inlineLabel, + } ) } + > + <span className="wp-block-form-input__label-content"> + <RichText.Content value={ label } /> + </span> + <TagName + className={ inputClasses } + type={ 'textarea' === type ? undefined : type } + name={ name || getNameFromLabelV1( label ) } + required={ required } + aria-required={ required } + placeholder={ placeholder || undefined } + style={ inputStyle } + /> + </label> + ); + /* eslint-enable jsx-a11y/label-has-associated-control */ + }, +}; + +const deprecated = [ v1 ]; + +export default deprecated; diff --git a/packages/block-library/src/form-input/edit.js b/packages/block-library/src/form-input/edit.js index 0742c22c22f42..0b34c70fbad2d 100644 --- a/packages/block-library/src/form-input/edit.js +++ b/packages/block-library/src/form-input/edit.js @@ -59,7 +59,7 @@ function InputFieldBlock( { attributes, setAttributes, className } ) { </PanelBody> </InspectorControls> ) } - <InspectorControls __experimentalGroup="advanced"> + <InspectorControls group="advanced"> <TextControl autoComplete="off" label={ __( 'Name' ) } diff --git a/packages/block-library/src/form-input/index.js b/packages/block-library/src/form-input/index.js index b700e0ade6ca7..8e0548a6b24db 100644 --- a/packages/block-library/src/form-input/index.js +++ b/packages/block-library/src/form-input/index.js @@ -2,6 +2,7 @@ * Internal dependencies */ import initBlock from '../utils/init-block'; +import deprecated from './deprecated'; import edit from './edit'; import metadata from './block.json'; import save from './save'; @@ -12,6 +13,7 @@ const { name } = metadata; export { metadata, name }; export const settings = { + deprecated, edit, save, variations, diff --git a/packages/block-library/src/form-input/save.js b/packages/block-library/src/form-input/save.js index 0cca31ca423ee..d8f5852c2ab90 100644 --- a/packages/block-library/src/form-input/save.js +++ b/packages/block-library/src/form-input/save.js @@ -9,9 +9,11 @@ import removeAccents from 'remove-accents'; */ import { RichText, + useBlockProps, __experimentalGetBorderClassesAndStyles as getBorderClassesAndStyles, __experimentalGetColorClassesAndStyles as getColorClassesAndStyles, } from '@wordpress/block-editor'; +import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; /** * Get the name attribute from a content string. @@ -21,11 +23,8 @@ import { * @return {string} Returns the slug. */ const getNameFromLabel = ( content ) => { - const dummyElement = document.createElement( 'div' ); - dummyElement.innerHTML = content; - // Get the slug. return ( - removeAccents( dummyElement.innerText ) + removeAccents( stripHTML( content ) ) // Convert anything that's not a letter or number to a hyphen. .replace( /[^\p{L}\p{N}]+/gu, '-' ) // Convert to lowercase @@ -54,30 +53,34 @@ export default function save( { attributes } ) { ); const TagName = type === 'textarea' ? 'textarea' : 'input'; + const blockProps = useBlockProps.save(); + if ( 'hidden' === type ) { return <input type={ type } name={ name } value={ value } />; } - /* eslint-disable jsx-a11y/label-has-associated-control */ return ( - <label - className={ classNames( 'wp-block-form-input__label', { - 'is-label-inline': inlineLabel, - } ) } - > - <span className="wp-block-form-input__label-content"> - <RichText.Content value={ label } /> - </span> - <TagName - className={ inputClasses } - type={ 'textarea' === type ? undefined : type } - name={ name || getNameFromLabel( label ) } - required={ required } - aria-required={ required } - placeholder={ placeholder || undefined } - style={ inputStyle } - /> - </label> + <div { ...blockProps }> + { /* eslint-disable jsx-a11y/label-has-associated-control */ } + <label + className={ classNames( 'wp-block-form-input__label', { + 'is-label-inline': inlineLabel, + } ) } + > + <span className="wp-block-form-input__label-content"> + <RichText.Content value={ label } /> + </span> + <TagName + className={ inputClasses } + type={ 'textarea' === type ? undefined : type } + name={ name || getNameFromLabel( label ) } + required={ required } + aria-required={ required } + placeholder={ placeholder || undefined } + style={ inputStyle } + /> + </label> + { /* eslint-enable jsx-a11y/label-has-associated-control */ } + </div> ); - /* eslint-enable jsx-a11y/label-has-associated-control */ } diff --git a/packages/block-library/src/gallery/block.json b/packages/block-library/src/gallery/block.json index 0867989af4ec7..fad92aed59bf7 100644 --- a/packages/block-library/src/gallery/block.json +++ b/packages/block-library/src/gallery/block.json @@ -46,8 +46,8 @@ "attribute": "data-id" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": ".blocks-gallery-item__caption" } } @@ -72,8 +72,8 @@ "maximum": 8 }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": ".blocks-gallery-caption" }, "imageCrop": { diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index 68ada058abefa..e73e1e76b9c5f 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -6,7 +6,7 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { compose, usePrevious } from '@wordpress/compose'; +import { compose } from '@wordpress/compose'; import { BaseControl, PanelBody, @@ -14,7 +14,6 @@ import { ToggleControl, RangeControl, Spinner, - ToolbarButton, } from '@wordpress/components'; import { store as blockEditorStore, @@ -25,13 +24,7 @@ import { BlockControls, MediaReplaceFlow, } from '@wordpress/block-editor'; -import { - Platform, - useCallback, - useEffect, - useState, - useMemo, -} from '@wordpress/element'; +import { Platform, useEffect, useMemo } from '@wordpress/element'; import { __, _x, sprintf } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { withViewportMatch } from '@wordpress/viewport'; @@ -39,7 +32,6 @@ import { View } from '@wordpress/primitives'; import { createBlock } from '@wordpress/blocks'; import { createBlobURL } from '@wordpress/blob'; import { store as noticesStore } from '@wordpress/notices'; -import { caption as captionIcon } from '@wordpress/icons'; /** * Internal dependencies @@ -94,34 +86,7 @@ function GalleryEdit( props ) { onFocus, } = props; - const { columns, imageCrop, linkTarget, linkTo, sizeSlug, caption } = - attributes; - const [ showCaption, setShowCaption ] = useState( !! caption ); - const prevCaption = usePrevious( caption ); - - // We need to show the caption when changes come from - // history navigation(undo/redo). - useEffect( () => { - if ( caption && ! prevCaption ) { - setShowCaption( true ); - } - }, [ caption, prevCaption ] ); - - useEffect( () => { - if ( ! isSelected && ! caption ) { - setShowCaption( false ); - } - }, [ isSelected, caption ] ); - - // Focus the caption when we click to add one. - const captionRef = useCallback( - ( node ) => { - if ( node && ! caption ) { - node.focus(); - } - }, - [ caption ] - ); + const { columns, imageCrop, linkTarget, linkTo, sizeSlug } = attributes; const { __unstableMarkNextChangeAsNotPersistent, @@ -620,25 +585,6 @@ function GalleryEdit( props ) { </InspectorControls> { Platform.isWeb && ( <> - <BlockControls group="block"> - { ! isContentLocked && ( - <ToolbarButton - onClick={ () => { - setShowCaption( ! showCaption ); - if ( showCaption && caption ) { - setAttributes( { caption: undefined } ); - } - } } - icon={ captionIcon } - isPressed={ showCaption } - label={ - showCaption - ? __( 'Remove caption' ) - : __( 'Add caption' ) - } - /> - ) } - </BlockControls> <BlockControls group="other"> <MediaReplaceFlow allowedTypes={ ALLOWED_MEDIA_TYPES } @@ -661,8 +607,7 @@ function GalleryEdit( props ) { ) } <Gallery { ...props } - showCaption={ showCaption } - ref={ Platform.isWeb ? captionRef : undefined } + isContentLocked={ isContentLocked } images={ images } mediaPlaceholder={ ! hasImages || Platform.isNative diff --git a/packages/block-library/src/gallery/gallery.js b/packages/block-library/src/gallery/gallery.js index 5d9168ecb06c4..e898ae2e9fdcb 100644 --- a/packages/block-library/src/gallery/gallery.js +++ b/packages/block-library/src/gallery/gallery.js @@ -6,16 +6,15 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - RichText, - __experimentalGetElementClassName, -} from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; -import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; import { View } from '@wordpress/primitives'; -import { forwardRef } from '@wordpress/element'; -export const Gallery = ( props, captionRef ) => { +/** + * Internal dependencies + */ +import { Caption } from '../utils/caption'; + +export default function Gallery( props ) { const { attributes, isSelected, @@ -24,10 +23,10 @@ export const Gallery = ( props, captionRef ) => { insertBlocksAfter, blockProps, __unstableLayoutClassNames: layoutClassNames, - showCaption, + isContentLocked, } = props; - const { align, columns, caption, imageCrop } = attributes; + const { align, columns, imageCrop } = attributes; return ( <figure @@ -50,32 +49,16 @@ export const Gallery = ( props, captionRef ) => { { mediaPlaceholder } </View> ) } - { showCaption && - ( ! RichText.isEmpty( caption ) || isSelected ) && ( - <RichText - identifier="caption" - aria-label={ __( 'Gallery caption text' ) } - placeholder={ __( 'Write gallery caption…' ) } - value={ caption } - className={ classnames( - 'blocks-gallery-caption', - __experimentalGetElementClassName( 'caption' ) - ) } - ref={ captionRef } - tagName="figcaption" - onChange={ ( value ) => - setAttributes( { caption: value } ) - } - inlineToolbar - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( getDefaultBlockName() ) - ) - } - /> - ) } + <Caption + attributes={ attributes } + setAttributes={ setAttributes } + isSelected={ isSelected } + insertBlocksAfter={ insertBlocksAfter } + showToolbarButton={ ! isContentLocked } + className="blocks-gallery-caption" + label={ __( 'Gallery caption text' ) } + placeholder={ __( 'Add gallery caption' ) } + /> </figure> ); -}; - -export default forwardRef( Gallery ); +} diff --git a/packages/block-library/src/gallery/gallery.native.js b/packages/block-library/src/gallery/gallery.native.js index e5a042385c160..8979d814a8da0 100644 --- a/packages/block-library/src/gallery/gallery.native.js +++ b/packages/block-library/src/gallery/gallery.native.js @@ -13,7 +13,11 @@ import styles from './gallery-styles.scss'; * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { BlockCaption, useInnerBlocksProps } from '@wordpress/block-editor'; +import { + BlockCaption, + RichText, + useInnerBlocksProps, +} from '@wordpress/block-editor'; import { useState, useEffect } from '@wordpress/element'; import { mediaUploadSync } from '@wordpress/react-native-bridge'; import { WIDE_ALIGNMENTS } from '@wordpress/components'; @@ -99,7 +103,7 @@ export const Gallery = ( props ) => { isSelected={ isCaptionSelected } accessible={ true } accessibilityLabelCreator={ ( caption ) => - ! caption + RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty gallery caption. */ 'Gallery caption. Empty' diff --git a/packages/block-library/src/gallery/v1/gallery.native.js b/packages/block-library/src/gallery/v1/gallery.native.js index c1d13cb6313e0..a3d8d314dac3e 100644 --- a/packages/block-library/src/gallery/v1/gallery.native.js +++ b/packages/block-library/src/gallery/v1/gallery.native.js @@ -17,6 +17,7 @@ import Tiles from './tiles'; import { __, sprintf } from '@wordpress/i18n'; import { BlockCaption, + RichText, store as blockEditorStore, } from '@wordpress/block-editor'; import { useState, useEffect } from '@wordpress/element'; @@ -141,7 +142,7 @@ export const Gallery = ( props ) => { isSelected={ isCaptionSelected } accessible={ true } accessibilityLabelCreator={ ( caption ) => - ! caption + RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty gallery caption. */ 'Gallery caption. Empty' : sprintf( diff --git a/packages/block-library/src/group/edit.js b/packages/block-library/src/group/edit.js index 9c8690c4e0e8e..a763bc95e60d7 100644 --- a/packages/block-library/src/group/edit.js +++ b/packages/block-library/src/group/edit.js @@ -10,6 +10,7 @@ import { store as blockEditorStore, } from '@wordpress/block-editor'; import { SelectControl } from '@wordpress/components'; +import { useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { View } from '@wordpress/primitives'; @@ -97,7 +98,8 @@ function GroupEdit( { attributes, name, setAttributes, clientId } ) { themeSupportsLayout || type === 'flex' || type === 'grid'; // Hooks. - const blockProps = useBlockProps(); + const ref = useRef(); + const blockProps = useBlockProps( { ref } ); const [ showPlaceholder, setShowPlaceholder ] = useShouldShowPlaceHolder( { attributes, @@ -124,6 +126,7 @@ function GroupEdit( { attributes, name, setAttributes, clientId } ) { ? blockProps : { className: 'wp-block-group__inner-container' }, { + dropZoneElement: ref.current, templateLock, allowedBlocks, renderAppender, diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json index 7c018f8472cb4..72cc67caddd9e 100644 --- a/packages/block-library/src/heading/block.json +++ b/packages/block-library/src/heading/block.json @@ -12,10 +12,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "h1,h2,h3,h4,h5,h6", - "default": "", "__experimentalRole": "content" }, "level": { @@ -57,9 +56,7 @@ "__experimentalTextDecoration": true, "__experimentalWritingMode": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true, - "textTransform": true + "fontSize": true } }, "__unstablePasteTextInline": true, diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index cfe91a71ff4f9..c5191e3dd8654 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -25,8 +25,8 @@ "__experimentalRole": "content" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/image/edit.native.js b/packages/block-library/src/image/edit.native.js index 44ebfda67d875..5c5308af5dfc3 100644 --- a/packages/block-library/src/image/edit.native.js +++ b/packages/block-library/src/image/edit.native.js @@ -47,6 +47,7 @@ import { BlockStyles, store as blockEditorStore, blockSettingsScreens, + RichText, } from '@wordpress/block-editor'; import { __, _x, sprintf } from '@wordpress/i18n'; import { getProtocol, hasQueryArg, isURL } from '@wordpress/url'; @@ -329,9 +330,7 @@ export class ImageEdit extends Component { accessibilityLabelCreator( caption ) { // Checks if caption is empty. - return ( typeof caption === 'string' && caption.trim().length === 0 ) || - caption === undefined || - caption === null + return RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty image caption. */ 'Image caption. Empty' : sprintf( diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 11d460efd472c..b74079b2b8b79 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -15,41 +15,24 @@ import { __experimentalToolsPanelItem as ToolsPanelItem, __experimentalUseCustomUnits as useCustomUnits, } from '@wordpress/components'; -import { useViewportMatch, usePrevious } from '@wordpress/compose'; +import { useViewportMatch } from '@wordpress/compose'; import { useSelect, useDispatch } from '@wordpress/data'; import { BlockControls, InspectorControls, - RichText, __experimentalImageURLInputUI as ImageURLInputUI, MediaReplaceFlow, store as blockEditorStore, useSettings, __experimentalImageEditor as ImageEditor, - __experimentalGetElementClassName, __experimentalUseBorderProps as useBorderProps, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; -import { - useEffect, - useMemo, - useState, - useRef, - useCallback, -} from '@wordpress/element'; +import { useEffect, useMemo, useState, useRef } from '@wordpress/element'; import { __, _x, sprintf, isRTL } from '@wordpress/i18n'; import { getFilename } from '@wordpress/url'; -import { - createBlock, - getDefaultBlockName, - switchToBlockType, -} from '@wordpress/blocks'; -import { - crop, - overlayText, - upload, - caption as captionIcon, -} from '@wordpress/icons'; +import { switchToBlockType } from '@wordpress/blocks'; +import { crop, overlayText, upload } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -60,6 +43,7 @@ import { unlock } from '../lock-unlock'; import { createUpgradedEmbedBlock } from '../embed/util'; import useClientWidth from './use-client-width'; import { isExternalImage } from './edit'; +import { Caption } from '../utils/caption'; /** * Module constants @@ -125,7 +109,6 @@ export default function Image( { const { url = '', alt, - caption, align, id, href, @@ -147,8 +130,6 @@ export default function Image( { const numericHeight = height ? parseInt( height, 10 ) : undefined; const imageRef = useRef(); - const prevCaption = usePrevious( caption ); - const [ showCaption, setShowCaption ] = useState( !! caption ); const { allowResize = true } = context; const { getBlock } = useSelect( blockEditorStore ); @@ -247,24 +228,6 @@ export default function Image( { .catch( () => {} ); }, [ id, url, isSelected, externalBlob, canUploadMedia ] ); - // We need to show the caption when changes come from - // history navigation(undo/redo). - useEffect( () => { - if ( caption && ! prevCaption ) { - setShowCaption( true ); - } - }, [ caption, prevCaption ] ); - - // Focus the caption when we click to add one. - const captionRef = useCallback( - ( node ) => { - if ( node && ! caption ) { - node.focus(); - } - }, - [ caption ] - ); - // Get naturalWidth and naturalHeight from image ref, and fall back to loaded natural // width and height. This resolves an issue in Safari where the loaded natural // width and height is otherwise lost when switching between alignments. @@ -355,11 +318,8 @@ export default function Image( { useEffect( () => { if ( ! isSelected ) { setIsEditingImage( false ); - if ( ! caption ) { - setShowCaption( false ); - } } - }, [ isSelected, caption ] ); + }, [ isSelected ] ); const canEditImage = id && naturalWidth && naturalHeight && imageEditing; const allowCrop = ! multiImageSelection && canEditImage && ! isEditingImage; @@ -420,6 +380,7 @@ export default function Image( { const resetAll = () => { setAttributes( { + alt: undefined, width: undefined, height: undefined, scale: undefined, @@ -439,23 +400,6 @@ export default function Image( { const controls = ( <> <BlockControls group="block"> - { hasNonContentControls && ( - <ToolbarButton - onClick={ () => { - setShowCaption( ! showCaption ); - if ( showCaption && caption ) { - setAttributes( { caption: undefined } ); - } - } } - icon={ captionIcon } - isPressed={ showCaption } - label={ - showCaption - ? __( 'Remove caption' ) - : __( 'Add caption' ) - } - /> - ) } { ! multiImageSelection && ! isEditingImage && ( <ImageURLInputUI url={ href || '' } @@ -513,14 +457,14 @@ export default function Image( { <ToolsPanelItem label={ __( 'Alternative text' ) } isShownByDefault={ true } - hasValue={ () => alt !== '' } + hasValue={ () => !! alt } onDeselect={ () => setAttributes( { alt: undefined } ) } > <TextareaControl label={ __( 'Alternative text' ) } - value={ alt } + value={ alt || '' } onChange={ updateAlt } help={ <> @@ -538,11 +482,13 @@ export default function Image( { </ToolsPanelItem> ) } { isResizable && dimensionsControl } - <ResolutionTool - value={ sizeSlug } - onChange={ updateImage } - options={ imageSizeOptions } - /> + { !! imageSizeOptions.length && ( + <ResolutionTool + value={ sizeSlug } + onChange={ updateImage } + options={ imageSizeOptions } + /> + ) } { showLightboxToggle && ( <ToolsPanelItem hasValue={ () => !! lightbox } @@ -793,29 +739,14 @@ export default function Image( { which causes duplicated image upload. */ } { ! temporaryURL && controls } { img } - { showCaption && - ( ! RichText.isEmpty( caption ) || isSelected ) && ( - <RichText - identifier="caption" - className={ __experimentalGetElementClassName( - 'caption' - ) } - ref={ captionRef } - tagName="figcaption" - aria-label={ __( 'Image caption text' ) } - placeholder={ __( 'Add caption' ) } - value={ caption } - onChange={ ( value ) => - setAttributes( { caption: value } ) - } - inlineToolbar - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( getDefaultBlockName() ) - ) - } - /> - ) } + <Caption + attributes={ attributes } + setAttributes={ setAttributes } + isSelected={ isSelected } + insertBlocksAfter={ insertBlocksAfter } + label={ __( 'Image caption text' ) } + showToolbarButton={ hasNonContentControls } + /> </> ); } diff --git a/packages/block-library/src/image/save.js b/packages/block-library/src/image/save.js index 81565af09abab..0b750068e9a87 100644 --- a/packages/block-library/src/image/save.js +++ b/packages/block-library/src/image/save.js @@ -36,7 +36,9 @@ export default function save( { attributes } ) { const borderProps = getBorderClassesAndStyles( attributes ); const classes = classnames( { - [ `align${ align }` ]: align, + // All other align classes are handled by block supports. + // `{ align: 'none' }` is unique to transforms for the image block. + alignnone: 'none' === align, [ `size-${ sizeSlug }` ]: sizeSlug, 'is-resized': width || height, 'has-custom-border': diff --git a/packages/block-library/src/list-item/block.json b/packages/block-library/src/list-item/block.json index 41221f1c31772..06997c2ac23f8 100644 --- a/packages/block-library/src/list-item/block.json +++ b/packages/block-library/src/list-item/block.json @@ -12,16 +12,23 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "li", - "default": "", "__experimentalRole": "content" } }, "supports": { "className": false, "__experimentalSelector": "li", + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, "typography": { "fontSize": true, "lineHeight": true, diff --git a/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap b/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap index 7d7e2a15f08dc..5c9aeb8284b45 100644 --- a/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap +++ b/packages/block-library/src/navigation-link/test/__snapshots__/hooks.js.snap @@ -30,7 +30,7 @@ exports[`hooks enhanceNavigationLinkVariations enhances variations with icon and xmlns="http://www.w3.org/2000/svg" > <Path - d="M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 14c0 .3-.2.5-.5.5H6c-.3 0-.5-.2-.5-.5V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v12zM7 11h2V9H7v2zm0 4h2v-2H7v2zm3-4h7V9h-7v2zm0 4h7v-2h-7v2z" + d="M18 5.5H6a.5.5 0 0 0-.5.5v12a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5V6a.5.5 0 0 0-.5-.5ZM6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1 5h1.5v1.5H7V9Zm1.5 4.5H7V15h1.5v-1.5ZM10 9h7v1.5h-7V9Zm7 4.5h-7V15h7v-1.5Z" /> </SVG>, "isActive": [Function], @@ -47,7 +47,10 @@ exports[`hooks enhanceNavigationLinkVariations enhances variations with icon and xmlns="http://www.w3.org/2000/svg" > <Path - d="M7 5.5h10a.5.5 0 01.5.5v12a.5.5 0 01-.5.5H7a.5.5 0 01-.5-.5V6a.5.5 0 01.5-.5zM17 4H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2zm-1 3.75H8v1.5h8v-1.5zM8 11h8v1.5H8V11zm6 3.25H8v1.5h6v-1.5z" + d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" + /> + <Path + d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /> </SVG>, "isActive": [Function], @@ -83,7 +86,7 @@ exports[`hooks enhanceNavigationLinkVariations enhances variations with icon and xmlns="http://www.w3.org/2000/svg" > <Path - d="M20.1 11.2l-6.7-6.7c-.1-.1-.3-.2-.5-.2H5c-.4-.1-.8.3-.8.7v7.8c0 .2.1.4.2.5l6.7 6.7c.2.2.5.4.7.5s.6.2.9.2c.3 0 .6-.1.9-.2.3-.1.5-.3.8-.5l5.6-5.6c.4-.4.7-1 .7-1.6.1-.6-.2-1.2-.6-1.6zM19 13.4L13.4 19c-.1.1-.2.1-.3.2-.2.1-.4.1-.6 0-.1 0-.2-.1-.3-.2l-6.5-6.5V5.8h6.8l6.5 6.5c.2.2.2.4.2.6 0 .1 0 .3-.2.5zM9 8c-.6 0-1 .4-1 1s.4 1 1 1 1-.4 1-1-.4-1-1-1z" + d="M4.75 4a.75.75 0 0 0-.75.75v7.826c0 .2.08.39.22.53l6.72 6.716a2.313 2.313 0 0 0 3.276-.001l5.61-5.611-.531-.53.532.528a2.315 2.315 0 0 0 0-3.264L13.104 4.22a.75.75 0 0 0-.53-.22H4.75ZM19 12.576a.815.815 0 0 1-.236.574l-5.61 5.611a.814.814 0 0 1-1.153 0L5.5 12.264V5.5h6.763l6.5 6.502a.816.816 0 0 1 .237.574ZM8.75 9.75a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" /> </SVG>, "isActive": [Function], diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index f12d83e2fe2ea..2e94cddcc9bc2 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -24,7 +24,6 @@ import { getColorClassName, Warning, __experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown, - __experimentalUseBlockOverlayActive as useBlockOverlayActive, __experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients, useBlockEditingMode, } from '@wordpress/block-editor'; @@ -290,7 +289,13 @@ function Navigation( { const textDecoration = attributes.style?.typography?.textDecoration; - const hasBlockOverlay = useBlockOverlayActive( clientId ); + const hasBlockOverlay = useSelect( + ( select ) => + select( blockEditorStore ).__unstableHasActiveBlockOverlayActive( + clientId + ), + [ clientId ] + ); const isResponsive = 'never' !== overlayMenu; const blockProps = useBlockProps( { ref: navRef, diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index eb06e731ccb8f..3af85afd92522 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -75,7 +75,7 @@ function block_core_navigation_sort_menu_items_by_parent_id( $menu_items ) { function block_core_navigation_get_inner_blocks_from_unstable_location( $attributes ) { $menu_items = block_core_navigation_get_menu_items_at_location( $attributes['__unstableLocation'] ); if ( empty( $menu_items ) ) { - return ''; + return new WP_Block_List( array(), $attributes ); } $menu_items_by_parent_id = block_core_navigation_sort_menu_items_by_parent_id( $menu_items ); diff --git a/packages/block-library/src/page-list/convert-to-links-modal.js b/packages/block-library/src/page-list/convert-to-links-modal.js index cd4049fecff58..f47b5e3de259d 100644 --- a/packages/block-library/src/page-list/convert-to-links-modal.js +++ b/packages/block-library/src/page-list/convert-to-links-modal.js @@ -5,7 +5,7 @@ import { Button, Modal } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; export const convertDescription = __( - 'This page list is synced with the published pages on your site. Detach the page list to add, delete, or reorder pages yourself.' + "This navigation menu displays your website's pages. Editing it will enable you to add, delete, or reorder pages. However, new pages will no longer be added automatically." ); export function ConvertToLinksModal( { onClick, onClose, disabled } ) { @@ -30,7 +30,7 @@ export function ConvertToLinksModal( { onClick, onClose, disabled } ) { disabled={ disabled } onClick={ onClick } > - { __( 'Detach' ) } + { __( 'Edit' ) } </Button> </div> </Modal> diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 85f56f4a838f5..3fe4fbb34e102 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -13,10 +13,9 @@ "type": "string" }, "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", - "default": "", "__experimentalRole": "content" }, "dropCap": { diff --git a/packages/block-library/src/paragraph/edit.js b/packages/block-library/src/paragraph/edit.js index 37a9c2ab9b10a..ac766f69dd846 100644 --- a/packages/block-library/src/paragraph/edit.js +++ b/packages/block-library/src/paragraph/edit.js @@ -49,6 +49,48 @@ function hasDropCapDisabled( align ) { return align === ( isRTL() ? 'left' : 'right' ) || align === 'center'; } +function DropCapControl( { clientId, attributes, setAttributes } ) { + // Please do no add a useSelect call to the paragraph block unconditionaly. + // Every useSelect added to a (frequestly used) block will degrade the load + // and type bit. By moving it within InspectorControls, the subscription is + // now only added for the selected block(s). + const [ isDropCapFeatureEnabled ] = useSettings( 'typography.dropCap' ); + + if ( ! isDropCapFeatureEnabled ) { + return null; + } + + const { align, dropCap } = attributes; + + let helpText; + if ( hasDropCapDisabled( align ) ) { + helpText = __( 'Not available for aligned text.' ); + } else if ( dropCap ) { + helpText = __( 'Showing large initial letter.' ); + } else { + helpText = __( 'Toggle to show a large initial letter.' ); + } + + return ( + <ToolsPanelItem + hasValue={ () => !! dropCap } + label={ __( 'Drop cap' ) } + onDeselect={ () => setAttributes( { dropCap: undefined } ) } + resetAllFilter={ () => ( { dropCap: undefined } ) } + panelId={ clientId } + > + <ToggleControl + __nextHasNoMarginBottom + label={ __( 'Drop cap' ) } + checked={ !! dropCap } + onChange={ () => setAttributes( { dropCap: ! dropCap } ) } + help={ helpText } + disabled={ hasDropCapDisabled( align ) ? true : false } + /> + </ToolsPanelItem> + ); +} + function ParagraphBlock( { attributes, mergeBlocks, @@ -58,7 +100,6 @@ function ParagraphBlock( { clientId, } ) { const { align, content, direction, dropCap, placeholder } = attributes; - const [ isDropCapFeatureEnabled ] = useSettings( 'typography.dropCap' ); const blockProps = useBlockProps( { ref: useOnEnter( { clientId, content } ), className: classnames( { @@ -68,15 +109,6 @@ function ParagraphBlock( { style: { direction }, } ); - let helpText; - if ( hasDropCapDisabled( align ) ) { - helpText = __( 'Not available for aligned text.' ); - } else if ( dropCap ) { - helpText = __( 'Showing large initial letter.' ); - } else { - helpText = __( 'Toggle to show a large initial letter.' ); - } - return ( <> <BlockControls group="block"> @@ -98,32 +130,13 @@ function ParagraphBlock( { } /> </BlockControls> - { isDropCapFeatureEnabled && ( - <InspectorControls group="typography"> - <ToolsPanelItem - hasValue={ () => !! dropCap } - label={ __( 'Drop cap' ) } - onDeselect={ () => - setAttributes( { dropCap: undefined } ) - } - resetAllFilter={ () => ( { dropCap: undefined } ) } - panelId={ clientId } - > - <ToggleControl - __nextHasNoMarginBottom - label={ __( 'Drop cap' ) } - checked={ !! dropCap } - onChange={ () => - setAttributes( { dropCap: ! dropCap } ) - } - help={ helpText } - disabled={ - hasDropCapDisabled( align ) ? true : false - } - /> - </ToolsPanelItem> - </InspectorControls> - ) } + <InspectorControls group="typography"> + <DropCapControl + clientId={ clientId } + attributes={ attributes } + setAttributes={ setAttributes } + /> + </InspectorControls> <RichText identifier="content" tagName="p" @@ -154,13 +167,13 @@ function ParagraphBlock( { onReplace={ onReplace } onRemove={ onRemove } aria-label={ - content - ? __( 'Block: Paragraph' ) - : __( + RichText.isEmpty( content ) + ? __( 'Empty block; start writing or type forward slash to choose a block' ) + : __( 'Block: Paragraph' ) } - data-empty={ content ? false : true } + data-empty={ RichText.isEmpty( content ) } placeholder={ placeholder || __( 'Type / to choose a block' ) } data-custom-placeholder={ placeholder ? true : undefined } __unstableEmbedURLOnPaste diff --git a/packages/block-library/src/post-title/block.json b/packages/block-library/src/post-title/block.json index eda5332f24022..75a4fa3c3a60f 100644 --- a/packages/block-library/src/post-title/block.json +++ b/packages/block-library/src/post-title/block.json @@ -55,9 +55,7 @@ "__experimentalTextDecoration": true, "__experimentalLetterSpacing": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true, - "textTransform": true + "fontSize": true } } }, diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index ec6ea839385eb..def870e7ad2fb 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -8,10 +8,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" } diff --git a/packages/block-library/src/pullquote/block.json b/packages/block-library/src/pullquote/block.json index 54c4175d3161b..7fc81d5683bd1 100644 --- a/packages/block-library/src/pullquote/block.json +++ b/packages/block-library/src/pullquote/block.json @@ -8,16 +8,15 @@ "textdomain": "default", "attributes": { "value": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "p", "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "textAlign": { @@ -46,8 +45,7 @@ "__experimentalTextDecoration": true, "__experimentalLetterSpacing": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true + "fontSize": true } }, "__experimentalBorder": { diff --git a/packages/block-library/src/query-title/block.json b/packages/block-library/src/query-title/block.json index 2db349e55db90..65eb03d310c12 100644 --- a/packages/block-library/src/query-title/block.json +++ b/packages/block-library/src/query-title/block.json @@ -50,9 +50,7 @@ "__experimentalTextTransform": true, "__experimentalTextDecoration": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true, - "textTransform": true + "fontSize": true } } }, diff --git a/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js b/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js index 0214b9cf995a0..6aa57d69141c7 100644 --- a/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js +++ b/packages/block-library/src/query/edit/inspector-controls/taxonomy-controls.js @@ -6,6 +6,7 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { useState, useEffect } from '@wordpress/element'; import { useDebounce } from '@wordpress/compose'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -177,6 +178,7 @@ function TaxonomyItem( { taxonomy, termIds, onChange } ) { value={ value } onInputChange={ debouncedSearch } suggestions={ suggestions } + displayTransform={ decodeEntities } onChange={ onTermsChange } __experimentalShowHowTo={ false } /> diff --git a/packages/block-library/src/quote/block.json b/packages/block-library/src/quote/block.json index d0bb9005f8e63..9deca000efe06 100644 --- a/packages/block-library/src/quote/block.json +++ b/packages/block-library/src/quote/block.json @@ -17,10 +17,9 @@ "__experimentalRole": "content" }, "citation": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "cite", - "default": "", "__experimentalRole": "content" }, "align": { @@ -42,8 +41,7 @@ "__experimentalTextDecoration": true, "__experimentalLetterSpacing": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true + "fontSize": true } }, "color": { diff --git a/packages/block-library/src/quote/transforms.js b/packages/block-library/src/quote/transforms.js index 4e153a6399029..f9b3970433fad 100644 --- a/packages/block-library/src/quote/transforms.js +++ b/packages/block-library/src/quote/transforms.js @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { RichText } from '@wordpress/block-editor'; import { createBlock } from '@wordpress/blocks'; const transforms = { @@ -113,14 +114,14 @@ const transforms = { type: 'block', blocks: [ 'core/paragraph' ], transform: ( { citation }, innerBlocks ) => - citation - ? [ + RichText.isEmpty( citation ) + ? innerBlocks + : [ ...innerBlocks, createBlock( 'core/paragraph', { content: citation, } ), - ] - : innerBlocks, + ], }, { type: 'block', @@ -129,26 +130,26 @@ const transforms = { createBlock( 'core/group', { anchor }, - citation - ? [ + RichText.isEmpty( citation ) + ? innerBlocks + : [ ...innerBlocks, createBlock( 'core/paragraph', { content: citation, } ), ] - : innerBlocks ), }, ], ungroup: ( { citation }, innerBlocks ) => - citation - ? [ + RichText.isEmpty( citation ) + ? innerBlocks + : [ ...innerBlocks, createBlock( 'core/paragraph', { content: citation, } ), - ] - : innerBlocks, + ], }; export default transforms; diff --git a/packages/block-library/src/site-title/block.json b/packages/block-library/src/site-title/block.json index e936bad0e4515..4a2685e6941fc 100644 --- a/packages/block-library/src/site-title/block.json +++ b/packages/block-library/src/site-title/block.json @@ -56,11 +56,7 @@ "__experimentalFontWeight": true, "__experimentalLetterSpacing": true, "__experimentalDefaultControls": { - "fontSize": true, - "lineHeight": true, - "fontAppearance": true, - "letterSpacing": true, - "textTransform": true + "fontSize": true } } }, diff --git a/packages/block-library/src/social-link/icons/gravatar.js b/packages/block-library/src/social-link/icons/gravatar.js new file mode 100644 index 0000000000000..dad5ccb96a6f7 --- /dev/null +++ b/packages/block-library/src/social-link/icons/gravatar.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/primitives'; + +export const GravatarIcon = () => ( + <SVG width="24" height="24" viewBox="0 0 24 24" version="1.1"> + <Path d="M10.8001 4.69937V10.6494C10.8001 11.1001 10.9791 11.5323 11.2978 11.851C11.6165 12.1697 12.0487 12.3487 12.4994 12.3487C12.9501 12.3487 13.3824 12.1697 13.7011 11.851C14.0198 11.5323 14.1988 11.1001 14.1988 10.6494V6.69089C15.2418 7.05861 16.1371 7.75537 16.7496 8.67617C17.3622 9.59698 17.6589 10.6919 17.595 11.796C17.5311 12.9001 17.1101 13.9535 16.3954 14.7975C15.6807 15.6415 14.711 16.2303 13.6325 16.4753C12.5541 16.7202 11.4252 16.608 10.4161 16.1555C9.40691 15.703 8.57217 14.9348 8.03763 13.9667C7.50308 12.9985 7.29769 11.8828 7.45242 10.7877C7.60714 9.69266 8.11359 8.67755 8.89545 7.89537C9.20904 7.57521 9.38364 7.14426 9.38132 6.69611C9.37899 6.24797 9.19994 5.81884 8.88305 5.50195C8.56616 5.18506 8.13704 5.00601 7.68889 5.00369C7.24075 5.00137 6.80979 5.17597 6.48964 5.48956C5.09907 6.8801 4.23369 8.7098 4.04094 10.6669C3.84819 12.624 4.34 14.5873 5.43257 16.2224C6.52515 17.8575 8.15088 19.0632 10.0328 19.634C11.9146 20.2049 13.9362 20.1055 15.753 19.3529C17.5699 18.6003 19.0695 17.241 19.9965 15.5066C20.9234 13.7722 21.2203 11.7701 20.8366 9.84133C20.4528 7.91259 19.4122 6.17658 17.892 4.92911C16.3717 3.68163 14.466 2.99987 12.4994 3C12.0487 3 11.6165 3.17904 11.2978 3.49773C10.9791 3.81643 10.8001 4.24867 10.8001 4.69937Z" /> + </SVG> +); diff --git a/packages/block-library/src/social-link/icons/index.js b/packages/block-library/src/social-link/icons/index.js index 85de13090ad5d..46c8c6d5acc6e 100644 --- a/packages/block-library/src/social-link/icons/index.js +++ b/packages/block-library/src/social-link/icons/index.js @@ -15,6 +15,7 @@ export * from './foursquare'; export * from './goodreads'; export * from './google'; export * from './github'; +export * from './gravatar'; export * from './instagram'; export * from './lastfm'; export * from './linkedin'; diff --git a/packages/block-library/src/social-link/index.php b/packages/block-library/src/social-link/index.php index b84c7338cf31f..b203a662822f5 100644 --- a/packages/block-library/src/social-link/index.php +++ b/packages/block-library/src/social-link/index.php @@ -194,6 +194,10 @@ function block_core_social_link_services( $service = '', $field = '' ) { 'name' => 'GitHub', 'icon' => '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M12,2C6.477,2,2,6.477,2,12c0,4.419,2.865,8.166,6.839,9.489c0.5,0.09,0.682-0.218,0.682-0.484 c0-0.236-0.009-0.866-0.014-1.699c-2.782,0.602-3.369-1.34-3.369-1.34c-0.455-1.157-1.11-1.465-1.11-1.465 c-0.909-0.62,0.069-0.608,0.069-0.608c1.004,0.071,1.532,1.03,1.532,1.03c0.891,1.529,2.341,1.089,2.91,0.833 c0.091-0.647,0.349-1.086,0.635-1.337c-2.22-0.251-4.555-1.111-4.555-4.943c0-1.091,0.39-1.984,1.03-2.682 C6.546,8.54,6.202,7.524,6.746,6.148c0,0,0.84-0.269,2.75,1.025C10.295,6.95,11.15,6.84,12,6.836 c0.85,0.004,1.705,0.114,2.504,0.336c1.909-1.294,2.748-1.025,2.748-1.025c0.546,1.376,0.202,2.394,0.1,2.646 c0.64,0.699,1.026,1.591,1.026,2.682c0,3.841-2.337,4.687-4.565,4.935c0.359,0.307,0.679,0.917,0.679,1.852 c0,1.335-0.012,2.415-0.012,2.741c0,0.269,0.18,0.579,0.688,0.481C19.138,20.161,22,16.416,22,12C22,6.477,17.523,2,12,2z"></path></svg>', ), + 'gravatar' => array( + 'name' => 'Gravatar', + 'icon' => '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M10.8001 4.69937V10.6494C10.8001 11.1001 10.9791 11.5323 11.2978 11.851C11.6165 12.1697 12.0487 12.3487 12.4994 12.3487C12.9501 12.3487 13.3824 12.1697 13.7011 11.851C14.0198 11.5323 14.1988 11.1001 14.1988 10.6494V6.69089C15.2418 7.05861 16.1371 7.75537 16.7496 8.67617C17.3622 9.59698 17.6589 10.6919 17.595 11.796C17.5311 12.9001 17.1101 13.9535 16.3954 14.7975C15.6807 15.6415 14.711 16.2303 13.6325 16.4753C12.5541 16.7202 11.4252 16.608 10.4161 16.1555C9.40691 15.703 8.57217 14.9348 8.03763 13.9667C7.50308 12.9985 7.29769 11.8828 7.45242 10.7877C7.60714 9.69266 8.11359 8.67755 8.89545 7.89537C9.20904 7.57521 9.38364 7.14426 9.38132 6.69611C9.37899 6.24797 9.19994 5.81884 8.88305 5.50195C8.56616 5.18506 8.13704 5.00601 7.68889 5.00369C7.24075 5.00137 6.80979 5.17597 6.48964 5.48956C5.09907 6.8801 4.23369 8.7098 4.04094 10.6669C3.84819 12.624 4.34 14.5873 5.43257 16.2224C6.52515 17.8575 8.15088 19.0632 10.0328 19.634C11.9146 20.2049 13.9362 20.1055 15.753 19.3529C17.5699 18.6003 19.0695 17.241 19.9965 15.5066C20.9234 13.7722 21.2203 11.7701 20.8366 9.84133C20.4528 7.91259 19.4122 6.17658 17.892 4.92911C16.3717 3.68163 14.466 2.99987 12.4994 3C12.0487 3 11.6165 3.17904 11.2978 3.49773C10.9791 3.81643 10.8001 4.24867 10.8001 4.69937Z" /></svg>', + ), 'instagram' => array( 'name' => 'Instagram', 'icon' => '<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M12,4.622c2.403,0,2.688,0.009,3.637,0.052c0.877,0.04,1.354,0.187,1.671,0.31c0.42,0.163,0.72,0.358,1.035,0.673 c0.315,0.315,0.51,0.615,0.673,1.035c0.123,0.317,0.27,0.794,0.31,1.671c0.043,0.949,0.052,1.234,0.052,3.637 s-0.009,2.688-0.052,3.637c-0.04,0.877-0.187,1.354-0.31,1.671c-0.163,0.42-0.358,0.72-0.673,1.035 c-0.315,0.315-0.615,0.51-1.035,0.673c-0.317,0.123-0.794,0.27-1.671,0.31c-0.949,0.043-1.233,0.052-3.637,0.052 s-2.688-0.009-3.637-0.052c-0.877-0.04-1.354-0.187-1.671-0.31c-0.42-0.163-0.72-0.358-1.035-0.673 c-0.315-0.315-0.51-0.615-0.673-1.035c-0.123-0.317-0.27-0.794-0.31-1.671C4.631,14.688,4.622,14.403,4.622,12 s0.009-2.688,0.052-3.637c0.04-0.877,0.187-1.354,0.31-1.671c0.163-0.42,0.358-0.72,0.673-1.035 c0.315-0.315,0.615-0.51,1.035-0.673c0.317-0.123,0.794-0.27,1.671-0.31C9.312,4.631,9.597,4.622,12,4.622 M12,3 C9.556,3,9.249,3.01,8.289,3.054C7.331,3.098,6.677,3.25,6.105,3.472C5.513,3.702,5.011,4.01,4.511,4.511 c-0.5,0.5-0.808,1.002-1.038,1.594C3.25,6.677,3.098,7.331,3.054,8.289C3.01,9.249,3,9.556,3,12c0,2.444,0.01,2.751,0.054,3.711 c0.044,0.958,0.196,1.612,0.418,2.185c0.23,0.592,0.538,1.094,1.038,1.594c0.5,0.5,1.002,0.808,1.594,1.038 c0.572,0.222,1.227,0.375,2.185,0.418C9.249,20.99,9.556,21,12,21s2.751-0.01,3.711-0.054c0.958-0.044,1.612-0.196,2.185-0.418 c0.592-0.23,1.094-0.538,1.594-1.038c0.5-0.5,0.808-1.002,1.038-1.594c0.222-0.572,0.375-1.227,0.418-2.185 C20.99,14.751,21,14.444,21,12s-0.01-2.751-0.054-3.711c-0.044-0.958-0.196-1.612-0.418-2.185c-0.23-0.592-0.538-1.094-1.038-1.594 c-0.5-0.5-1.002-0.808-1.594-1.038c-0.572-0.222-1.227-0.375-2.185-0.418C14.751,3.01,14.444,3,12,3L12,3z M12,7.378 c-2.552,0-4.622,2.069-4.622,4.622S9.448,16.622,12,16.622s4.622-2.069,4.622-4.622S14.552,7.378,12,7.378z M12,15 c-1.657,0-3-1.343-3-3s1.343-3,3-3s3,1.343,3,3S13.657,15,12,15z M16.804,6.116c-0.596,0-1.08,0.484-1.08,1.08 s0.484,1.08,1.08,1.08c0.596,0,1.08-0.484,1.08-1.08S17.401,6.116,16.804,6.116z"></path></svg>', diff --git a/packages/block-library/src/social-link/socials-with-bg.scss b/packages/block-library/src/social-link/socials-with-bg.scss index 3ee9b4b5148a8..6a5906483b5df 100644 --- a/packages/block-library/src/social-link/socials-with-bg.scss +++ b/packages/block-library/src/social-link/socials-with-bg.scss @@ -78,6 +78,11 @@ color: #fff; } +.wp-social-link-gravatar { + background-color: #1d4fc4; + color: #fff; +} + .wp-social-link-instagram { background-color: #f00075; color: #fff; diff --git a/packages/block-library/src/social-link/socials-without-bg.scss b/packages/block-library/src/social-link/socials-without-bg.scss index aa84b5ab1433c..85e0c4f6af4d7 100644 --- a/packages/block-library/src/social-link/socials-without-bg.scss +++ b/packages/block-library/src/social-link/socials-without-bg.scss @@ -58,6 +58,10 @@ color: #ea4434; } +.wp-social-link-gravatar { + color: #1d4fc4; +} + .wp-social-link-instagram { color: #f00075; } diff --git a/packages/block-library/src/social-link/variations.js b/packages/block-library/src/social-link/variations.js index 5b03b85ae4e60..af3219d2084c8 100644 --- a/packages/block-library/src/social-link/variations.js +++ b/packages/block-library/src/social-link/variations.js @@ -19,6 +19,7 @@ import { GoodreadsIcon, GoogleIcon, GitHubIcon, + GravatarIcon, InstagramIcon, LastfmIcon, LinkedInIcon, @@ -160,6 +161,12 @@ const variations = [ title: 'GitHub', icon: GitHubIcon, }, + { + name: 'gravatar', + attributes: { service: 'gravatar' }, + title: 'Gravatar', + icon: GravatarIcon, + }, { name: 'instagram', attributes: { service: 'instagram' }, diff --git a/packages/block-library/src/social-links/style.scss b/packages/block-library/src/social-links/style.scss index 23c741e9819f9..1ad883bbb8884 100644 --- a/packages/block-library/src/social-links/style.scss +++ b/packages/block-library/src/social-links/style.scss @@ -95,14 +95,20 @@ } // This needs specificity because themes usually override it with things like .widget-area a. -.wp-block-social-links .wp-block-social-link .wp-block-social-link-anchor { - &, - &:hover, - &:active, - &:visited, - svg { - color: currentColor; - fill: currentColor; +.wp-block-social-links .wp-block-social-link.wp-social-link { + display: inline-block; + margin: 0; + padding: 0; + + .wp-block-social-link-anchor { + &, + &:hover, + &:active, + &:visited, + svg { + color: currentColor; + fill: currentColor; + } } } diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index 7c2d7e1f8eb14..fcb3efaad334f 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -21,11 +21,11 @@ import { renderToString } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; import { store as noticeStore } from '@wordpress/notices'; +import { tableOfContents as icon } from '@wordpress/icons'; /** * Internal dependencies */ -import icon from './icon'; import TableOfContentsList from './list'; import { linearToNestedHeadingList } from './utils'; import { useObserveHeadings } from './hooks'; diff --git a/packages/block-library/src/table-of-contents/icon.js b/packages/block-library/src/table-of-contents/icon.js deleted file mode 100644 index 02b642ea5e923..0000000000000 --- a/packages/block-library/src/table-of-contents/icon.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * WordPress dependencies - */ -import { SVG, Path } from '@wordpress/components'; - -export default ( - <SVG - xmlns="http://www.w3.org/2000/svg" - width="24" - height="24" - viewBox="0 0 24 24" - > - <Path - d="M15.1 15.8H20v-1.5h-4.9v1.5zm-4-8.6v1.5H20V7.2h-8.9zM6 13c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 3c-.6 0-1-.4-1-1s.4-1 1-1 1 .4 1 1-.4 1-1 1zm5-3c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zM6 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" - fill="#1e1e1e" - /> - </SVG> -); diff --git a/packages/block-library/src/table-of-contents/index.js b/packages/block-library/src/table-of-contents/index.js index 8952f0ff381bb..408538a7dcadb 100644 --- a/packages/block-library/src/table-of-contents/index.js +++ b/packages/block-library/src/table-of-contents/index.js @@ -1,10 +1,14 @@ +/** + * WordPress dependencies + */ +import { tableOfContents as icon } from '@wordpress/icons'; + /** * Internal dependencies */ import initBlock from '../utils/init-block'; import metadata from './block.json'; import edit from './edit'; -import icon from './icon'; import save from './save'; const { name } = metadata; diff --git a/packages/block-library/src/table/block.json b/packages/block-library/src/table/block.json index d1139d6c55add..470886a1247fe 100644 --- a/packages/block-library/src/table/block.json +++ b/packages/block-library/src/table/block.json @@ -12,10 +12,9 @@ "default": false }, "caption": { - "type": "string", - "source": "html", - "selector": "figcaption", - "default": "" + "type": "rich-text", + "source": "rich-text", + "selector": "figcaption" }, "head": { "type": "array", @@ -30,8 +29,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -75,8 +74,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", @@ -120,8 +119,8 @@ "selector": "td,th", "query": { "content": { - "type": "string", - "source": "html" + "type": "rich-text", + "source": "rich-text" }, "tag": { "type": "string", diff --git a/packages/block-library/src/table/edit.js b/packages/block-library/src/table/edit.js index 17a9e1ecfdd5b..b8f239a01095d 100644 --- a/packages/block-library/src/table/edit.js +++ b/packages/block-library/src/table/edit.js @@ -555,6 +555,7 @@ function TableEdit( { > <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize type="number" label={ __( 'Column count' ) } value={ initialColumnCount } @@ -564,6 +565,7 @@ function TableEdit( { /> <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize type="number" label={ __( 'Row count' ) } value={ initialRowCount } @@ -572,7 +574,7 @@ function TableEdit( { className="blocks-table__placeholder-input" /> <Button - className="blocks-table__placeholder-button" + __next40pxDefaultSize variant="primary" type="submit" > diff --git a/packages/block-library/src/table/editor.scss b/packages/block-library/src/table/editor.scss index ef7e1bdcd9aa3..0367ed0a9c5d9 100644 --- a/packages/block-library/src/table/editor.scss +++ b/packages/block-library/src/table/editor.scss @@ -58,27 +58,14 @@ display: flex; flex-direction: column; align-items: flex-start; - - > * { - margin-bottom: $grid-unit-10; - } + gap: $grid-unit-10; @include break-medium() { flex-direction: row; align-items: flex-end; - - > * { - margin-bottom: 0; - } } } .blocks-table__placeholder-input { width: $grid-unit-10 * 14; - margin-right: $grid-unit-10; - margin-bottom: 0; - - input { - height: $button-size; - } } diff --git a/packages/block-library/src/utils/caption.js b/packages/block-library/src/utils/caption.js new file mode 100644 index 0000000000000..e9055cc29df02 --- /dev/null +++ b/packages/block-library/src/utils/caption.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { usePrevious } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { + RichText, + BlockControls, + __experimentalGetElementClassName, +} from '@wordpress/block-editor'; +import { ToolbarButton } from '@wordpress/components'; +import { caption as captionIcon } from '@wordpress/icons'; +import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; + +export function Caption( { + key = 'caption', + attributes, + setAttributes, + isSelected, + insertBlocksAfter, + placeholder = __( 'Add caption' ), + label = __( 'Caption text' ), + showToolbarButton = true, + className, +} ) { + const caption = attributes[ key ]; + const prevCaption = usePrevious( caption ); + const isCaptionEmpty = RichText.isEmpty( caption ); + const isPrevCaptionEmpty = RichText.isEmpty( prevCaption ); + const [ showCaption, setShowCaption ] = useState( ! isCaptionEmpty ); + + // We need to show the caption when changes come from + // history navigation(undo/redo). + useEffect( () => { + if ( ! isCaptionEmpty && isPrevCaptionEmpty ) { + setShowCaption( true ); + } + }, [ isCaptionEmpty, isPrevCaptionEmpty ] ); + + useEffect( () => { + if ( ! isSelected && isCaptionEmpty ) { + setShowCaption( false ); + } + }, [ isSelected, isCaptionEmpty ] ); + + // Focus the caption when we click to add one. + const ref = useCallback( + ( node ) => { + if ( node && isCaptionEmpty ) { + node.focus(); + } + }, + [ isCaptionEmpty ] + ); + return ( + <> + { showToolbarButton && ( + <BlockControls group="block"> + <ToolbarButton + onClick={ () => { + setShowCaption( ! showCaption ); + if ( showCaption && caption ) { + setAttributes( { caption: undefined } ); + } + } } + icon={ captionIcon } + isPressed={ showCaption } + label={ + showCaption + ? __( 'Remove caption' ) + : __( 'Add caption' ) + } + /> + </BlockControls> + ) } + { showCaption && + ( ! RichText.isEmpty( caption ) || isSelected ) && ( + <RichText + identifier={ key } + tagName="figcaption" + className={ classnames( + className, + __experimentalGetElementClassName( 'caption' ) + ) } + ref={ ref } + aria-label={ label } + placeholder={ placeholder } + value={ caption } + onChange={ ( value ) => + setAttributes( { caption: value } ) + } + inlineToolbar + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( + createBlock( getDefaultBlockName() ) + ) + } + /> + ) } + </> + ); +} diff --git a/packages/block-library/src/utils/remove-anchor-tag.js b/packages/block-library/src/utils/remove-anchor-tag.js index 31d1877082f50..82e7b03423648 100644 --- a/packages/block-library/src/utils/remove-anchor-tag.js +++ b/packages/block-library/src/utils/remove-anchor-tag.js @@ -6,5 +6,6 @@ * @return {string} The value with anchor tags removed. */ export default function removeAnchorTag( value ) { - return value.replace( /<\/?a[^>]*>/g, '' ); + // To do: Refactor this to use rich text's removeFormat instead. + return value.toString().replace( /<\/?a[^>]*>/g, '' ); } diff --git a/packages/block-library/src/verse/block.json b/packages/block-library/src/verse/block.json index d0fffc8ae5076..846a1dc99caaf 100644 --- a/packages/block-library/src/verse/block.json +++ b/packages/block-library/src/verse/block.json @@ -9,10 +9,9 @@ "textdomain": "default", "attributes": { "content": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "pre", - "default": "", "__unstablePreserveWhiteSpace": true, "__experimentalRole": "content" }, @@ -40,8 +39,7 @@ "__experimentalTextTransform": true, "__experimentalTextDecoration": true, "__experimentalDefaultControls": { - "fontSize": true, - "fontAppearance": true + "fontSize": true } }, "spacing": { diff --git a/packages/block-library/src/video/block.json b/packages/block-library/src/video/block.json index debe6f20fe53f..5d4680f39e79a 100644 --- a/packages/block-library/src/video/block.json +++ b/packages/block-library/src/video/block.json @@ -15,8 +15,8 @@ "attribute": "autoplay" }, "caption": { - "type": "string", - "source": "html", + "type": "rich-text", + "source": "rich-text", "selector": "figcaption", "__experimentalRole": "content" }, diff --git a/packages/block-library/src/video/edit.js b/packages/block-library/src/video/edit.js index 5647aec95c006..88b2669e66f56 100644 --- a/packages/block-library/src/video/edit.js +++ b/packages/block-library/src/video/edit.js @@ -14,7 +14,6 @@ import { PanelBody, Spinner, Placeholder, - ToolbarButton, } from '@wordpress/components'; import { BlockControls, @@ -24,17 +23,14 @@ import { MediaUpload, MediaUploadCheck, MediaReplaceFlow, - RichText, useBlockProps, store as blockEditorStore, - __experimentalGetElementClassName, } from '@wordpress/block-editor'; -import { useRef, useEffect, useState, useCallback } from '@wordpress/element'; +import { useRef, useEffect } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { useInstanceId, usePrevious } from '@wordpress/compose'; +import { useInstanceId } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; -import { video as icon, caption as captionIcon } from '@wordpress/icons'; -import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; +import { video as icon } from '@wordpress/icons'; import { store as noticesStore } from '@wordpress/notices'; /** @@ -44,6 +40,7 @@ import { createUpgradedEmbedBlock } from '../embed/util'; import VideoCommonSettings from './edit-common-settings'; import TracksEditor from './tracks-editor'; import Tracks from './tracks'; +import { Caption } from '../utils/caption'; // Much of this description is duplicated from MediaPlaceholder. const placeholder = ( content ) => { @@ -76,9 +73,7 @@ function VideoEdit( { const instanceId = useInstanceId( VideoEdit ); const videoPlayer = useRef(); const posterImageButton = useRef(); - const { id, caption, controls, poster, src, tracks } = attributes; - const prevCaption = usePrevious( caption ); - const [ showCaption, setShowCaption ] = useState( !! caption ); + const { id, controls, poster, src, tracks } = attributes; const isTemporaryVideo = ! id && isBlobURL( src ); const mediaUpload = useSelect( ( select ) => select( blockEditorStore ).getSettings().mediaUpload, @@ -106,30 +101,6 @@ function VideoEdit( { } }, [ poster ] ); - // We need to show the caption when changes come from - // history navigation(undo/redo). - useEffect( () => { - if ( caption && ! prevCaption ) { - setShowCaption( true ); - } - }, [ caption, prevCaption ] ); - - // Focus the caption when we click to add one. - const captionRef = useCallback( - ( node ) => { - if ( node && ! caption ) { - node.focus(); - } - }, - [ caption ] - ); - - useEffect( () => { - if ( ! isSelected && ! caption ) { - setShowCaption( false ); - } - }, [ isSelected, caption ] ); - function onSelectVideo( media ) { if ( ! media || ! media.url ) { // In this case there was an error @@ -214,23 +185,6 @@ function VideoEdit( { return ( <> - <BlockControls group="block"> - <ToolbarButton - onClick={ () => { - setShowCaption( ! showCaption ); - if ( showCaption && caption ) { - setAttributes( { caption: undefined } ); - } - } } - icon={ captionIcon } - isPressed={ showCaption } - label={ - showCaption - ? __( 'Remove caption' ) - : __( 'Add caption' ) - } - /> - </BlockControls> <BlockControls> <TracksEditor tracks={ tracks } @@ -324,29 +278,13 @@ function VideoEdit( { </video> </Disabled> { isTemporaryVideo && <Spinner /> } - { showCaption && - ( ! RichText.isEmpty( caption ) || isSelected ) && ( - <RichText - identifier="caption" - tagName="figcaption" - className={ __experimentalGetElementClassName( - 'caption' - ) } - aria-label={ __( 'Video caption text' ) } - ref={ captionRef } - placeholder={ __( 'Add caption' ) } - value={ caption } - onChange={ ( value ) => - setAttributes( { caption: value } ) - } - inlineToolbar - __unstableOnSplitAtEnd={ () => - insertBlocksAfter( - createBlock( getDefaultBlockName() ) - ) - } - /> - ) } + <Caption + attributes={ attributes } + setAttributes={ setAttributes } + isSelected={ isSelected } + insertBlocksAfter={ insertBlocksAfter } + label={ __( 'Video caption text' ) } + /> </figure> </> ); diff --git a/packages/block-library/src/video/edit.native.js b/packages/block-library/src/video/edit.native.js index 6c1798756b40c..b974e61e10960 100644 --- a/packages/block-library/src/video/edit.native.js +++ b/packages/block-library/src/video/edit.native.js @@ -29,6 +29,7 @@ import { VIDEO_ASPECT_RATIO, VideoPlayer, InspectorControls, + RichText, store as blockEditorStore, } from '@wordpress/block-editor'; import { __, sprintf } from '@wordpress/i18n'; @@ -366,7 +367,7 @@ class VideoEdit extends Component { <BlockCaption accessible={ true } accessibilityLabelCreator={ ( caption ) => - ! caption + RichText.isEmpty( caption ) ? /* translators: accessibility text. Empty video caption. */ __( 'Video caption. Empty' ) : sprintf( diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 961cb338d7337..928d9d94740b4 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -42,6 +42,7 @@ "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", + "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", "change-case": "^4.1.2", "colord": "^2.7.0", diff --git a/packages/blocks/src/api/matchers.js b/packages/blocks/src/api/matchers.js index 7a6ac84891658..950f1539440a0 100644 --- a/packages/blocks/src/api/matchers.js +++ b/packages/blocks/src/api/matchers.js @@ -3,6 +3,11 @@ */ export { attr, prop, text, query } from 'hpq'; +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; + /** * Internal dependencies */ @@ -41,3 +46,10 @@ export function html( selector, multilineTag ) { return match.innerHTML; }; } + +export const richText = ( selector, preserveWhiteSpace ) => ( el ) => { + const target = selector ? el.querySelector( selector ) : el; + return target + ? RichTextData.fromHTMLElement( target, { preserveWhiteSpace } ) + : RichTextData.empty(); +}; diff --git a/packages/blocks/src/api/parser/get-block-attributes.js b/packages/blocks/src/api/parser/get-block-attributes.js index cc81c10800552..24faae7370463 100644 --- a/packages/blocks/src/api/parser/get-block-attributes.js +++ b/packages/blocks/src/api/parser/get-block-attributes.js @@ -9,12 +9,22 @@ import memoize from 'memize'; */ import { pipe } from '@wordpress/compose'; import { applyFilters } from '@wordpress/hooks'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies */ -import { attr, html, text, query, node, children, prop } from '../matchers'; -import { normalizeBlockType } from '../utils'; +import { + attr, + html, + text, + query, + node, + children, + prop, + richText, +} from '../matchers'; +import { normalizeBlockType, getDefault } from '../utils'; /** * Higher-order hpq matcher which enhances an attribute matcher to return true @@ -58,6 +68,9 @@ export const toBooleanAttributeMatcher = ( matcher ) => */ export function isOfType( value, type ) { switch ( type ) { + case 'rich-text': + return value instanceof RichTextData; + case 'string': return typeof value === 'string'; @@ -134,6 +147,7 @@ export function getBlockAttribute( case 'property': case 'html': case 'text': + case 'rich-text': case 'children': case 'node': case 'query': @@ -152,7 +166,7 @@ export function getBlockAttribute( } if ( value === undefined ) { - value = attributeSchema.default; + value = getDefault( attributeSchema ); } return value; @@ -211,6 +225,11 @@ export const matcherFromSource = memoize( ( sourceConfig ) => { return html( sourceConfig.selector, sourceConfig.multiline ); case 'text': return text( sourceConfig.selector ); + case 'rich-text': + return richText( + sourceConfig.selector, + sourceConfig.__unstablePreserveWhiteSpace + ); case 'children': return children( sourceConfig.selector ); case 'node': diff --git a/packages/blocks/src/api/raw-handling/html-to-blocks.js b/packages/blocks/src/api/raw-handling/html-to-blocks.js index 18630a9abdce4..1ee2bdc263126 100644 --- a/packages/blocks/src/api/raw-handling/html-to-blocks.js +++ b/packages/blocks/src/api/raw-handling/html-to-blocks.js @@ -1,7 +1,13 @@ +/** + * WordPress dependencies + */ +import { Platform } from '@wordpress/element'; + /** * Internal dependencies */ import { createBlock, findTransform } from '../factory'; +import parse from '../parser'; import { getBlockAttributes } from '../parser/get-block-attributes'; import { getRawTransforms } from './get-raw-transforms'; @@ -28,6 +34,13 @@ export function htmlToBlocks( html, handler ) { ); if ( ! rawTransform ) { + // Until the HTML block is supported in the native version, we'll parse it + // instead of creating the block to generate it as an unsupported block. + if ( Platform.isNative ) { + return parse( + `<!-- wp:html -->${ node.outerHTML }<!-- /wp:html -->` + ); + } return createBlock( // Should not be hardcoded. 'core/html', diff --git a/packages/blocks/src/api/raw-handling/test/paste-handler.js b/packages/blocks/src/api/raw-handling/test/paste-handler.js index 6938ad0d9c408..9b3dad39a0a5b 100644 --- a/packages/blocks/src/api/raw-handling/test/paste-handler.js +++ b/packages/blocks/src/api/raw-handling/test/paste-handler.js @@ -73,9 +73,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ @@ -113,9 +113,9 @@ describe( 'pasteHandler', () => { expect( console ).toHaveLogged(); + delete result.attributes.caption; expect( result.attributes ).toEqual( { hasFixedLayout: false, - caption: '', head: [ { cells: [ diff --git a/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index c43445c627226..60a94117b36e2 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -11,6 +11,7 @@ import a11yPlugin from 'colord/plugins/a11y'; import { Component, isValidElement } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { __unstableStripHTML as stripHTML } from '@wordpress/dom'; +import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies @@ -47,8 +48,12 @@ export function isUnmodifiedBlock( block ) { const newBlock = isUnmodifiedBlock[ block.name ]; const blockType = getBlockType( block.name ); - return Object.keys( blockType?.attributes ?? {} ).every( - ( key ) => newBlock.attributes[ key ] === block.attributes[ key ] + function isEqual( a, b ) { + return ( a?.valueOf() ?? a ) === ( b?.valueOf() ?? b ); + } + + return Object.keys( blockType?.attributes ?? {} ).every( ( key ) => + isEqual( newBlock.attributes[ key ], block.attributes[ key ] ) ); } @@ -243,6 +248,16 @@ export function getAccessibleBlockLabel( ); } +export function getDefault( attributeSchema ) { + if ( attributeSchema.default !== undefined ) { + return attributeSchema.default; + } + + if ( attributeSchema.type === 'rich-text' ) { + return new RichTextData(); + } +} + /** * Ensure attributes contains only values defined by block type, and merge * default values for missing attributes. @@ -264,9 +279,26 @@ export function __experimentalSanitizeBlockAttributes( name, attributes ) { const value = attributes[ key ]; if ( undefined !== value ) { - accumulator[ key ] = value; - } else if ( schema.hasOwnProperty( 'default' ) ) { - accumulator[ key ] = schema.default; + if ( schema.type === 'rich-text' ) { + if ( value instanceof RichTextData ) { + accumulator[ key ] = value; + } else if ( typeof value === 'string' ) { + accumulator[ key ] = + RichTextData.fromHTMLString( value ); + } + } else if ( + schema.type === 'string' && + value instanceof RichTextData + ) { + accumulator[ key ] = value.toHTMLString(); + } else { + accumulator[ key ] = value; + } + } else { + const _default = getDefault( schema ); + if ( undefined !== _default ) { + accumulator[ key ] = _default; + } } if ( [ 'node', 'children' ].indexOf( schema.source ) !== -1 ) { diff --git a/packages/commands/README.md b/packages/commands/README.md index 0a276d80c73e9..946b101e9ef27 100644 --- a/packages/commands/README.md +++ b/packages/commands/README.md @@ -62,8 +62,6 @@ _This package assumes that your code will run in an **ES2015+** environment. If Store definition for the commands namespace. -See how the Commands Store is being used in components like [site-hub](https://github.com/WordPress/gutenberg/blob/HEAD/packages/edit-site/src/components/site-hub/index.js#L23) and [document-actions](https://github.com/WordPress/gutenberg/blob/HEAD/packages/edit-post/src/components/header/document-actions/index.js#L14). - _Related_ - <https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore> diff --git a/packages/commands/src/store/index.js b/packages/commands/src/store/index.js index c3751f9ab4497..f3aa6f85f28b8 100644 --- a/packages/commands/src/store/index.js +++ b/packages/commands/src/store/index.js @@ -17,8 +17,6 @@ const STORE_NAME = 'core/commands'; /** * Store definition for the commands namespace. * - * See how the Commands Store is being used in components like [site-hub](https://github.com/WordPress/gutenberg/blob/HEAD/packages/edit-site/src/components/site-hub/index.js#L23) and [document-actions](https://github.com/WordPress/gutenberg/blob/HEAD/packages/edit-post/src/components/header/document-actions/index.js#L14). - * * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore * * @type {Object} diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 8ad7fb695eb3f..5e73e4319a1ba 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,15 +2,50 @@ ## Unreleased +### Enhancements + +- `FormToggle`: fix sass deprecation warning ([#56672](https://github.com/WordPress/gutenberg/pull/56672)). +- `QueryControls`: Add opt-in prop for 40px default size ([#56576](https://github.com/WordPress/gutenberg/pull/56576)). +- `CheckboxControl`: Add option to not render label ([#56158](https://github.com/WordPress/gutenberg/pull/56158)). +- `PaletteEdit`: Gradient pickers to use same width as color pickers ([#56801](https://github.com/WordPress/gutenberg/pull/56801)). +- `FocalPointPicker`: Add opt-in prop for 40px default size ([#56021](https://github.com/WordPress/gutenberg/pull/56021)). +- `DimensionControl`: Add opt-in prop for 40px default size ([#56805](https://github.com/WordPress/gutenberg/pull/56805)). +- `FontSizePicker`: Add opt-in prop for 40px default size ([#56804](https://github.com/WordPress/gutenberg/pull/56804)). + +### Bug Fix +- `PaletteEdit`: temporary custom gradient not saving ([#56896](https://github.com/WordPress/gutenberg/pull/56896)). +- `ToggleGroupControl`: react correctly to external controlled updates ([#56678](https://github.com/WordPress/gutenberg/pull/56678)). +- `ToolsPanel`: fix a performance issue ([#56770](https://github.com/WordPress/gutenberg/pull/56770)). +- `BorderControl`: adjust `BorderControlDropdown` Button size to fix misaligned border ([#56730](https://github.com/WordPress/gutenberg/pull/56730)). + +### Internal + +- `DropdownMenuV2Ariakit`: prevent prefix collapsing if all radios or checkboxes are unselected ([#56720](https://github.com/WordPress/gutenberg/pull/56720)). + +### Experimental + +- `Tabs`: implement new `tabId` prop ([#56883](https://github.com/WordPress/gutenberg/pull/56883)). + +### Experimental + +- `Tabs`: improve focus handling in controlled mode ([#56658](https://github.com/WordPress/gutenberg/pull/56658)). + +### Documentation + +- `Search`: Added links to storybook for more information on usage. ([#56815](https://github.com/WordPress/gutenberg/pull/56815)). +- `Spinner`: Added links to storybook for more information on usage. ([#56953](https://github.com/WordPress/gutenberg/pull/56953)). + ## 25.13.0 (2023-11-29) ### Enhancements - `FormToggle`: refine animation and improve high contrast styles ([#56515](https://github.com/WordPress/gutenberg/pull/56515)). - `Button`: Add focus rings to focusable disabled buttons ([#56383](https://github.com/WordPress/gutenberg/pull/56383)). +- `InserterButton`: Move mobile InserterButton from components package to block-editor package ([#56494](https://github.com/WordPress/gutenberg/pull/56494)) ### Bug Fix +- `DateTime`: Make the Timezone indication render a tooltip only when necessary. ([#56214](https://github.com/WordPress/gutenberg/pull/56214)). - `ToolsPanelItem`: Use useLayoutEffect to prevent rendering glitch for last panel item styling. ([#56536](https://github.com/WordPress/gutenberg/pull/56536)). - `FormTokenField`: Fix broken suggestions scrollbar when the `__experimentalExpandOnFocus` prop is defined ([#56426](https://github.com/WordPress/gutenberg/pull/56426)). - `FormTokenField`: `onFocus` prop is now typed as a React `FocusEvent` ([#56426](https://github.com/WordPress/gutenberg/pull/56426)). @@ -22,7 +57,7 @@ ### Documentation -- `Text` and `Heading`: improve docs around default values and truncation logic ([#56518](https://github.com/WordPress/gutenberg/pull/56518)) +- `Text` and `Heading`: improve docs around default values and truncation logic ([#56518](https://github.com/WordPress/gutenberg/pull/56518)) ### Internal diff --git a/packages/components/src/border-control/border-control-dropdown/component.tsx b/packages/components/src/border-control/border-control-dropdown/component.tsx index 4f43a6ed0ce55..3ee01bcda8f3b 100644 --- a/packages/components/src/border-control/border-control-dropdown/component.tsx +++ b/packages/components/src/border-control/border-control-dropdown/component.tsx @@ -149,6 +149,7 @@ const BorderControlDropdown = ( popoverControlsClassName, resetButtonClassName, showDropdownHeader, + size, __unstablePopoverProps, ...otherProps } = useBorderControlDropdown( props ); @@ -178,6 +179,7 @@ const BorderControlDropdown = ( tooltipPosition={ dropdownPosition } label={ __( 'Border color and style picker' ) } showTooltip={ true } + __next40pxDefaultSize={ size === '__unstable-large' ? true : false } > <span className={ indicatorWrapperClassName }> <ColorIndicator @@ -198,7 +200,7 @@ const BorderControlDropdown = ( <HStack> <StyledLabel>{ __( 'Border color' ) }</StyledLabel> <Button - isSmall + size="small" label={ __( 'Close border color' ) } icon={ closeSmall } onClick={ onClose } diff --git a/packages/components/src/border-control/border-control-dropdown/hook.ts b/packages/components/src/border-control/border-control-dropdown/hook.ts index b60aa52a34e2e..5366babc266c6 100644 --- a/packages/components/src/border-control/border-control-dropdown/hook.ts +++ b/packages/components/src/border-control/border-control-dropdown/hook.ts @@ -57,8 +57,8 @@ export function useBorderControlDropdown( // Generate class names. const cx = useCx(); const classes = useMemo( () => { - return cx( styles.borderControlDropdown( size ), className ); - }, [ className, cx, size ] ); + return cx( styles.borderControlDropdown, className ); + }, [ className, cx ] ); const indicatorClassName = useMemo( () => { return cx( styles.borderColorIndicator ); @@ -95,6 +95,7 @@ export function useBorderControlDropdown( popoverContentClassName, popoverControlsClassName, resetButtonClassName, + size, __experimentalIsRenderedInSidebar, }; } diff --git a/packages/components/src/border-control/styles.ts b/packages/components/src/border-control/styles.ts index 322e9563d58a4..1a2263b9fb6ff 100644 --- a/packages/components/src/border-control/styles.ts +++ b/packages/components/src/border-control/styles.ts @@ -59,18 +59,11 @@ export const wrapperHeight = ( size?: 'default' | '__unstable-large' ) => { `; }; -export const borderControlDropdown = ( - size?: 'default' | '__unstable-large' -) => css` +export const borderControlDropdown = css` background: #fff; && > button { - /* - * Override button component styles to fit within BorderControl - * regardless of size. - */ - height: ${ size === '__unstable-large' ? '40px' : '30px' }; - width: ${ size === '__unstable-large' ? '40px' : '30px' }; + aspect-ratio: 1; padding: 0; display: flex; align-items: center; diff --git a/packages/components/src/checkbox-control/README.md b/packages/components/src/checkbox-control/README.md index 12f792ea8577b..66f3cae2be379 100644 --- a/packages/components/src/checkbox-control/README.md +++ b/packages/components/src/checkbox-control/README.md @@ -77,11 +77,12 @@ const MyCheckboxControl = () => { The set of props accepted by the component will be specified below. Props not included in this set will be applied to the input element. -#### `label`: `string` +#### `label`: `string|false` A label for the input field, that appears at the side of the checkbox. The prop will be rendered as content a label element. If no prop is passed an empty label is rendered. +If the prop is set to false no label is rendered. - Required: No diff --git a/packages/components/src/checkbox-control/index.tsx b/packages/components/src/checkbox-control/index.tsx index cd70bb8485ac7..54a9952d19949 100644 --- a/packages/components/src/checkbox-control/index.tsx +++ b/packages/components/src/checkbox-control/index.tsx @@ -125,12 +125,14 @@ export function CheckboxControl( /> ) : null } </span> - <label - className="components-checkbox-control__label" - htmlFor={ id } - > - { label } - </label> + { label && ( + <label + className="components-checkbox-control__label" + htmlFor={ id } + > + { label } + </label> + ) } </BaseControl> ); } diff --git a/packages/components/src/checkbox-control/test/__snapshots__/index.tsx.snap b/packages/components/src/checkbox-control/test/__snapshots__/index.tsx.snap index 408f18d8c7e77..f3bdccc1ccff6 100644 --- a/packages/components/src/checkbox-control/test/__snapshots__/index.tsx.snap +++ b/packages/components/src/checkbox-control/test/__snapshots__/index.tsx.snap @@ -5,14 +5,14 @@ Snapshot Diff: - First value + Second value -@@ -8,17 +8,31 @@ +@@ -8,13 +8,27 @@ <span class="components-checkbox-control__input-container" > <input class="components-checkbox-control__input" -- id="inspector-checkbox-control-5" -+ id="inspector-checkbox-control-6" +- id="inspector-checkbox-control-6" ++ id="inspector-checkbox-control-7" type="checkbox" value="1" + /> @@ -31,11 +31,6 @@ Snapshot Diff: /> + </svg> </span> - <label - class="components-checkbox-control__label" -- for="inspector-checkbox-control-5" -+ for="inspector-checkbox-control-6" - /> </div> </div> </div> diff --git a/packages/components/src/checkbox-control/test/index.tsx b/packages/components/src/checkbox-control/test/index.tsx index 061087fdc0901..55e1447661392 100644 --- a/packages/components/src/checkbox-control/test/index.tsx +++ b/packages/components/src/checkbox-control/test/index.tsx @@ -60,6 +60,13 @@ describe( 'CheckboxControl', () => { expect( label ).toBeInTheDocument(); } ); + it( 'should not render label element if label is set to false', () => { + render( <CheckboxControl label={ false } /> ); + + const label = screen.queryByRole( 'label' ); + expect( label ).not.toBeInTheDocument(); + } ); + it( 'should render a checkbox in an indeterminate state', () => { render( <CheckboxControl indeterminate /> ); expect( getInput() ).toHaveProperty( 'indeterminate', true ); diff --git a/packages/components/src/checkbox-control/types.ts b/packages/components/src/checkbox-control/types.ts index 07ab55493fc66..48a1d02affb70 100644 --- a/packages/components/src/checkbox-control/types.ts +++ b/packages/components/src/checkbox-control/types.ts @@ -19,9 +19,10 @@ export type CheckboxControlProps = Pick< /** * A label for the input field, that appears at the side of the checkbox. * The prop will be rendered as content a label element. If no prop is - * passed an empty label is rendered. + * passed an empty label is rendered. If the prop is set to false no label + * is rendered. */ - label?: string; + label?: string | false; /** * If checked is true the checkbox will be checked. If checked is false the * checkbox will be unchecked. If no value is passed the checkbox will be diff --git a/packages/components/src/custom-select-control/test/index.js b/packages/components/src/custom-select-control/test/index.js index 150afe4aa75f5..52bb841a4f953 100644 --- a/packages/components/src/custom-select-control/test/index.js +++ b/packages/components/src/custom-select-control/test/index.js @@ -4,54 +4,205 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies */ import CustomSelectControl from '..'; -describe( 'CustomSelectControl', () => { - it( 'Captures the keypress event and does not let it propagate', async () => { - const user = userEvent.setup(); - const onKeyDown = jest.fn(); - const options = [ - { - key: 'one', - name: 'Option one', - }, - { - key: 'two', - name: 'Option two', - }, - { - key: 'three', - name: 'Option three', +const customClass = 'amber-skies'; + +const props = { + label: 'label!', + options: [ + { + key: 'flower1', + name: 'violets', + }, + { + key: 'flower2', + name: 'crimson clover', + className: customClass, + }, + { + key: 'flower3', + name: 'poppy', + }, + { + key: 'color1', + name: 'amber', + className: customClass, + }, + { + key: 'color2', + name: 'aquamarine', + style: { + backgroundColor: 'rgb(127, 255, 212)', + rotate: '13deg', }, - ]; + }, + ], + __nextUnconstrainedWidth: true, +}; - render( - <div - // This role="none" is required to prevent an eslint warning about accessibility. - role="none" - onKeyDown={ onKeyDown } - > - <CustomSelectControl - options={ options } - __nextUnconstrainedWidth - /> - </div> +const ControlledCustomSelectControl = ( { options } ) => { + const [ value, setValue ] = useState( options[ 0 ] ); + return ( + <CustomSelectControl + { ...props } + onChange={ ( { selectedItem } ) => setValue( selectedItem ) } + value={ options.find( ( option ) => option.key === value.key ) } + /> + ); +}; + +describe.each( [ + [ 'uncontrolled', CustomSelectControl ], + [ 'controlled', ControlledCustomSelectControl ], +] )( 'CustomSelectControl %s', ( ...modeAndComponent ) => { + const [ , Component ] = modeAndComponent; + + it( 'Should replace the initial selection when a new item is selected', async () => { + const user = userEvent.setup(); + + render( <Component { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.click( currentSelectedItem ); + + await user.click( + screen.getByRole( 'option', { + name: 'crimson clover', + } ) + ); + + expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' ); + + await user.click( currentSelectedItem ); + + await user.click( + screen.getByRole( 'option', { + name: 'poppy', + } ) + ); + + expect( currentSelectedItem ).toHaveTextContent( 'poppy' ); + } ); + + it( 'Should keep current selection if dropdown is closed without changing selection', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toBeVisible(); + + await user.keyboard( '{escape}' ); + expect( + screen.queryByRole( 'listbox', { + name: 'label!', + } ) + ).not.toBeInTheDocument(); + + expect( currentSelectedItem ).toHaveTextContent( + props.options[ 0 ].name + ); + } ); + + it( 'Should apply class only to options that have a className defined', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + await user.click( + screen.getByRole( 'button', { + expanded: false, + } ) + ); + + // return an array of items _with_ a className added + const itemsWithClass = props.options.filter( + ( option ) => option.className !== undefined + ); + + // assert against filtered array + itemsWithClass.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).toHaveClass( + customClass + ) + ); + + // return an array of items _without_ a className added + const itemsWithoutClass = props.options.filter( + ( option ) => option.className === undefined + ); + + // assert against filtered array + itemsWithoutClass.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).not.toHaveClass( + customClass + ) + ); + } ); + + it( 'Should apply styles only to options that have styles defined', async () => { + const user = userEvent.setup(); + const customStyles = + 'background-color: rgb(127, 255, 212); rotate: 13deg;'; + + render( <CustomSelectControl { ...props } /> ); + + await user.click( + screen.getByRole( 'button', { + expanded: false, + } ) + ); + + // return an array of items _with_ styles added + const styledItems = props.options.filter( + ( option ) => option.style !== undefined ); - const toggleButton = screen.getByRole( 'button' ); - await user.click( toggleButton ); - const customSelect = screen.getByRole( 'listbox' ); - await user.type( customSelect, '{enter}' ); + // assert against filtered array + styledItems.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).toHaveStyle( + customStyles + ) + ); + + // return an array of items _without_ styles added + const unstyledItems = props.options.filter( + ( option ) => option.style === undefined + ); - expect( onKeyDown ).toHaveBeenCalledTimes( 0 ); + // assert against filtered array + unstyledItems.map( ( { name } ) => + expect( screen.getByRole( 'option', { name } ) ).not.toHaveStyle( + customStyles + ) + ); } ); it( 'does not show selected hint by default', () => { render( <CustomSelectControl + { ...props } label="Custom select" options={ [ { @@ -60,7 +211,6 @@ describe( 'CustomSelectControl', () => { __experimentalHint: 'Hint', }, ] } - __nextUnconstrainedWidth /> ); expect( @@ -71,6 +221,7 @@ describe( 'CustomSelectControl', () => { it( 'shows selected hint when __experimentalShowSelectedHint is set', () => { render( <CustomSelectControl + { ...props } label="Custom select" options={ [ { @@ -80,11 +231,192 @@ describe( 'CustomSelectControl', () => { }, ] } __experimentalShowSelectedHint - __nextUnconstrainedWidth /> ); expect( screen.getByRole( 'button', { name: 'Custom select' } ) ).toHaveTextContent( 'Hint' ); } ); + + describe( 'Keyboard behavior and accessibility', () => { + it( 'Captures the keypress event and does not let it propagate', async () => { + const user = userEvent.setup(); + const onKeyDown = jest.fn(); + + render( + <div + // This role="none" is required to prevent an eslint warning about accessibility. + role="none" + onKeyDown={ onKeyDown } + > + <CustomSelectControl { ...props } /> + </div> + ); + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + await user.click( currentSelectedItem ); + + const customSelect = screen.getByRole( 'listbox', { + name: 'label!', + } ); + await user.type( customSelect, '{enter}' ); + + expect( onKeyDown ).toHaveBeenCalledTimes( 0 ); + } ); + + it( 'Should be able to change selection using keyboard', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + expect( currentSelectedItem ).toHaveFocus(); + + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toHaveFocus(); + + await user.keyboard( '{arrowdown}' ); + await user.keyboard( '{enter}' ); + + expect( currentSelectedItem ).toHaveTextContent( 'crimson clover' ); + } ); + + it( 'Should be able to type characters to select matching options', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + await user.keyboard( '{enter}' ); + expect( + screen.getByRole( 'listbox', { + name: 'label!', + } ) + ).toHaveFocus(); + + await user.keyboard( '{a}' ); + await user.keyboard( '{enter}' ); + expect( currentSelectedItem ).toHaveTextContent( 'amber' ); + } ); + + it( 'Can change selection with a focused input and closed dropdown if typed characters match an option', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + expect( currentSelectedItem ).toHaveFocus(); + + await user.keyboard( '{a}' ); + await user.keyboard( '{q}' ); + + expect( + screen.queryByRole( 'listbox', { + name: 'label!', + hidden: true, + } ) + ).not.toBeInTheDocument(); + + await user.keyboard( '{enter}' ); + expect( currentSelectedItem ).toHaveTextContent( 'aquamarine' ); + } ); + + it( 'Should have correct aria-selected value for selections', async () => { + const user = userEvent.setup(); + + render( <CustomSelectControl { ...props } /> ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.click( currentSelectedItem ); + + // get all items except for first option + const unselectedItems = props.options.filter( + ( { name } ) => name !== props.options[ 0 ].name + ); + + // assert that all other items have aria-selected="false" + unselectedItems.map( ( { name } ) => + expect( + screen.getByRole( 'option', { name, selected: false } ) + ).toBeVisible() + ); + + // assert that first item has aria-selected="true" + expect( + screen.getByRole( 'option', { + name: props.options[ 0 ].name, + selected: true, + } ) + ).toBeVisible(); + + // change the current selection + await user.click( screen.getByRole( 'option', { name: 'poppy' } ) ); + + // click button to mount listbox with options again + await user.click( currentSelectedItem ); + + // check that first item is has aria-selected="false" after new selection + expect( + screen.getByRole( 'option', { + name: props.options[ 0 ].name, + selected: false, + } ) + ).toBeVisible(); + + // check that new selected item now has aria-selected="true" + expect( + screen.getByRole( 'option', { + name: 'poppy', + selected: true, + } ) + ).toBeVisible(); + } ); + + it( 'Should call custom event handlers', async () => { + const user = userEvent.setup(); + const onFocusMock = jest.fn(); + const onBlurMock = jest.fn(); + + render( + <CustomSelectControl + { ...props } + onFocus={ onFocusMock } + onBlur={ onBlurMock } + /> + ); + + const currentSelectedItem = screen.getByRole( 'button', { + expanded: false, + } ); + + await user.tab(); + + expect( currentSelectedItem ).toHaveFocus(); + expect( onFocusMock ).toHaveBeenCalledTimes( 1 ); + + await user.tab(); + expect( currentSelectedItem ).not.toHaveFocus(); + expect( onBlurMock ).toHaveBeenCalledTimes( 1 ); + } ); + } ); } ); diff --git a/packages/components/src/date-time/time/timezone.tsx b/packages/components/src/date-time/time/timezone.tsx index 9fac1ec094ed8..9b08eac6307aa 100644 --- a/packages/components/src/date-time/time/timezone.tsx +++ b/packages/components/src/date-time/time/timezone.tsx @@ -32,12 +32,24 @@ const TimeZone = () => { ? timezone.abbr : `UTC${ offsetSymbol }${ timezone.offset }`; + // Replace underscore with space in strings like `America/Costa_Rica`. + const prettyTimezoneString = timezone.string.replace( '_', ' ' ); + const timezoneDetail = 'UTC' === timezone.string ? __( 'Coordinated Universal Time' ) - : `(${ zoneAbbr }) ${ timezone.string.replace( '_', ' ' ) }`; - - return ( + : `(${ zoneAbbr }) ${ prettyTimezoneString }`; + + // When the prettyTimezoneString is empty, there is no additional timezone + // detail information to show in a Tooltip. + const hasNoAdditionalTimezoneDetail = + prettyTimezoneString.trim().length === 0; + + return hasNoAdditionalTimezoneDetail ? ( + <StyledComponent className="components-datetime__timezone"> + { zoneAbbr } + </StyledComponent> + ) : ( <Tooltip placement="top" text={ timezoneDetail }> <StyledComponent className="components-datetime__timezone"> { zoneAbbr } diff --git a/packages/components/src/dimension-control/index.tsx b/packages/components/src/dimension-control/index.tsx index 0fbdd62b58a00..38ad5b2f85ccc 100644 --- a/packages/components/src/dimension-control/index.tsx +++ b/packages/components/src/dimension-control/index.tsx @@ -42,6 +42,7 @@ import type { SelectControlSingleSelectionProps } from '../select-control/types' */ export function DimensionControl( props: DimensionControlProps ) { const { + __next40pxDefaultSize = false, label, value, sizes = sizesTable, @@ -85,6 +86,7 @@ export function DimensionControl( props: DimensionControlProps ) { return ( <SelectControl + __next40pxDefaultSize={ __next40pxDefaultSize } className={ classnames( className, 'block-editor-dimension-control' diff --git a/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap index 3934085f03528..9b3f8b2d90e98 100644 --- a/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap +++ b/packages/components/src/dimension-control/test/__snapshots__/index.test.js.snap @@ -764,7 +764,7 @@ exports[`DimensionControl rendering renders with icon and custom icon label 1`] xmlns="http://www.w3.org/2000/svg" > <path - d="M18 11.2h-5.2V6h-1.6v5.2H6v1.6h5.2V18h1.6v-5.2H18z" + d="M11 12.5V17.5H12.5V12.5H17.5V11H12.5V6H11V11H6V12.5H11Z" /> </svg> Margin @@ -1057,7 +1057,7 @@ exports[`DimensionControl rendering renders with icon and default icon label 1`] xmlns="http://www.w3.org/2000/svg" > <path - d="M18 11.2h-5.2V6h-1.6v5.2H6v1.6h5.2V18h1.6v-5.2H18z" + d="M11 12.5V17.5H12.5V12.5H17.5V11H12.5V6H11V11H6V12.5H11Z" /> </svg> Margin diff --git a/packages/components/src/dimension-control/types.ts b/packages/components/src/dimension-control/types.ts index 534b80053db96..671454f18c8a9 100644 --- a/packages/components/src/dimension-control/types.ts +++ b/packages/components/src/dimension-control/types.ts @@ -45,4 +45,10 @@ export type DimensionControlProps = { * @default '' */ className?: string; + /** + * Start opting into the larger default height that will become the default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; }; diff --git a/packages/components/src/dropdown-menu-v2-ariakit/styles.ts b/packages/components/src/dropdown-menu-v2-ariakit/styles.ts index 465bdb1aebb30..eaa249ae86b78 100644 --- a/packages/components/src/dropdown-menu-v2-ariakit/styles.ts +++ b/packages/components/src/dropdown-menu-v2-ariakit/styles.ts @@ -212,6 +212,18 @@ export const ItemPrefixWrapper = styled.span` /* Always occupy the first column, even when auto-collapsing */ grid-column: 1; + /* + * Even when the item is not checked, occupy the same screen space to avoid + * the space collapside when no items are checked. + */ + ${ DropdownMenuCheckboxItem } > &, + ${ DropdownMenuRadioItem } > & { + /* Same width as the check icons */ + min-width: ${ space( 6 ) }; + } + + ${ DropdownMenuCheckboxItem } > &, + ${ DropdownMenuRadioItem } > &, &:not( :empty ) { margin-inline-end: ${ space( 2 ) }; } diff --git a/packages/components/src/focal-point-picker/controls.tsx b/packages/components/src/focal-point-picker/controls.tsx index f204d5736779c..40cf8d215704b 100644 --- a/packages/components/src/focal-point-picker/controls.tsx +++ b/packages/components/src/focal-point-picker/controls.tsx @@ -23,6 +23,7 @@ const noop = () => {}; export default function FocalPointPickerControls( { __nextHasNoMarginBottom, + __next40pxDefaultSize, hasHelpText, onChange = noop, point = { @@ -51,8 +52,10 @@ export default function FocalPointPickerControls( { className="focal-point-picker__controls" __nextHasNoMarginBottom={ __nextHasNoMarginBottom } hasHelpText={ hasHelpText } + gap={ 4 } > <FocalPointUnitControl + __next40pxDefaultSize={ __next40pxDefaultSize } label={ __( 'Left' ) } aria-label={ __( 'Focal point left position' ) } value={ [ valueX, '%' ].join( '' ) } @@ -66,6 +69,7 @@ export default function FocalPointPickerControls( { dragDirection="e" /> <FocalPointUnitControl + __next40pxDefaultSize={ __next40pxDefaultSize } label={ __( 'Top' ) } aria-label={ __( 'Focal point top position' ) } value={ [ valueY, '%' ].join( '' ) } diff --git a/packages/components/src/focal-point-picker/index.tsx b/packages/components/src/focal-point-picker/index.tsx index 65efdc322cf0c..1b4c4d9dff966 100644 --- a/packages/components/src/focal-point-picker/index.tsx +++ b/packages/components/src/focal-point-picker/index.tsx @@ -84,6 +84,7 @@ const GRID_OVERLAY_TIMEOUT = 600; */ export function FocalPointPicker( { __nextHasNoMarginBottom, + __next40pxDefaultSize = false, autoPlay = true, className, help, @@ -273,6 +274,7 @@ export function FocalPointPicker( { </MediaWrapper> <Controls __nextHasNoMarginBottom={ __nextHasNoMarginBottom } + __next40pxDefaultSize={ __next40pxDefaultSize } hasHelpText={ !! help } point={ { x, y } } onChange={ ( value ) => { diff --git a/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts b/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts index f405b02959f9d..3df6d1bc6eafb 100644 --- a/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts +++ b/packages/components/src/focal-point-picker/styles/focal-point-picker-style.ts @@ -53,7 +53,7 @@ export const MediaPlaceholder = styled.div` `; export const StyledUnitControl = styled( UnitControl )` - width: 100px; + width: 100%; `; const deprecatedBottomMargin = ( { diff --git a/packages/components/src/focal-point-picker/types.ts b/packages/components/src/focal-point-picker/types.ts index 81b683b7ce16c..bd66ae02451a9 100644 --- a/packages/components/src/focal-point-picker/types.ts +++ b/packages/components/src/focal-point-picker/types.ts @@ -26,6 +26,12 @@ export type FocalPointPickerProps = Pick< * @default false */ __nextHasNoMarginBottom?: boolean; + /** + * Start opting into the larger default height that will become the default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; /** * Autoplays HTML5 video. This only applies to video sources (`url`). * @@ -62,6 +68,7 @@ export type FocalPointPickerProps = Pick< export type FocalPointPickerControlsProps = { __nextHasNoMarginBottom?: boolean; + __next40pxDefaultSize?: boolean; /** * A bit of extra bottom margin will be added if a `help` text * needs to be rendered under it. diff --git a/packages/components/src/font-size-picker/font-size-picker-select.tsx b/packages/components/src/font-size-picker/font-size-picker-select.tsx index d3fc2ffe4a61f..32438cfab8115 100644 --- a/packages/components/src/font-size-picker/font-size-picker-select.tsx +++ b/packages/components/src/font-size-picker/font-size-picker-select.tsx @@ -27,6 +27,7 @@ const CUSTOM_OPTION: FontSizePickerSelectOption = { const FontSizePickerSelect = ( props: FontSizePickerSelectProps ) => { const { + __next40pxDefaultSize, fontSizes, value, disableCustomFontSizes, @@ -67,6 +68,7 @@ const FontSizePickerSelect = ( props: FontSizePickerSelectProps ) => { return ( <CustomSelectControl + __next40pxDefaultSize={ __next40pxDefaultSize } __nextUnconstrainedWidth className="components-font-size-picker__select" label={ __( 'Font size' ) } diff --git a/packages/components/src/font-size-picker/font-size-picker-toggle-group.tsx b/packages/components/src/font-size-picker/font-size-picker-toggle-group.tsx index 697d9e11b67e4..69c86ecfc817b 100644 --- a/packages/components/src/font-size-picker/font-size-picker-toggle-group.tsx +++ b/packages/components/src/font-size-picker/font-size-picker-toggle-group.tsx @@ -14,10 +14,18 @@ import { T_SHIRT_ABBREVIATIONS, T_SHIRT_NAMES } from './constants'; import type { FontSizePickerToggleGroupProps } from './types'; const FontSizePickerToggleGroup = ( props: FontSizePickerToggleGroupProps ) => { - const { fontSizes, value, __nextHasNoMarginBottom, size, onChange } = props; + const { + fontSizes, + value, + __nextHasNoMarginBottom, + __next40pxDefaultSize, + size, + onChange, + } = props; return ( <ToggleGroupControl __nextHasNoMarginBottom={ __nextHasNoMarginBottom } + __next40pxDefaultSize={ __next40pxDefaultSize } label={ __( 'Font size' ) } hideLabelFromVision value={ value } diff --git a/packages/components/src/font-size-picker/index.tsx b/packages/components/src/font-size-picker/index.tsx index e454b3093bf6a..38488cf9fbb0e 100644 --- a/packages/components/src/font-size-picker/index.tsx +++ b/packages/components/src/font-size-picker/index.tsx @@ -45,6 +45,7 @@ const UnforwardedFontSizePicker = ( const { /** Start opting into the new margin-free styles that will become the default in a future version. */ __nextHasNoMarginBottom = false, + __next40pxDefaultSize = false, fallbackFontSize, fontSizes = [], disableCustomFontSizes = false, @@ -165,6 +166,7 @@ const UnforwardedFontSizePicker = ( shouldUseSelectControl && ! showCustomValueControl && ( <FontSizePickerSelect + __next40pxDefaultSize={ __next40pxDefaultSize } fontSizes={ fontSizes } value={ value } disableCustomFontSizes={ disableCustomFontSizes } @@ -194,6 +196,7 @@ const UnforwardedFontSizePicker = ( fontSizes={ fontSizes } value={ value } __nextHasNoMarginBottom={ __nextHasNoMarginBottom } + __next40pxDefaultSize={ __next40pxDefaultSize } size={ size } onChange={ ( newValue ) => { if ( newValue === undefined ) { @@ -214,6 +217,7 @@ const UnforwardedFontSizePicker = ( <Flex className="components-font-size-picker__custom-size-control"> <FlexItem isBlock> <UnitControl + __next40pxDefaultSize={ __next40pxDefaultSize } label={ __( 'Custom' ) } labelPosition="top" hideLabelFromVision @@ -241,6 +245,9 @@ const UnforwardedFontSizePicker = ( __nextHasNoMarginBottom={ __nextHasNoMarginBottom } + __next40pxDefaultSize={ + __next40pxDefaultSize + } className="components-font-size-picker__custom-input" label={ __( 'Custom Size' ) } hideLabelFromVision @@ -276,9 +283,10 @@ const UnforwardedFontSizePicker = ( variant="secondary" __next40pxDefaultSize size={ - size !== '__unstable-large' - ? 'small' - : 'default' + size === '__unstable-large' || + props.__next40pxDefaultSize + ? 'default' + : 'small' } > { __( 'Reset' ) } diff --git a/packages/components/src/font-size-picker/types.ts b/packages/components/src/font-size-picker/types.ts index f4d00c2d3ce67..9363417222458 100644 --- a/packages/components/src/font-size-picker/types.ts +++ b/packages/components/src/font-size-picker/types.ts @@ -57,6 +57,12 @@ export type FontSizePickerProps = { * @default false */ __nextHasNoMarginBottom?: boolean; + /** + * Start opting into the larger default height that will become the default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; /** * Size of the control. * @@ -93,6 +99,7 @@ export type FontSizePickerSelectProps = Pick< >; onChange: NonNullable< FontSizePickerProps[ 'onChange' ] >; onSelectCustom: () => void; + __next40pxDefaultSize: boolean; }; export type FontSizePickerSelectOption = { @@ -104,7 +111,7 @@ export type FontSizePickerSelectOption = { export type FontSizePickerToggleGroupProps = Pick< FontSizePickerProps, - 'value' | 'size' | '__nextHasNoMarginBottom' + 'value' | 'size' | '__nextHasNoMarginBottom' | '__next40pxDefaultSize' > & { fontSizes: NonNullable< FontSizePickerProps[ 'fontSizes' ] >; onChange: NonNullable< FontSizePickerProps[ 'onChange' ] >; diff --git a/packages/components/src/form-toggle/style.scss b/packages/components/src/form-toggle/style.scss index 314f6fa36d12f..d04ad4c651f86 100644 --- a/packages/components/src/form-toggle/style.scss +++ b/packages/components/src/form-toggle/style.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $toggle-width: 36px; $toggle-height: 18px; $toggle-border-width: 1px; @@ -20,7 +22,7 @@ $transition-duration: 0.2s; border: $toggle-border-width solid $gray-900; width: $toggle-width; height: $toggle-height; - border-radius: $toggle-height * 0.5; + border-radius: math.div($toggle-height, 2); transition: $transition-duration background-color ease, $transition-duration border-color ease; @@ -59,7 +61,7 @@ $transition-duration: 0.2s; background-color: $gray-900; // Transparent border acts as a fill in Windows High Contrast Mode. - border: $thumb-size / 2 solid transparent; + border: math.div($thumb-size, 2) solid transparent; } // Checked state. diff --git a/packages/components/src/index.native.js b/packages/components/src/index.native.js index f88399fbee287..dc8a77ad77d1e 100644 --- a/packages/components/src/index.native.js +++ b/packages/components/src/index.native.js @@ -106,11 +106,9 @@ export { default as LinkPickerScreen } from './mobile/link-picker/link-picker-sc export { default as LinkSettings } from './mobile/link-settings'; export { default as LinkSettingsScreen } from './mobile/link-settings/link-settings-screen'; export { default as LinkSettingsNavigation } from './mobile/link-settings/link-settings-navigation'; -export { default as ImageLinkDestinationsScreen } from './mobile/link-settings/image-link-destinations-screen'; export { default as SegmentedControl } from './mobile/segmented-control'; export { default as Image, IMAGE_DEFAULT_FOCAL_POINT } from './mobile/image'; export { default as ImageEditingButton } from './mobile/image/image-editing-button'; -export { default as InserterButton } from './mobile/inserter-button'; export { setClipboard, getClipboard } from './mobile/clipboard'; export { default as AudioPlayer } from './mobile/audio-player'; export { default as Badge } from './mobile/badge'; diff --git a/packages/components/src/mobile/global-styles-context/test/utils.native.js b/packages/components/src/mobile/global-styles-context/test/utils.native.js index c1f968de24e48..6144b9a13ae89 100644 --- a/packages/components/src/mobile/global-styles-context/test/utils.native.js +++ b/packages/components/src/mobile/global-styles-context/test/utils.native.js @@ -108,6 +108,28 @@ describe( 'parseStylesVariables', () => { expect.objectContaining( PARSED_GLOBAL_STYLES ) ); } ); + + it( 'returns the parsed custom color values correctly', () => { + const defaultStyles = { + ...DEFAULT_GLOBAL_STYLES, + color: { + text: 'var(--wp--custom--color--blue)', + background: 'var(--wp--custom--color--green)', + }, + }; + const customValues = parseStylesVariables( + JSON.stringify( RAW_FEATURES.custom ), + MAPPED_VALUES + ); + const styles = parseStylesVariables( + JSON.stringify( defaultStyles ), + MAPPED_VALUES, + customValues + ); + expect( styles ).toEqual( + expect.objectContaining( PARSED_GLOBAL_STYLES ) + ); + } ); } ); describe( 'getGlobalStyles', () => { diff --git a/packages/components/src/mobile/global-styles-context/utils.native.js b/packages/components/src/mobile/global-styles-context/utils.native.js index f2cbcae9c3f3e..b56e28da46207 100644 --- a/packages/components/src/mobile/global-styles-context/utils.native.js +++ b/packages/components/src/mobile/global-styles-context/utils.native.js @@ -248,6 +248,20 @@ export function parseStylesVariables( styles, mappedValues, customValues ) { const customValuesData = customValues ?? JSON.parse( stylesBase ); stylesBase = stylesBase.replace( regex, ( _$1, $2 ) => { const path = $2.split( '--' ); + + // Supports cases for variables like var(--wp--custom--color--background) + if ( path[ 0 ] === 'color' ) { + const colorKey = path[ path.length - 1 ]; + if ( mappedValues?.color ) { + const matchedValue = mappedValues.color?.values?.find( + ( { slug } ) => slug === colorKey + ); + if ( matchedValue ) { + return `${ matchedValue?.color }`; + } + } + } + if ( path.reduce( ( prev, curr ) => prev && prev[ curr ], diff --git a/packages/components/src/mobile/link-settings/style.native.scss b/packages/components/src/mobile/link-settings/style.native.scss index 137c2e32dfc76..b9545b80ec4ab 100644 --- a/packages/components/src/mobile/link-settings/style.native.scss +++ b/packages/components/src/mobile/link-settings/style.native.scss @@ -2,20 +2,3 @@ padding-left: 0; padding-right: 0; } - -// used in both light and dark modes -.placeholderTextColor { - color: #87a6bc; -} - -.optionIcon { - color: $blue-50; -} - -.optionIconDark { - color: $blue-30; -} - -.unselectedOptionIcon { - opacity: 0; -} diff --git a/packages/components/src/palette-edit/index.tsx b/packages/components/src/palette-edit/index.tsx index b3b8b626ce3b4..40621a407f217 100644 --- a/packages/components/src/palette-edit/index.tsx +++ b/packages/components/src/palette-edit/index.tsx @@ -60,7 +60,7 @@ import type { PaletteElement, } from './types'; -const DEFAULT_COLOR = '#000'; +export const DEFAULT_COLOR = '#000'; function NameInput( { value, onChange, label }: NameInputProps ) { return ( @@ -261,16 +261,30 @@ function Option< T extends Color | Gradient >( { ); } -function isTemporaryElement( +/** + * Checks if a color or gradient is a temporary element by testing against default values. + */ +export function isTemporaryElement( slugPrefix: string, { slug, color, gradient }: Color | Gradient -) { +): Boolean { const regex = new RegExp( `^${ slugPrefix }color-([\\d]+)$` ); - return ( - regex.test( slug ) && - ( ( !! color && color === DEFAULT_COLOR ) || - ( !! gradient && gradient === DEFAULT_GRADIENT ) ) - ); + + // If the slug matches the temporary name regex, + // check if the color or gradient matches the default value. + if ( regex.test( slug ) ) { + // The order is important as gradient elements + // contain a color property. + if ( !! gradient ) { + return gradient === DEFAULT_GRADIENT; + } + + if ( !! color ) { + return color === DEFAULT_COLOR; + } + } + + return false; } function PaletteEditListView< T extends Color | Gradient >( { diff --git a/packages/components/src/palette-edit/style.scss b/packages/components/src/palette-edit/style.scss index 55fdfbf42cb52..d73c7ff46cc3c 100644 --- a/packages/components/src/palette-edit/style.scss +++ b/packages/components/src/palette-edit/style.scss @@ -1,6 +1,6 @@ .components-palette-edit__popover-gradient-picker { - width: 280px; - padding: 8px; + width: 260px; + padding: $grid-unit-10; } .components-dropdown-menu__menu { .components-palette-edit__menu-button { diff --git a/packages/components/src/palette-edit/test/index.tsx b/packages/components/src/palette-edit/test/index.tsx index 1bf2802709de7..1a0b2fdaaab3f 100644 --- a/packages/components/src/palette-edit/test/index.tsx +++ b/packages/components/src/palette-edit/test/index.tsx @@ -6,8 +6,13 @@ import { render, fireEvent, screen } from '@testing-library/react'; /** * Internal dependencies */ -import PaletteEdit, { getNameForPosition } from '..'; +import PaletteEdit, { + getNameForPosition, + isTemporaryElement, + DEFAULT_COLOR, +} from '..'; import type { PaletteElement } from '../types'; +import { DEFAULT_GRADIENT } from '../../custom-gradient-picker/constants'; describe( 'getNameForPosition', () => { test( 'should return 1 by default', () => { @@ -80,6 +85,75 @@ describe( 'getNameForPosition', () => { } ); } ); +describe( 'isTemporaryElement', () => { + [ + { + message: 'identifies temporary color', + slug: 'test-', + obj: { + name: '', + slug: 'test-color-1', + color: DEFAULT_COLOR, + }, + expected: true, + }, + { + message: 'identifies temporary gradient', + slug: 'test-', + obj: { + name: '', + slug: 'test-color-1', + gradient: DEFAULT_GRADIENT, + }, + expected: true, + }, + { + message: 'identifies custom color slug', + slug: 'test-', + obj: { + name: '', + slug: 'test-color-happy', + color: DEFAULT_COLOR, + }, + expected: false, + }, + { + message: 'identifies custom color value', + slug: 'test-', + obj: { + name: '', + slug: 'test-color-1', + color: '#ccc', + }, + expected: false, + }, + { + message: 'identifies custom gradient slug', + slug: 'test-', + obj: { + name: '', + slug: 'test-gradient-joy', + color: DEFAULT_GRADIENT, + }, + expected: false, + }, + { + message: 'identifies custom gradient value', + slug: 'test-', + obj: { + name: '', + slug: 'test-color-3', + color: 'linear-gradient(90deg, rgba(22, 22, 22, 1) 0%, rgb(155, 81, 224) 100%)', + }, + expected: false, + }, + ].forEach( ( { message, slug, obj, expected } ) => { + it( `should ${ message }`, () => { + expect( isTemporaryElement( slug, obj ) ).toBe( expected ); + } ); + } ); +} ); + describe( 'PaletteEdit', () => { const defaultProps = { colors: [ { color: '#ffffff', name: 'Base', slug: 'base' } ], diff --git a/packages/components/src/query-controls/author-select.tsx b/packages/components/src/query-controls/author-select.tsx index fb5f575108230..f5f4feb9525f1 100644 --- a/packages/components/src/query-controls/author-select.tsx +++ b/packages/components/src/query-controls/author-select.tsx @@ -6,6 +6,7 @@ import TreeSelect from '../tree-select'; import type { AuthorSelectProps } from './types'; export default function AuthorSelect( { + __next40pxDefaultSize, label, noOptionLabel, authorList, @@ -28,6 +29,7 @@ export default function AuthorSelect( { : undefined } __nextHasNoMarginBottom + __next40pxDefaultSize={ __next40pxDefaultSize } /> ); } diff --git a/packages/components/src/query-controls/category-select.tsx b/packages/components/src/query-controls/category-select.tsx index 9f9c1b3a0f07c..bc2306ff048fa 100644 --- a/packages/components/src/query-controls/category-select.tsx +++ b/packages/components/src/query-controls/category-select.tsx @@ -11,6 +11,7 @@ import { useMemo } from '@wordpress/element'; import type { CategorySelectProps } from './types'; export default function CategorySelect( { + __next40pxDefaultSize, label, noOptionLabel, categoriesList, @@ -37,6 +38,7 @@ export default function CategorySelect( { } { ...props } __nextHasNoMarginBottom + __next40pxDefaultSize={ __next40pxDefaultSize } /> ); } diff --git a/packages/components/src/query-controls/index.tsx b/packages/components/src/query-controls/index.tsx index 6c3c6ba952a06..ee207b6da82b9 100644 --- a/packages/components/src/query-controls/index.tsx +++ b/packages/components/src/query-controls/index.tsx @@ -60,6 +60,7 @@ function isMultipleCategorySelection( * ``` */ export function QueryControls( { + __next40pxDefaultSize = false, authorList, selectedAuthorId, numberOfItems, @@ -81,6 +82,7 @@ export function QueryControls( { onOrderChange && onOrderByChange && ( <SelectControl __nextHasNoMarginBottom + __next40pxDefaultSize={ __next40pxDefaultSize } key="query-controls-order-select" label={ __( 'Order by' ) } value={ `${ orderBy }/${ order }` } @@ -131,6 +133,7 @@ export function QueryControls( { props.categoriesList && props.onCategoryChange && ( <CategorySelect + __next40pxDefaultSize={ __next40pxDefaultSize } key="query-controls-category-select" categoriesList={ props.categoriesList } label={ __( 'Category' ) } @@ -143,6 +146,7 @@ export function QueryControls( { props.categorySuggestions && props.onCategoryChange && ( <FormTokenField + __next40pxDefaultSize={ __next40pxDefaultSize } __nextHasNoMarginBottom key="query-controls-categories-select" label={ __( 'Categories' ) } @@ -166,6 +170,7 @@ export function QueryControls( { ), onAuthorChange && ( <AuthorSelect + __next40pxDefaultSize={ __next40pxDefaultSize } key="query-controls-author-select" authorList={ authorList } label={ __( 'Author' ) } @@ -177,7 +182,7 @@ export function QueryControls( { onNumberOfItemsChange && ( <RangeControl __nextHasNoMarginBottom - __next40pxDefaultSize + __next40pxDefaultSize={ __next40pxDefaultSize } key="query-controls-range-control" label={ __( 'Number of items' ) } value={ numberOfItems } diff --git a/packages/components/src/query-controls/types.ts b/packages/components/src/query-controls/types.ts index f620e6040eed3..8c45d22c54c06 100644 --- a/packages/components/src/query-controls/types.ts +++ b/packages/components/src/query-controls/types.ts @@ -31,6 +31,7 @@ export type CategorySelectProps = Pick< categoriesList: Category[]; onChange: ( newCategory: string ) => void; selectedCategoryId?: Category[ 'id' ]; + __next40pxDefaultSize: boolean; }; export type AuthorSelectProps = Pick< @@ -40,6 +41,7 @@ export type AuthorSelectProps = Pick< authorList?: Author[]; onChange: ( newAuthor: string ) => void; selectedAuthorId?: Author[ 'id' ]; + __next40pxDefaultSize: boolean; }; type Order = 'asc' | 'desc'; @@ -101,6 +103,13 @@ type BaseQueryControlsProps = { * The selected author ID. */ selectedAuthorId?: AuthorSelectProps[ 'selectedAuthorId' ]; + /** + * Start opting into the larger default height that will become the + * default size in a future version. + * + * @default false + */ + __next40pxDefaultSize?: boolean; }; export type QueryControlsWithSingleCategorySelectionProps = diff --git a/packages/components/src/search-control/README.md b/packages/components/src/search-control/README.md index bd12580f3c878..07a18f07130ce 100644 --- a/packages/components/src/search-control/README.md +++ b/packages/components/src/search-control/README.md @@ -8,6 +8,8 @@ SearchControl components let users display a search control. 1. [Development guidelines](#development-guidelines) 2. [Related components](#related-components) +Check out the [Storybook page](https://wordpress.github.io/gutenberg/?path=/docs/components-searchcontrol--docs) for a visual exploration of this component. + ## Development guidelines ### Usage diff --git a/packages/components/src/spinner/README.md b/packages/components/src/spinner/README.md index 474e6ebcb0a81..64f596f22032f 100644 --- a/packages/components/src/spinner/README.md +++ b/packages/components/src/spinner/README.md @@ -17,3 +17,5 @@ function Example() { The spinner component should: - Signal to users that the processing of their request is underway and will soon complete. + +Check out the [Storybook page](https://wordpress.github.io/gutenberg/?path=/docs/components-spinner--docs) for a visual exploration of this component. diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md index 423216e940584..732dec9dba7df 100644 --- a/packages/components/src/tabs/README.md +++ b/packages/components/src/tabs/README.md @@ -163,9 +163,9 @@ The children elements, which should be a series of `Tabs.TabPanel` components. ##### Props -###### `id`: `string` +###### `tabId`: `string` -The id of the tab, which is prepended with the `Tabs` instance ID. +A unique identifier for the tab, which is used to generate a unique id for the underlying element. The value of this prop should match with the value of the `tabId` prop on the corresponding `Tabs.TabPanel` component. - Required: Yes @@ -198,9 +198,9 @@ The children elements, generally the content to display on the tabpanel. - Required: No -###### `id`: `string` +###### `tabId`: `string` -The id of the tabpanel, which is combined with the `Tabs` instance ID and the suffix `-view` +A unique identifier for the tabpanel, which is used to generate an instanced id for the underlying element. The value of this prop should match with the value of the `tabId` prop on the corresponding `Tabs.Tab` component. - Required: Yes diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index 12dd0b4fcc83f..7f738cb9f08a9 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -45,7 +45,7 @@ function Tabs( { const isControlled = selectedTabId !== undefined; const { items, selectedId } = store.useState(); - const { setSelectedId } = store; + const { setSelectedId, move } = store; // Keep track of whether tabs have been populated. This is used to prevent // certain effects from firing too early while tab data and relevant @@ -154,6 +154,27 @@ function Tabs( { setSelectedId, ] ); + // In controlled mode, make sure browser focus follows the selected tab if + // the selection is changed while a tab is already being focused. + useLayoutEffect( () => { + if ( ! isControlled || ! selectOnMove ) { + return; + } + const currentItem = items.find( ( item ) => item.id === selectedId ); + const activeElement = currentItem?.element?.ownerDocument.activeElement; + const tabsHasFocus = items.some( ( item ) => { + return activeElement && activeElement === item.element; + } ); + + if ( + activeElement && + tabsHasFocus && + selectedId !== activeElement.id + ) { + move( selectedId ); + } + }, [ isControlled, items, move, selectOnMove, selectedId ] ); + const contextValue = useMemo( () => ( { store, diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index ce8c8324edaee..0e7ab725e371d 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -40,17 +40,17 @@ const Template: StoryFn< typeof Tabs > = ( props ) => { return ( <Tabs { ...props }> <Tabs.TabList> - <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' } focusable={ false }> + <Tabs.TabPanel tabId="tab3" focusable={ false }> <p>Selected tab: Tab 3</p> <p> This tabpanel has its <code>focusable</code> prop set to @@ -71,19 +71,19 @@ const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => { return ( <Tabs { ...props }> <Tabs.TabList> - <Tabs.Tab id={ 'tab1' } disabled={ true }> + <Tabs.Tab tabId="tab1" disabled={ true }> Tab 1 </Tabs.Tab> - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> @@ -96,31 +96,31 @@ const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => { <Tabs { ...props }> <Tabs.TabList> <Tabs.Tab - id={ 'tab1' } + tabId="tab1" render={ <Button icon={ wordpress } label="Tab 1" showTooltip /> } /> <Tabs.Tab - id={ 'tab2' } + tabId="tab2" render={ <Button icon={ link } label="Tab 2" showTooltip /> } /> <Tabs.Tab - id={ 'tab3' } + tabId="tab3" render={ <Button icon={ more } label="Tab 3" showTooltip /> } /> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> @@ -140,18 +140,18 @@ const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => { <SlotFillProvider> <Tabs { ...props }> <Tabs.TabList> - <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> <Fill name="tabs-are-fun"> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Fill> @@ -196,9 +196,9 @@ const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => { } } > <Tabs.TabList> - <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> <Button variant={ 'tertiary' } @@ -211,13 +211,13 @@ const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => { Close Tabs </Button> </div> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> @@ -251,19 +251,19 @@ const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => { } } > <Tabs.TabList> - <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> + <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> @@ -314,19 +314,19 @@ const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => { </Button> <Tabs { ...props }> <Tabs.TabList> - <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> - <Tabs.Tab id={ 'tab2' } disabled={ disableTab2 }> + <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> + <Tabs.Tab tabId="tab2" disabled={ disableTab2 }> Tab 2 </Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> @@ -348,17 +348,17 @@ const TabGetsRemovedTemplate: StoryFn< typeof Tabs > = ( props ) => { </Button> <Tabs { ...props }> <Tabs.TabList> - { ! removeTab1 && <Tabs.Tab id={ 'tab1' }>Tab 1</Tabs.Tab> } - <Tabs.Tab id={ 'tab2' }>Tab 2</Tabs.Tab> - <Tabs.Tab id={ 'tab3' }>Tab 3</Tabs.Tab> + { ! removeTab1 && <Tabs.Tab tabId="tab1">Tab 1</Tabs.Tab> } + <Tabs.Tab tabId="tab2">Tab 2</Tabs.Tab> + <Tabs.Tab tabId="tab3">Tab 3</Tabs.Tab> </Tabs.TabList> - <Tabs.TabPanel id={ 'tab1' }> + <Tabs.TabPanel tabId="tab1"> <p>Selected tab: Tab 1</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab2' }> + <Tabs.TabPanel tabId="tab2"> <p>Selected tab: Tab 2</p> </Tabs.TabPanel> - <Tabs.TabPanel id={ 'tab3' }> + <Tabs.TabPanel tabId="tab3"> <p>Selected tab: Tab 3</p> </Tabs.TabPanel> </Tabs> diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx index 4bfc99e8ef43b..e1aa85c636cdd 100644 --- a/packages/components/src/tabs/tab.tsx +++ b/packages/components/src/tabs/tab.tsx @@ -15,15 +15,15 @@ import type { WordPressComponentProps } from '../context'; export const Tab = forwardRef< HTMLButtonElement, - WordPressComponentProps< TabProps, 'button', false > ->( function Tab( { children, id, disabled, render, ...otherProps }, ref ) { + Omit< WordPressComponentProps< TabProps, 'button', false >, 'id' > +>( function Tab( { children, tabId, disabled, render, ...otherProps }, ref ) { const context = useTabsContext(); if ( ! context ) { warning( '`Tabs.Tab` must be wrapped in a `Tabs` component.' ); return null; } const { store, instanceId } = context; - const instancedTabId = `${ instanceId }-${ id }`; + const instancedTabId = `${ instanceId }-${ tabId }`; return ( <StyledTab ref={ ref } diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx index 8e8d72280a493..14c449bf41d13 100644 --- a/packages/components/src/tabs/tabpanel.tsx +++ b/packages/components/src/tabs/tabpanel.tsx @@ -20,20 +20,24 @@ import type { WordPressComponentProps } from '../context'; export const TabPanel = forwardRef< HTMLDivElement, - WordPressComponentProps< TabPanelProps, 'div', false > ->( function TabPanel( { children, id, focusable = true, ...otherProps }, ref ) { + Omit< WordPressComponentProps< TabPanelProps, 'div', false >, 'id' > +>( function TabPanel( + { children, tabId, focusable = true, ...otherProps }, + ref +) { const context = useTabsContext(); if ( ! context ) { warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); return null; } const { store, instanceId } = context; + const instancedTabId = `${ instanceId }-${ tabId }`; return ( <StyledTabPanel ref={ ref } store={ store } - id={ `${ instanceId }-${ id }-view` } + id={ instancedTabId } focusable={ focusable } { ...otherProps } > diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index fac8127c4cc0d..70ad3c1c18ae5 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -2,12 +2,12 @@ * External dependencies */ import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { press, click } from '@ariakit/test'; /** * WordPress dependencies */ -import { useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; /** * Internal dependencies @@ -16,7 +16,7 @@ import Tabs from '..'; import type { TabsProps } from '../types'; type Tab = { - id: string; + tabId: string; title: string; content: React.ReactNode; tab: { @@ -30,19 +30,19 @@ type Tab = { const TABS: Tab[] = [ { - id: 'alpha', + tabId: 'alpha', title: 'Alpha', content: 'Selected tab: Alpha', tab: { className: 'alpha-class' }, }, { - id: 'beta', + tabId: 'beta', title: 'Beta', content: 'Selected tab: Beta', tab: { className: 'beta-class' }, }, { - id: 'gamma', + tabId: 'gamma', title: 'Gamma', content: 'Selected tab: Gamma', tab: { className: 'gamma-class' }, @@ -52,7 +52,7 @@ const TABS: Tab[] = [ const TABS_WITH_DELTA: Tab[] = [ ...TABS, { - id: 'delta', + tabId: 'delta', title: 'Delta', content: 'Selected tab: Delta', tab: { className: 'delta-class' }, @@ -70,8 +70,8 @@ const UncontrolledTabs = ( { <Tabs.TabList> { tabs.map( ( tabObj ) => ( <Tabs.Tab - key={ tabObj.id } - id={ tabObj.id } + key={ tabObj.tabId } + tabId={ tabObj.tabId } className={ tabObj.tab.className } disabled={ tabObj.tab.disabled } > @@ -81,8 +81,8 @@ const UncontrolledTabs = ( { </Tabs.TabList> { tabs.map( ( tabObj ) => ( <Tabs.TabPanel - key={ tabObj.id } - id={ tabObj.id } + key={ tabObj.tabId } + tabId={ tabObj.tabId } focusable={ tabObj.tabpanel?.focusable } > { tabObj.content } @@ -102,6 +102,10 @@ const ControlledTabs = ( { string | undefined | null >( props.selectedTabId ); + useEffect( () => { + setSelectedTabId( props.selectedTabId ); + }, [ props.selectedTabId ] ); + return ( <Tabs { ...props } @@ -114,8 +118,8 @@ const ControlledTabs = ( { <Tabs.TabList> { tabs.map( ( tabObj ) => ( <Tabs.Tab - key={ tabObj.id } - id={ tabObj.id } + key={ tabObj.tabId } + tabId={ tabObj.tabId } className={ tabObj.tab.className } disabled={ tabObj.tab.disabled } > @@ -124,7 +128,7 @@ const ControlledTabs = ( { ) ) } </Tabs.TabList> { tabs.map( ( tabObj ) => ( - <Tabs.TabPanel key={ tabObj.id } id={ tabObj.id }> + <Tabs.TabPanel key={ tabObj.tabId } tabId={ tabObj.tabId }> { tabObj.content } </Tabs.TabPanel> ) ) } @@ -184,28 +188,24 @@ describe( 'Tabs', () => { } ); describe( 'Focus Behavior', () => { it( 'should focus on the related TabPanel when pressing the Tab key', async () => { - const user = userEvent.setup(); - render( <UncontrolledTabs tabs={ TABS } /> ); const selectedTabPanel = await screen.findByRole( 'tabpanel' ); // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); // By default the tabpanel should receive focus - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( selectedTabPanel ).toHaveFocus(); } ); it( 'should not focus on the related TabPanel when pressing the Tab key if `focusable: false` is set', async () => { - const user = userEvent.setup(); - const TABS_WITH_ALPHA_FOCUSABLE_FALSE = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, content: ( @@ -229,13 +229,13 @@ describe( 'Tabs', () => { // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); // Because the alpha tabpanel is set to `focusable: false`, pressing // the Tab key should focus the button, not the tabpanel - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( alphaButton ).toHaveFocus(); } ); } ); @@ -258,7 +258,6 @@ describe( 'Tabs', () => { describe( 'Tab Activation', () => { it( 'defaults to automatic tab activation (pointer clicks)', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -273,7 +272,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Click on Beta, make sure beta is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + await click( screen.getByRole( 'tab', { name: 'Beta' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( @@ -282,7 +281,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Click on Alpha, make sure beta is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( @@ -292,7 +291,6 @@ describe( 'Tabs', () => { } ); it( 'defaults to automatic tab activation (arrow keys)', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -307,12 +305,12 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -320,7 +318,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -328,7 +326,6 @@ describe( 'Tabs', () => { } ); it( 'wraps around the last/first tab when using arrow keys', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -341,12 +338,12 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Navigate backwards with arrow keys and make sure that the Gamma tab // (the last tab) is selected automatically. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -354,7 +351,7 @@ describe( 'Tabs', () => { // Navigate forward with arrow keys. Make sure alpha (the first tab) is // selected automatically. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -362,7 +359,6 @@ describe( 'Tabs', () => { } ); it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -377,18 +373,18 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Press the arrow down key, nothing happens - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); @@ -415,7 +411,7 @@ describe( 'Tabs', () => { // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -423,7 +419,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -431,7 +427,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowUp]' ); + await press.ArrowUp(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); @@ -439,7 +435,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. Make sure alpha is // selected automatically. - await user.keyboard( '[ArrowDown]' ); + await press.ArrowDown(); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); @@ -447,11 +443,10 @@ describe( 'Tabs', () => { } ); it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'delta' + tabObj.tabId === 'delta' ? { ...tabObj, tab: { @@ -477,7 +472,7 @@ describe( 'Tabs', () => { // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await getSelectedTab() ).toHaveFocus(); // Confirm onSelect has not been re-called expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); @@ -487,7 +482,9 @@ describe( 'Tabs', () => { // it was the tab that was last selected before delta. Therefore, the // `mockOnSelect` function gets called only twice (and not three times) // - it will receive focus, when using arrow keys - await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + await press.ArrowRight(); + await press.ArrowRight(); + await press.ArrowRight(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( screen.getByRole( 'tab', { name: 'Delta' } ) @@ -498,7 +495,7 @@ describe( 'Tabs', () => { // Navigate backwards with arrow keys. The gamma tab receives focus. // The `mockOnSelect` callback doesn't fire, since the gamma tab was // already selected. - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -506,37 +503,26 @@ describe( 'Tabs', () => { // Click on the disabled tab. Compared to using arrow keys to move the // focus, disabled tabs ignore pointer clicks — and therefore, they don't // receive focus, nor they cause the `mockOnSelect` function to fire. - await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + await click( screen.getByRole( 'tab', { name: 'Delta' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); } ); it( 'should not focus the next tab when the Tab key is pressed', async () => { - const user = userEvent.setup(); - render( <UncontrolledTabs tabs={ TABS } /> ); // Tab should initially focus the first tab in the tablist, which // is Alpha. - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); - // This assertion ensures the component has had time to fully - // render, preventing flakiness. - // see https://github.com/WordPress/gutenberg/pull/55950 - await waitFor( () => - expect( - screen.getByRole( 'tab', { name: 'Beta' } ) - ).toHaveAttribute( 'tabindex', '-1' ) - ); - // Because all other tabs should have `tabindex=-1`, pressing Tab // should NOT move the focus to the next tab, which is Beta. // Instead, focus should go to the currently selected tabpanel (alpha). - await user.keyboard( '[Tab]' ); + await press.Tab(); expect( await screen.findByRole( 'tabpanel', { name: 'Alpha', @@ -545,7 +531,6 @@ describe( 'Tabs', () => { } ); it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); render( @@ -563,7 +548,7 @@ describe( 'Tabs', () => { // Click on Alpha and make sure it is selected. // onSelect shouldn't fire since the selected tab didn't change. - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await screen.findByRole( 'tab', { name: 'Alpha' } ) ).toHaveFocus(); @@ -574,13 +559,13 @@ describe( 'Tabs', () => { // that the tab selection happens only when pressing the spacebar // or enter key. onSelect shouldn't fire since the selected tab // didn't change. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await screen.findByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - await user.keyboard( '[Enter]' ); + await press.Enter(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -588,7 +573,7 @@ describe( 'Tabs', () => { // focused, but that tab selection happens only when pressing the // spacebar or enter key. onSelect shouldn't fire since the selected // tab didn't change. - await user.keyboard( '[ArrowRight]' ); + await press.ArrowRight(); expect( await screen.findByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); @@ -597,7 +582,7 @@ describe( 'Tabs', () => { screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); - await user.keyboard( '[Space]' ); + await press.Space(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); @@ -623,7 +608,7 @@ describe( 'Tabs', () => { } ); it( 'should not load any tab if the active tab is removed and there are no enabled tabs', async () => { const TABS_WITH_BETA_GAMMA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id !== 'alpha' + tabObj.tabId !== 'alpha' ? { ...tabObj, tab: { @@ -700,7 +685,6 @@ describe( 'Tabs', () => { } ); it( 'should fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -713,9 +697,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await user.click( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -731,7 +713,6 @@ describe( 'Tabs', () => { } ); it( 'should fall back to the tab associated to `initialTabId` if the currently active tab becomes disabled', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const { rerender } = render( @@ -744,14 +725,12 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await user.click( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ); + await click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -822,12 +801,11 @@ describe( 'Tabs', () => { describe( 'Disabled tab', () => { it( 'should disable the tab when `disabled` is `true`', async () => { - const user = userEvent.setup(); const mockOnSelect = jest.fn(); const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'delta' + tabObj.tabId === 'delta' ? { ...tabObj, tab: { @@ -853,20 +831,15 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + // Move focus to the tablist, make sure alpha is focused. + await press.Tab(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + // onSelect should not be called since the disabled tab is // highlighted, but not selected. - await user.keyboard( '[Tab]' ); - - // This assertion ensures focus has time to move to the first - // tab before the test proceeds, preventing flakiness. - // see https://github.com/WordPress/gutenberg/pull/55950 - await waitFor( () => - expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus() - ); - - await user.keyboard( '[ArrowLeft]' ); + await press.ArrowLeft(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Delta (which is disabled) has focus @@ -880,7 +853,7 @@ describe( 'Tabs', () => { it( 'should select first enabled tab when the initial tab is disabled', async () => { const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -909,7 +882,7 @@ describe( 'Tabs', () => { it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => { const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) => - tabObj.id !== 'gamma' + tabObj.tabId !== 'gamma' ? { ...tabObj, tab: { @@ -951,7 +924,7 @@ describe( 'Tabs', () => { expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'alpha' + tabObj.tabId === 'alpha' ? { ...tabObj, tab: { @@ -998,7 +971,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'gamma' + tabObj.tabId === 'gamma' ? { ...tabObj, tab: { @@ -1067,14 +1040,10 @@ describe( 'Tabs', () => { /> ); - // No tab should be selected i.e. it doesn't fall back to first tab. - // `waitFor` is needed here to prevent testing library from - // throwing a 'not wrapped in `act()`' error. - await waitFor( () => - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument() - ); + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); } ); @@ -1086,7 +1055,7 @@ describe( 'Tabs', () => { // Remove beta rerender( <ControlledTabs - tabs={ TABS.filter( ( tab ) => tab.id !== 'beta' ) } + tabs={ TABS.filter( ( tab ) => tab.tabId !== 'beta' ) } selectedTabId="beta" /> ); @@ -1120,7 +1089,7 @@ describe( 'Tabs', () => { it( 'should not render any tab if `selectedTabId` refers to a disabled tab', async () => { const TABS_WITH_DELTA_WITH_BETA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => - tabObj.id === 'beta' + tabObj.tabId === 'beta' ? { ...tabObj, tab: { @@ -1157,7 +1126,7 @@ describe( 'Tabs', () => { expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => - tabObj.id === 'beta' + tabObj.tabId === 'beta' ? { ...tabObj, tab: { @@ -1203,5 +1172,110 @@ describe( 'Tabs', () => { ).not.toBeInTheDocument(); } ); } ); + + describe( 'When `selectOnMove` is `true`', () => { + it( 'should automatically select a newly focused tab', async () => { + render( <ControlledTabs tabs={ TABS } selectedTabId="beta" /> ); + + await press.Tab(); + + // Tab key should focus the currently selected tab, which is Beta. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Arrow keys should select and move focus to the next tab. + await press.ArrowRight(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + } ); + it( 'should automatically update focus when the selected tab is changed by the controlling component', async () => { + const { rerender } = render( + <ControlledTabs tabs={ TABS } selectedTabId="beta" /> + ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + + rerender( + <ControlledTabs tabs={ TABS } selectedTabId="gamma" /> + ); + + // When the selected tab is changed, it should automatically receive focus. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + } ); + } ); + describe( 'When `selectOnMove` is `false`', () => { + it( 'should apply focus without automatically changing the selected tab', async () => { + render( + <ControlledTabs + tabs={ TABS } + selectedTabId="beta" + selectOnMove={ false } + /> + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + + // Arrow key should move focus but not automatically change the selected tab. + await press.ArrowRight(); + expect( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Pressing the spacebar should select the focused tab. + await press.Space(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Arrow key should move focus but not automatically change the selected tab. + await press.ArrowRight(); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Pressing the enter/return should select the focused tab. + await press.Enter(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + } ); + it( 'should not automatically update focus when the selected tab is changed by the controlling component', async () => { + const { rerender } = render( + <ControlledTabs + tabs={ TABS } + selectedTabId="beta" + selectOnMove={ false } + /> + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Tab key should focus the currently selected tab, which is Beta. + await press.Tab(); + expect( await getSelectedTab() ).toHaveFocus(); + + rerender( + <ControlledTabs + tabs={ TABS } + selectedTabId="gamma" + selectOnMove={ false } + /> + ); + + // When the selected tab is changed, it should not automatically receive focus. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + } ); + } ); } ); } ); diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts index 8b07193741091..389665b13357f 100644 --- a/packages/components/src/tabs/types.ts +++ b/packages/components/src/tabs/types.ts @@ -78,8 +78,10 @@ export type TabListProps = { export type TabProps = { /** * The id of the tab, which is prepended with the `Tabs` instanceId. + * The value of this prop should match with the value of the `tabId` prop on + * the corresponding `Tabs.TabPanel` component. */ - id: string; + tabId: string; /** * The children elements, generally the text to display on the tab. */ @@ -103,9 +105,12 @@ export type TabPanelProps = { */ children?: React.ReactNode; /** - * A unique identifier for the tabpanel, which is used to generate a unique `id` for the underlying element. + * A unique identifier for the tabpanel, which is used to generate an + * instanced id for the underlying element. + * The value of this prop should match with the value of the `tabId` prop on + * the corresponding `Tabs.Tab` component. */ - id: string; + tabId: string; /** * Determines whether or not the tabpanel element should be focusable. * If `false`, pressing the tab key will skip over the tabpanel, and instead diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx index 78be0e89fc0bb..b54b5764d4e0f 100644 --- a/packages/components/src/toggle-group-control/test/index.tsx +++ b/packages/components/src/toggle-group-control/test/index.tsx @@ -25,8 +25,11 @@ import cleanupTooltip from '../../tooltip/test/utils'; const ControlledToggleGroupControl = ( { value: valueProp, onChange, + extraButtonOptions, ...props -}: ToggleGroupControlProps ) => { +}: ToggleGroupControlProps & { + extraButtonOptions?: { name: string; value: string }[]; +} ) => { const [ value, setValue ] = useState( valueProp ); return ( @@ -40,6 +43,14 @@ const ControlledToggleGroupControl = ( { value={ value } /> <Button onClick={ () => setValue( undefined ) }>Reset</Button> + { extraButtonOptions?.map( ( obj ) => ( + <Button + key={ obj.value } + onClick={ () => setValue( obj.value ) } + > + { obj.name } + </Button> + ) ) } </> ); }; @@ -192,6 +203,48 @@ describe.each( [ expect( rigasOption ).not.toBeChecked(); expect( jackOption ).not.toBeChecked(); } ); + + it( 'should update correctly when triggered by external updates', async () => { + const user = userEvent.setup(); + + render( + <Component + value="rigas" + label="Test Toggle Group Control" + extraButtonOptions={ [ + { name: 'Rigas', value: 'rigas' }, + { name: 'Jack', value: 'jack' }, + ] } + > + { options } + </Component> + ); + + expect( screen.getByRole( 'radio', { name: 'R' } ) ).toBeChecked(); + expect( + screen.getByRole( 'radio', { name: 'J' } ) + ).not.toBeChecked(); + + await user.click( screen.getByRole( 'button', { name: 'Jack' } ) ); + expect( screen.getByRole( 'radio', { name: 'J' } ) ).toBeChecked(); + expect( + screen.getByRole( 'radio', { name: 'R' } ) + ).not.toBeChecked(); + + await user.click( screen.getByRole( 'button', { name: 'Rigas' } ) ); + expect( screen.getByRole( 'radio', { name: 'R' } ) ).toBeChecked(); + expect( + screen.getByRole( 'radio', { name: 'J' } ) + ).not.toBeChecked(); + + await user.click( screen.getByRole( 'button', { name: 'Reset' } ) ); + expect( + screen.getByRole( 'radio', { name: 'R' } ) + ).not.toBeChecked(); + expect( + screen.getByRole( 'radio', { name: 'J' } ) + ).not.toBeChecked(); + } ); } describe( 'isDeselectable', () => { diff --git a/packages/components/src/toggle-group-control/toggle-group-control/utils.ts b/packages/components/src/toggle-group-control/toggle-group-control/utils.ts index 1a012e6efe00d..3f6d6e135a0df 100644 --- a/packages/components/src/toggle-group-control/toggle-group-control/utils.ts +++ b/packages/components/src/toggle-group-control/toggle-group-control/utils.ts @@ -21,30 +21,25 @@ type ValueProp = ToggleGroupControlProps[ 'value' ]; export function useComputeControlledOrUncontrolledValue( valueProp: ValueProp ): { value: ValueProp; defaultValue: ValueProp } { - const hasEverBeenUsedInControlledMode = useRef( false ); - const previousValueProp = usePrevious( valueProp ); + const prevValueProp = usePrevious( valueProp ); + const prevIsControlled = useRef( false ); + // Assume the component is being used in controlled mode on the first re-render + // that has a different `valueProp` from the previous render. + const isControlled = + prevIsControlled.current || + ( prevValueProp !== undefined && + valueProp !== undefined && + prevValueProp !== valueProp ); useEffect( () => { - if ( ! hasEverBeenUsedInControlledMode.current ) { - // Assume the component is being used in controlled mode if: - // - the `value` prop is not `undefined` - // - the `value` prop was not previously `undefined` and was given a new value - hasEverBeenUsedInControlledMode.current = - valueProp !== undefined && - previousValueProp !== undefined && - valueProp !== previousValueProp; - } - }, [ valueProp, previousValueProp ] ); + prevIsControlled.current = isControlled; + }, [ isControlled ] ); - let value, defaultValue; - - if ( hasEverBeenUsedInControlledMode.current ) { + if ( isControlled ) { // When in controlled mode, use `''` instead of `undefined` - value = valueProp ?? ''; - } else { - // When in uncontrolled mode, the `value` should be intended as the initial value - defaultValue = valueProp; + return { value: valueProp ?? '', defaultValue: undefined }; } - return { value, defaultValue }; + // When in uncontrolled mode, the `value` should be intended as the initial value + return { value: undefined, defaultValue: valueProp }; } diff --git a/packages/components/src/tools-panel/tools-panel-item/hook.ts b/packages/components/src/tools-panel/tools-panel-item/hook.ts index 244349b6379ea..fe415b8723a88 100644 --- a/packages/components/src/tools-panel/tools-panel-item/hook.ts +++ b/packages/components/src/tools-panel/tools-panel-item/hook.ts @@ -52,11 +52,14 @@ export function useToolsPanelItem( __experimentalLastVisibleItemClass, } = useToolsPanelContext(); - const hasValueCallback = useCallback( hasValue, [ panelId, hasValue ] ); - const resetAllFilterCallback = useCallback( resetAllFilter, [ - panelId, - resetAllFilter, - ] ); + // hasValue is a new function on every render, so do not add it as a + // dependency to the useCallback hook! If needed, we should use a ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + const hasValueCallback = useCallback( hasValue, [ panelId ] ); + // resetAllFilter is a new function on every render, so do not add it as a + // dependency to the useCallback hook! If needed, we should use a ref. + // eslint-disable-next-line react-hooks/exhaustive-deps + const resetAllFilterCallback = useCallback( resetAllFilter, [ panelId ] ); const previousPanelId = usePrevious( currentPanelId ); const hasMatchingPanel = @@ -126,27 +129,13 @@ export function useToolsPanelItem( const newValueSet = isValueSet && ! wasValueSet; // Notify the panel when an item's value has been set. - // - // 1. For default controls, this is so "reset" appears beside its menu item. - // 2. For optional controls, when the panel ID is `null`, it allows the - // panel to ensure the item is toggled on for display in the menu, given the - // value has been set external to the control. useEffect( () => { if ( ! newValueSet ) { return; } - if ( isShownByDefault || currentPanelId === null ) { - flagItemCustomization( label, menuGroup ); - } - }, [ - currentPanelId, - newValueSet, - isShownByDefault, - menuGroup, - label, - flagItemCustomization, - ] ); + flagItemCustomization( label, menuGroup ); + }, [ newValueSet, menuGroup, label, flagItemCustomization ] ); // Determine if the panel item's corresponding menu is being toggled and // trigger appropriate callback if it is. diff --git a/packages/compose/README.md b/packages/compose/README.md index 5a3ec6437b1fd..ce393f2b5fd18 100644 --- a/packages/compose/README.md +++ b/packages/compose/README.md @@ -249,6 +249,18 @@ _Returns_ - `import('../../utils/debounce').DebouncedFunc<TFunc>`: Debounced function. +### useDebouncedInput + +Helper hook for input fields that need to debounce the value before using it. + +_Parameters_ + +- _defaultValue_ `any`: The default value to use. + +_Returns_ + +- `[string, Function, string]`: The input value, the setter and the debounced input value. + ### useDisabled In some circumstances, such as block previews, all focusable DOM elements (input fields, links, buttons, etc.) need to be disabled. This hook adds the behavior to disable nested DOM elements to the returned ref. diff --git a/packages/edit-site/src/utils/use-debounced-input.js b/packages/compose/src/hooks/use-debounced-input/index.js similarity index 59% rename from packages/edit-site/src/utils/use-debounced-input.js rename to packages/compose/src/hooks/use-debounced-input/index.js index 26cd6c0da0e0a..91a01073fcfee 100644 --- a/packages/edit-site/src/utils/use-debounced-input.js +++ b/packages/compose/src/hooks/use-debounced-input/index.js @@ -2,8 +2,18 @@ * WordPress dependencies */ import { useEffect, useState } from '@wordpress/element'; -import { useDebounce } from '@wordpress/compose'; +/** + * Internal dependencies + */ +import useDebounce from '../use-debounce'; + +/** + * Helper hook for input fields that need to debounce the value before using it. + * + * @param {any} defaultValue The default value to use. + * @return {[string, Function, string]} The input value, the setter and the debounced input value. + */ export default function useDebouncedInput( defaultValue = '' ) { const [ input, setInput ] = useState( defaultValue ); const [ debouncedInput, setDebouncedState ] = useState( defaultValue ); diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js index 1a667c98cb690..3d03463f49079 100644 --- a/packages/compose/src/index.js +++ b/packages/compose/src/index.js @@ -39,6 +39,7 @@ export { default as useResizeObserver } from './hooks/use-resize-observer'; export { default as useAsyncList } from './hooks/use-async-list'; export { default as useWarnOnChange } from './hooks/use-warn-on-change'; export { default as useDebounce } from './hooks/use-debounce'; +export { default as useDebouncedInput } from './hooks/use-debounced-input'; export { default as useThrottle } from './hooks/use-throttle'; export { default as useMergeRefs } from './hooks/use-merge-refs'; export { default as useRefEffect } from './hooks/use-ref-effect'; diff --git a/packages/compose/src/index.native.js b/packages/compose/src/index.native.js index 0b9a41679f83a..a3f959e644f32 100644 --- a/packages/compose/src/index.native.js +++ b/packages/compose/src/index.native.js @@ -33,6 +33,7 @@ export { default as usePreferredColorScheme } from './hooks/use-preferred-color- export { default as usePreferredColorSchemeStyle } from './hooks/use-preferred-color-scheme-style'; export { default as useResizeObserver } from './hooks/use-resize-observer'; export { default as useDebounce } from './hooks/use-debounce'; +export { default as useDebouncedInput } from './hooks/use-debounced-input'; export { default as useThrottle } from './hooks/use-throttle'; export { default as useMergeRefs } from './hooks/use-merge-refs'; export { default as useRefEffect } from './hooks/use-ref-effect'; diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f016336260ab1..a2c60c45aaa03 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -19,18 +19,6 @@ export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; -// A hardcoded list of post types that support revisions. -// Reflects post types in Core's src/wp-includes/post.php. -// @TODO: Ideally this should be fetched from the `/types` REST API's view context. -const POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT = [ - 'post', - 'page', - 'wp_block', - 'wp_navigation', - 'wp_template', - 'wp_template_part', -]; - export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -223,9 +211,6 @@ export const rootEntitiesConfig = [ `/wp/v2/global-styles/${ parentId }/revisions${ revisionId ? '/' + revisionId : '' }`, - supports: { - revisions: true, - }, supportsPagination: true, }, { @@ -315,11 +300,6 @@ async function loadPostTypeEntities() { selection: true, }, mergedEdits: { meta: true }, - supports: { - revisions: POST_TYPE_ENTITIES_WITH_REVISIONS_SUPPORT.includes( - postType?.slug - ), - }, rawAttributes: POST_RAW_ATTRIBUTES, getTitle: ( record ) => record?.title?.rendered || diff --git a/packages/core-data/src/footnotes/get-footnotes-order.js b/packages/core-data/src/footnotes/get-footnotes-order.js index 42adeed7621e8..fcaeae660ec1a 100644 --- a/packages/core-data/src/footnotes/get-footnotes-order.js +++ b/packages/core-data/src/footnotes/get-footnotes-order.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { create } from '@wordpress/rich-text'; - /** * Internal dependencies */ @@ -14,18 +9,16 @@ function getBlockFootnotesOrder( block ) { if ( ! cache.has( block ) ) { const order = []; for ( const value of getRichTextValuesCached( block ) ) { - if ( ! value || ! value.includes( 'data-fn' ) ) { + if ( ! value ) { continue; } // replacements is a sparse array, use forEach to skip empty slots. - create( { html: value } ).replacements.forEach( - ( { type, attributes } ) => { - if ( type === 'core/footnote' ) { - order.push( attributes[ 'data-fn' ] ); - } + value.replacements.forEach( ( { type, attributes } ) => { + if ( type === 'core/footnote' ) { + order.push( attributes[ 'data-fn' ] ); } - ); + } ); } cache.set( block, order ); } diff --git a/packages/core-data/src/footnotes/index.js b/packages/core-data/src/footnotes/index.js index fa1c5fad5c7e7..9458290f9cb40 100644 --- a/packages/core-data/src/footnotes/index.js +++ b/packages/core-data/src/footnotes/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { create, toHTMLString } from '@wordpress/rich-text'; +import { RichTextData, create, toHTMLString } from '@wordpress/rich-text'; /** * Internal dependencies @@ -53,15 +53,18 @@ export function updateFootnotesFromMeta( blocks, meta ) { continue; } - if ( typeof value !== 'string' ) { + // To do, remove support for string values? + if ( + typeof value !== 'string' && + ! ( value instanceof RichTextData ) + ) { continue; } - if ( value.indexOf( 'data-fn' ) === -1 ) { - continue; - } - - const richTextValue = create( { html: value } ); + const richTextValue = + typeof value === 'string' + ? RichTextData.fromHTMLString( value ) + : value; richTextValue.replacements.forEach( ( replacement ) => { if ( replacement.type === 'core/footnote' ) { @@ -78,7 +81,10 @@ export function updateFootnotesFromMeta( blocks, meta ) { } } ); - attributes[ key ] = toHTMLString( { value: richTextValue } ); + attributes[ key ] = + typeof value === 'string' + ? richTextValue.toHTMLString() + : richTextValue; } return attributes; diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index a499b42f17543..8e6be42524468 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -355,51 +355,37 @@ function entity( entityConfig ) { return state; }, - // Add revisions to the state tree if the post type supports it. - ...( entityConfig?.supports?.revisions - ? { - revisions: ( state = {}, action ) => { - // Use the same queriedDataReducer shape for revisions. - if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { - const recordKey = action.recordKey; - delete action.recordKey; - const newState = queriedDataReducer( - state[ recordKey ], - { - ...action, - type: 'RECEIVE_ITEMS', - } - ); - return { - ...state, - [ recordKey ]: newState, - }; - } + revisions: ( state = {}, action ) => { + // Use the same queriedDataReducer shape for revisions. + if ( action.type === 'RECEIVE_ITEM_REVISIONS' ) { + const recordKey = action.recordKey; + delete action.recordKey; + const newState = queriedDataReducer( state[ recordKey ], { + ...action, + type: 'RECEIVE_ITEMS', + } ); + return { + ...state, + [ recordKey ]: newState, + }; + } - if ( action.type === 'REMOVE_ITEMS' ) { - return Object.fromEntries( - Object.entries( state ).filter( - ( [ id ] ) => - ! action.itemIds.some( - ( itemId ) => { - if ( - Number.isInteger( - itemId - ) - ) { - return itemId === +id; - } - return itemId === id; - } - ) - ) - ); - } + if ( action.type === 'REMOVE_ITEMS' ) { + return Object.fromEntries( + Object.entries( state ).filter( + ( [ id ] ) => + ! action.itemIds.some( ( itemId ) => { + if ( Number.isInteger( itemId ) ) { + return itemId === +id; + } + return itemId === id; + } ) + ) + ); + } - return state; - }, - } - : {} ), + return state; + }, } ) ); } diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 245d64d05d064..807005ec4a6e8 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -737,11 +737,7 @@ export const getRevisions = ( config ) => config.name === name && config.kind === kind ); - if ( - ! entityConfig || - entityConfig?.__experimentalNoFetch || - ! entityConfig?.supports?.revisions - ) { + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -766,60 +762,76 @@ export const getRevisions = query ); - let records, meta; - if ( entityConfig.supportsPagination && query.per_page !== -1 ) { - const response = await apiFetch( { path, parse: false } ); - records = Object.values( await response.json() ); - meta = { - totalItems: parseInt( response.headers.get( 'X-WP-Total' ) ), - }; - } else { - records = Object.values( await apiFetch( { path } ) ); + let records, response; + const meta = {}; + const isPaginated = + entityConfig.supportsPagination && query.per_page !== -1; + try { + response = await apiFetch( { path, parse: ! isPaginated } ); + } catch ( error ) { + // Do nothing if our request comes back with an API error. + return; } - // If we request fields but the result doesn't contain the fields, - // explicitly set these fields as "undefined" - // that way we consider the query "fulfilled". - if ( query._fields ) { - records = records.map( ( record ) => { - query._fields.split( ',' ).forEach( ( field ) => { - if ( ! record.hasOwnProperty( field ) ) { - record[ field ] = undefined; - } + if ( response ) { + if ( isPaginated ) { + records = Object.values( await response.json() ); + meta.totalItems = parseInt( + response.headers.get( 'X-WP-Total' ) + ); + } else { + records = Object.values( response ); + } + + // If we request fields but the result doesn't contain the fields, + // explicitly set these fields as "undefined" + // that way we consider the query "fulfilled". + if ( query._fields ) { + records = records.map( ( record ) => { + query._fields.split( ',' ).forEach( ( field ) => { + if ( ! record.hasOwnProperty( field ) ) { + record[ field ] = undefined; + } + } ); + + return record; } ); + } - return record; - } ); - } + dispatch.receiveRevisions( + kind, + name, + recordKey, + records, + query, + false, + meta + ); - dispatch.receiveRevisions( - kind, - name, - recordKey, - records, - query, - false, - meta - ); + // When requesting all fields, the list of results can be used to + // resolve the `getRevision` selector in addition to `getRevisions`. + if ( ! query?._fields && ! query.context ) { + const key = entityConfig.key || DEFAULT_ENTITY_KEY; + const resolutionsArgs = records + .filter( ( record ) => record[ key ] ) + .map( ( record ) => [ + kind, + name, + recordKey, + record[ key ], + ] ); - // When requesting all fields, the list of results can be used to - // resolve the `getRevision` selector in addition to `getRevisions`. - if ( ! query?._fields && ! query.context ) { - const key = entityConfig.key || DEFAULT_ENTITY_KEY; - const resolutionsArgs = records - .filter( ( record ) => record[ key ] ) - .map( ( record ) => [ kind, name, recordKey, record[ key ] ] ); - - dispatch( { - type: 'START_RESOLUTIONS', - selectorName: 'getRevision', - args: resolutionsArgs, - } ); - dispatch( { - type: 'FINISH_RESOLUTIONS', - selectorName: 'getRevision', - args: resolutionsArgs, - } ); + dispatch( { + type: 'START_RESOLUTIONS', + selectorName: 'getRevision', + args: resolutionsArgs, + } ); + dispatch( { + type: 'FINISH_RESOLUTIONS', + selectorName: 'getRevision', + args: resolutionsArgs, + } ); + } } }; @@ -850,11 +862,7 @@ export const getRevision = ( config ) => config.name === name && config.kind === kind ); - if ( - ! entityConfig || - entityConfig?.__experimentalNoFetch || - ! entityConfig?.supports?.revisions - ) { + if ( ! entityConfig || entityConfig?.__experimentalNoFetch ) { return; } @@ -878,6 +886,15 @@ export const getRevision = query ); - const record = await apiFetch( { path } ); - dispatch.receiveRevisions( kind, name, recordKey, record, query ); + let record; + try { + record = await apiFetch( { path } ); + } catch ( error ) { + // Do nothing if our request comes back with an API error. + return; + } + + if ( record ) { + dispatch.receiveRevisions( kind, name, recordKey, record, query ); + } }; diff --git a/packages/create-block-interactive-template/CHANGELOG.md b/packages/create-block-interactive-template/CHANGELOG.md index 72ed6677e0b4e..388c9de959e43 100644 --- a/packages/create-block-interactive-template/CHANGELOG.md +++ b/packages/create-block-interactive-template/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add all files to the generated plugin zip. [#56943](https://github.com/WordPress/gutenberg/pull/56943) +- Prevent crash when Gutenberg plugin is not installed. [#56941](https://github.com/WordPress/gutenberg/pull/56941) + ## 1.10.1 (2023-12-07) - Update template to use modules instead of scripts. [#56694](https://github.com/WordPress/gutenberg/pull/56694) diff --git a/packages/create-block-interactive-template/block-templates/render.php.mustache b/packages/create-block-interactive-template/block-templates/render.php.mustache index 01cbe6ed83cfb..0f6883a936240 100644 --- a/packages/create-block-interactive-template/block-templates/render.php.mustache +++ b/packages/create-block-interactive-template/block-templates/render.php.mustache @@ -15,7 +15,9 @@ $unique_id = wp_unique_id( 'p-' ); // Enqueue the view file. -gutenberg_enqueue_module( '{{namespace}}-view' ); +if (function_exists('gutenberg_enqueue_module')) { + gutenberg_enqueue_module( '{{namespace}}-view' ); +} ?> <div diff --git a/packages/create-block-interactive-template/index.js b/packages/create-block-interactive-template/index.js index b2682600f7af6..6e5ffcb9cc9ae 100644 --- a/packages/create-block-interactive-template/index.js +++ b/packages/create-block-interactive-template/index.js @@ -10,6 +10,7 @@ module.exports = { description: 'An interactive block with the Interactivity API', dashicon: 'media-interactive', npmDependencies: [ '@wordpress/interactivity' ], + customPackageJSON: { files: [ '[^.]*' ] }, supports: { interactivity: true, }, diff --git a/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache b/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache index 73726b930e472..81e6b035b1d73 100644 --- a/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache +++ b/packages/create-block-interactive-template/plugin-templates/$slug.php.mustache @@ -44,11 +44,13 @@ if ( ! defined( 'ABSPATH' ) ) { function {{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init() { register_block_type( __DIR__ . '/build' ); - gutenberg_register_module( - '{{namespace}}-view', - plugin_dir_url( __FILE__ ) . 'src/view.js', - array( '@wordpress/interactivity' ), - '{{version}}' - ); + if (function_exists('gutenberg_register_module')) { + gutenberg_register_module( + '{{namespace}}-view', + plugin_dir_url( __FILE__ ) . 'src/view.js', + array( '@wordpress/interactivity' ), + '{{version}}' + ); + } } add_action( 'init', '{{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init' ); diff --git a/packages/customize-widgets/src/components/header/index.js b/packages/customize-widgets/src/components/header/index.js index 34e4573c719dd..5bd0b2c2f4d47 100644 --- a/packages/customize-widgets/src/components/header/index.js +++ b/packages/customize-widgets/src/components/header/index.js @@ -6,13 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { Popover, ToolbarButton } from '@wordpress/components'; -import { useViewportMatch } from '@wordpress/compose'; -import { - NavigableToolbar, - privateApis as blockEditorPrivateApis, -} from '@wordpress/block-editor'; -import { createPortal, useEffect, useRef, useState } from '@wordpress/element'; +import { ToolbarButton } from '@wordpress/components'; +import { NavigableToolbar } from '@wordpress/block-editor'; +import { createPortal, useEffect, useState } from '@wordpress/element'; import { displayShortcut, isAppleOS } from '@wordpress/keycodes'; import { __, _x, isRTL } from '@wordpress/i18n'; import { plus, undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; @@ -22,9 +18,6 @@ import { plus, undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; */ import Inserter from '../inserter'; import MoreMenu from '../more-menu'; -import { unlock } from '../../lock-unlock'; - -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); function Header( { sidebar, @@ -33,8 +26,6 @@ function Header( { setIsInserterOpened, isFixedToolbarActive, } ) { - const isLargeViewport = useViewportMatch( 'medium' ); - const blockToolbarRef = useRef(); const [ [ hasUndo, hasRedo ], setUndoRedo ] = useState( [ sidebar.hasUndo(), sidebar.hasRedo(), @@ -107,18 +98,6 @@ function Header( { <Inserter setIsOpened={ setIsInserterOpened } />, inserter.contentContainer[ 0 ] ) } - - { isFixedToolbarActive && isLargeViewport && ( - <> - <div className="selected-block-tools-wrapper"> - <BlockContextualToolbar isFixed /> - </div> - <Popover.Slot - ref={ blockToolbarRef } - name="block-toolbar" - /> - </> - ) } </> ); } diff --git a/packages/customize-widgets/src/components/header/style.scss b/packages/customize-widgets/src/components/header/style.scss index d9d4a487e647c..27460a82e0ad1 100644 --- a/packages/customize-widgets/src/components/header/style.scss +++ b/packages/customize-widgets/src/components/header/style.scss @@ -1,5 +1,5 @@ .customize-widgets-header { - @include break-medium() { + @include break-small() { // Make space for the floating toolbar. margin-bottom: $grid-unit-20 + $default-block-margin; } diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/index.js b/packages/customize-widgets/src/components/sidebar-block-editor/index.js index ccb6fca871429..80deb12dfcf74 100644 --- a/packages/customize-widgets/src/components/sidebar-block-editor/index.js +++ b/packages/customize-widgets/src/components/sidebar-block-editor/index.js @@ -1,12 +1,13 @@ /** * WordPress dependencies */ +import { useViewportMatch } from '@wordpress/compose'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { useMemo, createPortal } from '@wordpress/element'; import { BlockList, - BlockTools, + BlockToolbar, BlockInspector, privateApis as blockEditorPrivateApis, __unstableBlockSettingsMenuFirstItem, @@ -37,6 +38,7 @@ export default function SidebarBlockEditor( { inspector, } ) { const [ isInserterOpened, setIsInserterOpened ] = useInserter( inserter ); + const isMediumViewport = useViewportMatch( 'small' ); const { hasUploadPermissions, isFixedToolbarActive, @@ -77,7 +79,7 @@ export default function SidebarBlockEditor( { ...blockEditorSettings, __experimentalSetIsInserterOpened: setIsInserterOpened, mediaUpload: mediaUploadBlockEditor, - hasFixedToolbar: isFixedToolbarActive, + hasFixedToolbar: isFixedToolbarActive || ! isMediumViewport, keepCaretInsideBlock, __unstableHasCustomAppender: true, }; @@ -85,6 +87,7 @@ export default function SidebarBlockEditor( { hasUploadPermissions, blockEditorSettings, isFixedToolbarActive, + isMediumViewport, keepCaretInsideBlock, setIsInserterOpened, ] ); @@ -109,18 +112,20 @@ export default function SidebarBlockEditor( { inserter={ inserter } isInserterOpened={ isInserterOpened } setIsInserterOpened={ setIsInserterOpened } - isFixedToolbarActive={ isFixedToolbarActive } + isFixedToolbarActive={ + isFixedToolbarActive || ! isMediumViewport + } /> - - <BlockTools> - <BlockCanvas - shouldIframe={ false } - styles={ settings.defaultEditorStyles } - height="100%" - > - <BlockList renderAppender={ BlockAppender } /> - </BlockCanvas> - </BlockTools> + { ( isFixedToolbarActive || ! isMediumViewport ) && ( + <BlockToolbar hideDragHandle /> + ) } + <BlockCanvas + shouldIframe={ false } + styles={ settings.defaultEditorStyles } + height="100%" + > + <BlockList renderAppender={ BlockAppender } /> + </BlockCanvas> { createPortal( // This is a temporary hack to prevent button component inside <BlockInspector> diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/style.scss b/packages/customize-widgets/src/components/sidebar-block-editor/style.scss index a1b99447155eb..1aa62ed32e847 100644 --- a/packages/customize-widgets/src/components/sidebar-block-editor/style.scss +++ b/packages/customize-widgets/src/components/sidebar-block-editor/style.scss @@ -1,23 +1,3 @@ -.block-editor-block-contextual-toolbar.is-fixed { - // The top position used for the 'sticky' positioning. - top: 0; - - // Offset the customizer's sidebar padding. - margin-left: -$grid-unit-15; - margin-right: -$grid-unit-15; - // added important to override the inline style coming from - // the block-editor/block-contextual-toolbar component. - width: calc(100% + #{ $grid-unit-30 }) !important; - - & > .block-editor-block-toolbar__group-collapse-fixed-toolbar { - display: none; - } - - // Scroll sideways. - overflow-y: auto; - z-index: z-index(".customize-widgets__block-toolbar"); -} - .customize-control-sidebar_block_editor .block-editor-block-list__block-popover { // FloatingUI library used in Popover component forces us to have an "absolute" inline style. // We need to override this in the customizer. diff --git a/packages/dataviews/.npmrc b/packages/dataviews/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/dataviews/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md new file mode 100644 index 0000000000000..6ed52df107782 --- /dev/null +++ b/packages/dataviews/CHANGELOG.md @@ -0,0 +1,3 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> + +## Unreleased diff --git a/packages/edit-site/src/components/dataviews/README.md b/packages/dataviews/README.md similarity index 67% rename from packages/edit-site/src/components/dataviews/README.md rename to packages/dataviews/README.md index 9f0c7a61087c9..c0d0a01cbc3e2 100644 --- a/packages/edit-site/src/components/dataviews/README.md +++ b/packages/dataviews/README.md @@ -1,6 +1,16 @@ -# DataView +# DataViews -This file documents the DataViews UI component, which provides an API to render datasets using different view types (table, grid, etc.). +DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.). + +## Installation + +Install the module + +```bash +npm install @wordpress/dataviews --save +``` + +## Usage ```js <DataViews @@ -12,6 +22,7 @@ This file documents the DataViews UI component, which provides an API to render fields={ fields } actions={ [ trashPostAction ] } paginationInfo={ { totalItems, totalPages } } + onSelectionChange={ ( items ) => { /* ... */ } } /> ``` @@ -36,7 +47,7 @@ Example: ```js { - type: 'list', + type: 'table', perPage: 5, page: 1, sort: { @@ -45,15 +56,15 @@ Example: }, search: '', filters: [ - { field: 'author', operator: OPERATOR_IN, value: 2 }, - { field: 'status', operator: OPERATOR_IN, value: 'publish,draft' } + { field: 'author', operator: 'in', value: 2 }, + { field: 'status', operator: 'in', value: 'publish,draft' } ], hiddenFields: [ 'date', 'featured-image' ], layout: {}, } ``` -- `type`: view type, one of `list` or `grid`. +- `type`: view type, one of `table`, `grid`, `list`. See "View types". - `perPage`: number of records to show per page. - `page`: the page that is visible. - `sort.field`: field used for sorting the dataset. @@ -61,12 +72,12 @@ Example: - `search`: the text search applied to the dataset. - `filters`: the filters applied to the dataset. Each item describes: - `field`: which field this filter is bound to. - - `operator`: which type of filter it is. Only `in` available at the moment. + - `operator`: which type of filter it is. One of `in`, `notIn`. See "Operator types". - `value`: the actual value selected by the user. - `hiddenFields`: the `id` of the fields that are hidden in the UI. - `layout`: config that is specific to a particular layout type. - - `mediaField`: used by the `grid` layout. The `id` of the field to be used for rendering each card's media. - - `primaryField`: used by the `grid` layout. The `id` of the field to be used for rendering each card's title. + - `mediaField`: used by the `grid` and `list` layouts. The `id` of the field to be used for rendering each card's media. + - `primaryField`: used by the `grid` and `list` layouts. The `id` of the field to be highlighted in each card/list item. ### View <=> data @@ -75,9 +86,9 @@ The view is a representation of the visible state of the dataset. Note, however, The following example shows how a view object is used to query the WordPress REST API via the entities abstraction. The same can be done with any other data provider. ```js -function MyCustomPageList() { +function MyCustomPageTable() { const [ view, setView ] = useState( { - type: 'list', + type: 'table', perPage: 5, page: 1, sort: { @@ -86,8 +97,8 @@ function MyCustomPageList() { }, search: '', filters: [ - { field: 'author', operator: OPERATOR_IN, value: 2 }, - { field: 'status', operator: OPERATOR_IN, value: 'publish,draft' } + { field: 'author', operator: 'in', value: 2 }, + { field: 'status', operator: 'in', value: 'publish,draft' } ], hiddenFields: [ 'date', 'featured-image' ], layout: {}, @@ -96,10 +107,10 @@ function MyCustomPageList() { const queryArgs = useMemo( () => { const filters = {}; view.filters.forEach( ( filter ) => { - if ( filter.field === 'status' && filter.operator === OPERATOR_IN ) { + if ( filter.field === 'status' && filter.operator === 'in' ) { filters.status = filter.value; } - if ( filter.field === 'author' && filter.operator === OPERATOR_IN ) { + if ( filter.field === 'author' && filter.operator === 'in' ) { filters.author = filter.value; } } ); @@ -157,7 +168,7 @@ Example: <a href="...">{ item.author }</a> ); }, - type: ENUMERATION_TYPE, + type: 'enumeration', elements: [ { value: 1, label: 'Admin' } { value: 2, label: 'User' } @@ -172,9 +183,11 @@ Example: - `getValue`: function that returns the value of the field. - `render`: function that renders the field. - `elements`: the set of valid values for the field's value. -- `type`: the type of the field. Used to generate the proper filters. Only `enumeration` available at the moment. +- `type`: the type of the field. Used to generate the proper filters. Only `enumeration` available at the moment. See "Field types". - `enableSorting`: whether the data can be sorted by the given field. True by default. - `enableHiding`: whether the field can be hidden. True by default. +- `filterBy`: configuration for the filters. + - `operators`: the list of operators supported by the field. ## Actions @@ -189,3 +202,23 @@ Array of operations that can be performed upon each record. Each action is an ob - `callback`: function, required unless `RenderModal` is provided. Callback function that takes the record as input and performs the required action. - `RenderModal`: ReactElement, optional. If an action requires that some UI be rendered in a modal, it can provide a component which takes as props the record as `item` and a `closeModal` function. When this prop is provided, the `callback` property is ignored. - `hideModalHeader`: boolean, optional. This property is used in combination with `RenderModal` and controls the visibility of the modal's header. If the action renders a modal and doesn't hide the header, the action's label is going to be used in the modal's header. + +## Types + +- Layout types: + - `table`: the view uses a table layout. + - `grid`: the view uses a grid layout. + - `list`: the view uses a list layout. +- Field types: + - `enumeration`: the field value should be taken and can be filtered from a closed list of elements. +- Operator types: + - `in`: operator to be used in filters for fields of type `enumeration`. + - `notIn`: operator to be used in filters for fields of type `enumeration`. + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json new file mode 100644 index 0000000000000..1872480d759c3 --- /dev/null +++ b/packages/dataviews/package.json @@ -0,0 +1,48 @@ +{ + "name": "@wordpress/dataviews", + "version": "0.1.0", + "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "dataviews" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/dataviews/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/dataviews" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "@wordpress/keycodes": "file:../keycodes", + "@wordpress/private-apis": "file:../private-apis", + "classnames": "^2.3.1", + "remove-accents": "^0.5.0" + }, + "peerDependencies": { + "react": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/edit-site/src/components/dataviews/add-filter.js b/packages/dataviews/src/add-filter.js similarity index 93% rename from packages/edit-site/src/components/dataviews/add-filter.js rename to packages/dataviews/src/add-filter.js index 7999ff413f96c..715135a533fb4 100644 --- a/packages/edit-site/src/components/dataviews/add-filter.js +++ b/packages/dataviews/src/add-filter.js @@ -12,7 +12,7 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { unlock } from '../../lock-unlock'; +import { unlock } from './lock-unlock'; import { ENUMERATION_TYPE, OPERATOR_IN } from './constants'; const { @@ -36,8 +36,7 @@ export default function AddFilter( { fields, view, onChangeView } ) { name: field.header, elements: field.elements || [], isVisible: view.filters.some( - ( f ) => - f.field === field.id && f.operator === OPERATOR_IN + ( f ) => f.field === field.id ), } ); } @@ -95,7 +94,6 @@ export default function AddFilter( { fields, view, onChangeView } ) { ], } ) ); } } - role="menuitemcheckbox" > { element.label } </DropdownMenuItem> diff --git a/packages/dataviews/src/constants.js b/packages/dataviews/src/constants.js new file mode 100644 index 0000000000000..387050a1dca5b --- /dev/null +++ b/packages/dataviews/src/constants.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { __, isRTL } from '@wordpress/i18n'; +import { + blockTable, + category, + formatListBullets, + formatListBulletsRTL, +} from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import ViewTable from './view-table'; +import ViewGrid from './view-grid'; +import ViewList from './view-list'; + +// Field types. +export const ENUMERATION_TYPE = 'enumeration'; + +// Filter operators. +export const OPERATOR_IN = 'in'; +export const OPERATOR_NOT_IN = 'notIn'; + +// View layouts. +export const LAYOUT_TABLE = 'table'; +export const LAYOUT_GRID = 'grid'; +export const LAYOUT_LIST = 'list'; + +export const VIEW_LAYOUTS = [ + { + type: LAYOUT_TABLE, + label: __( 'Table' ), + component: ViewTable, + icon: blockTable, + }, + { + type: LAYOUT_GRID, + label: __( 'Grid' ), + component: ViewGrid, + icon: category, + }, + { + type: LAYOUT_LIST, + label: __( 'List' ), + component: ViewList, + icon: isRTL() ? formatListBulletsRTL : formatListBullets, + }, +]; diff --git a/packages/edit-site/src/components/dataviews/dataviews.js b/packages/dataviews/src/dataviews.js similarity index 70% rename from packages/edit-site/src/components/dataviews/dataviews.js rename to packages/dataviews/src/dataviews.js index 56a9cfd7c6ae3..9e7b45d04ef87 100644 --- a/packages/edit-site/src/components/dataviews/dataviews.js +++ b/packages/dataviews/src/dataviews.js @@ -5,33 +5,16 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, } from '@wordpress/components'; -import { useMemo } from '@wordpress/element'; +import { useMemo, useState } from '@wordpress/element'; /** * Internal dependencies */ -import ViewList from './view-list'; import Pagination from './pagination'; import ViewActions from './view-actions'; import Filters from './filters'; import Search from './search'; -import { ViewGrid } from './view-grid'; -import { ViewSideBySide } from './view-side-by-side'; - -// To do: convert to view type registry. -export const viewTypeSupportsMap = { - list: {}, - grid: {}, - 'side-by-side': { - preview: true, - }, -}; - -const viewTypeMap = { - list: ViewList, - grid: ViewGrid, - 'side-by-side': ViewSideBySide, -}; +import { VIEW_LAYOUTS } from './constants'; export default function DataViews( { view, @@ -45,8 +28,19 @@ export default function DataViews( { isLoading = false, paginationInfo, supportedLayouts, + onSelectionChange, + deferredRendering, } ) { - const ViewComponent = viewTypeMap[ view.type ]; + const [ selection, setSelection ] = useState( [] ); + + const onSetSelection = ( items ) => { + setSelection( items.map( ( item ) => item.id ) ); + onSelectionChange( items ); + }; + + const ViewComponent = VIEW_LAYOUTS.find( + ( v ) => v.type === view.type + ).component; const _fields = useMemo( () => { return fields.map( ( field ) => ( { ...field, @@ -55,8 +49,11 @@ export default function DataViews( { }, [ fields ] ); return ( <div className="dataviews-wrapper"> - <VStack spacing={ 4 } justify="flex-start"> - <HStack alignment="flex-start"> + <VStack spacing={ 0 } justify="flex-start"> + <HStack + alignment="flex-start" + className="dataviews__filters-view-actions" + > <HStack justify="start" wrap> { search && ( <Search @@ -87,6 +84,9 @@ export default function DataViews( { data={ data } getItemId={ getItemId } isLoading={ isLoading } + onSelectionChange={ onSetSelection } + selection={ selection } + deferredRendering={ deferredRendering } /> <Pagination view={ view } diff --git a/packages/dataviews/src/filter-summary.js b/packages/dataviews/src/filter-summary.js new file mode 100644 index 0000000000000..3c30c6837103a --- /dev/null +++ b/packages/dataviews/src/filter-summary.js @@ -0,0 +1,221 @@ +/** + * WordPress dependencies + */ +import { + Button, + privateApis as componentsPrivateApis, + Icon, +} from '@wordpress/components'; +import { chevronDown, chevronRightSmall, check } from '@wordpress/icons'; +import { __, sprintf } from '@wordpress/i18n'; +import { Children, Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { OPERATOR_IN, OPERATOR_NOT_IN } from './constants'; +import { unlock } from './lock-unlock'; + +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuGroupV2: DropdownMenuGroup, + DropdownMenuItemV2: DropdownMenuItem, + DropdownMenuSeparatorV2: DropdownMenuSeparator, + DropdownSubMenuV2: DropdownSubMenu, + DropdownSubMenuTriggerV2: DropdownSubMenuTrigger, +} = unlock( componentsPrivateApis ); + +const FilterText = ( { activeElement, filterInView, filter } ) => { + if ( activeElement === undefined ) { + return filter.name; + } + + if ( + activeElement !== undefined && + filterInView?.operator === OPERATOR_IN + ) { + return sprintf( + /* translators: 1: Filter name. 2: Filter value. e.g.: "Author is Admin". */ + __( '%1$s is %2$s' ), + filter.name, + activeElement.label + ); + } + + if ( + activeElement !== undefined && + filterInView?.operator === OPERATOR_NOT_IN + ) { + return sprintf( + /* translators: 1: Filter name. 2: Filter value. e.g.: "Author is not Admin". */ + __( '%1$s is not %2$s' ), + filter.name, + activeElement.label + ); + } + + return sprintf( + /* translators: 1: Filter name e.g.: "Unknown status for Author". */ + __( 'Unknown status for %1$s' ), + filter.name + ); +}; + +function WithSeparators( { children } ) { + return Children.toArray( children ) + .filter( Boolean ) + .map( ( child, i ) => ( + <Fragment key={ i }> + { i > 0 && <DropdownMenuSeparator /> } + { child } + </Fragment> + ) ); +} + +export default function FilterSummary( { filter, view, onChangeView } ) { + const filterInView = view.filters.find( ( f ) => f.field === filter.field ); + const activeElement = filter.elements.find( + ( element ) => element.value === filterInView?.value + ); + + return ( + <DropdownMenu + key={ filter.field } + trigger={ + <Button variant="tertiary" size="compact" label={ filter.name }> + <FilterText + activeElement={ activeElement } + filterInView={ filterInView } + filter={ filter } + /> + <Icon icon={ chevronDown } style={ { flexShrink: 0 } } /> + </Button> + } + > + <WithSeparators> + <DropdownMenuGroup> + { filter.elements.map( ( element ) => { + return ( + <DropdownMenuItem + key={ element.value } + role="menuitemradio" + aria-checked={ + activeElement?.value === element.value + } + prefix={ + activeElement?.value === element.value && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => + onChangeView( ( currentView ) => ( { + ...currentView, + page: 1, + filters: [ + ...view.filters.filter( + ( f ) => + f.field !== filter.field + ), + { + field: filter.field, + operator: + filterInView?.operator || + filter.operators[ 0 ], + value: + activeElement?.value === + element.value + ? undefined + : element.value, + }, + ], + } ) ) + } + > + { element.label } + </DropdownMenuItem> + ); + } ) } + </DropdownMenuGroup> + { filter.operators.length > 1 && ( + <DropdownSubMenu + trigger={ + <DropdownSubMenuTrigger + suffix={ + <> + { filterInView.operator === OPERATOR_IN + ? __( 'Is' ) + : __( 'Is not' ) } + <Icon icon={ chevronRightSmall } />{ ' ' } + </> + } + > + { __( 'Conditions' ) } + </DropdownSubMenuTrigger> + } + > + <DropdownMenuItem + key="in-filter" + role="menuitemradio" + aria-checked={ + filterInView?.operator === OPERATOR_IN + } + prefix={ + filterInView?.operator === OPERATOR_IN && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => + onChangeView( ( currentView ) => ( { + ...currentView, + page: 1, + filters: [ + ...view.filters.filter( + ( f ) => f.field !== filter.field + ), + { + field: filter.field, + operator: OPERATOR_IN, + value: filterInView?.value, + }, + ], + } ) ) + } + > + { __( 'Is' ) } + </DropdownMenuItem> + <DropdownMenuItem + key="not-in-filter" + role="menuitemradio" + aria-checked={ + filterInView?.operator === OPERATOR_NOT_IN + } + prefix={ + filterInView?.operator === OPERATOR_NOT_IN && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => + onChangeView( ( currentView ) => ( { + ...currentView, + page: 1, + filters: [ + ...view.filters.filter( + ( f ) => f.field !== filter.field + ), + { + field: filter.field, + operator: OPERATOR_NOT_IN, + value: filterInView?.value, + }, + ], + } ) ) + } + > + { __( 'Is not' ) } + </DropdownMenuItem> + </DropdownSubMenu> + ) } + </WithSeparators> + </DropdownMenu> + ); +} diff --git a/packages/edit-site/src/components/dataviews/filters.js b/packages/dataviews/src/filters.js similarity index 66% rename from packages/edit-site/src/components/dataviews/filters.js rename to packages/dataviews/src/filters.js index 0583fd1e45eb6..e2d24e7a848ee 100644 --- a/packages/edit-site/src/components/dataviews/filters.js +++ b/packages/dataviews/src/filters.js @@ -4,7 +4,17 @@ import FilterSummary from './filter-summary'; import AddFilter from './add-filter'; import ResetFilters from './reset-filters'; -import { ENUMERATION_TYPE, OPERATOR_IN } from './constants'; +import { ENUMERATION_TYPE, OPERATOR_IN, OPERATOR_NOT_IN } from './constants'; + +const operatorsFromField = ( field ) => { + let operators = field.filterBy?.operators; + if ( ! operators || ! Array.isArray( operators ) ) { + operators = [ OPERATOR_IN, OPERATOR_NOT_IN ]; + } + return operators.filter( ( operator ) => + [ OPERATOR_IN, OPERATOR_NOT_IN ].includes( operator ) + ); +}; export default function Filters( { fields, view, onChangeView } ) { const filters = []; @@ -13,15 +23,24 @@ export default function Filters( { fields, view, onChangeView } ) { return; } + const operators = operatorsFromField( field ); + if ( operators.length === 0 ) { + return; + } + switch ( field.type ) { case ENUMERATION_TYPE: filters.push( { field: field.id, name: field.header, elements: field.elements || [], + operators, isVisible: view.filters.some( ( f ) => - f.field === field.id && f.operator === OPERATOR_IN + f.field === field.id && + [ OPERATOR_IN, OPERATOR_NOT_IN ].includes( + f.operator + ) ), } ); } diff --git a/packages/dataviews/src/index.js b/packages/dataviews/src/index.js new file mode 100644 index 0000000000000..01c67b34c5c99 --- /dev/null +++ b/packages/dataviews/src/index.js @@ -0,0 +1,2 @@ +export { default as DataViews } from './dataviews'; +export { VIEW_LAYOUTS } from './constants'; diff --git a/packages/edit-site/src/components/dataviews/item-actions.js b/packages/dataviews/src/item-actions.js similarity index 97% rename from packages/edit-site/src/components/dataviews/item-actions.js rename to packages/dataviews/src/item-actions.js index bec33e915b8a8..1b0bd5f213ca8 100644 --- a/packages/edit-site/src/components/dataviews/item-actions.js +++ b/packages/dataviews/src/item-actions.js @@ -14,7 +14,7 @@ import { moreVertical } from '@wordpress/icons'; /** * Internal dependencies */ -import { unlock } from '../../lock-unlock'; +import { unlock } from './lock-unlock'; const { DropdownMenuV2Ariakit: DropdownMenu, @@ -37,7 +37,10 @@ function ButtonTrigger( { action, onClick } ) { function DropdownMenuItemTrigger( { action, onClick } ) { return ( - <DropdownMenuItem onClick={ onClick }> + <DropdownMenuItem + onClick={ onClick } + hideOnClick={ ! action.RenderModal } + > <DropdownMenuItemLabel>{ action.label }</DropdownMenuItemLabel> </DropdownMenuItem> ); diff --git a/packages/dataviews/src/lock-unlock.js b/packages/dataviews/src/lock-unlock.js new file mode 100644 index 0000000000000..18318773cefef --- /dev/null +++ b/packages/dataviews/src/lock-unlock.js @@ -0,0 +1,10 @@ +/** + * WordPress dependencies + */ +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { lock, unlock } = + __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I know using unstable features means my theme or plugin will inevitably break in the next version of WordPress.', + '@wordpress/dataviews' + ); diff --git a/packages/edit-site/src/components/dataviews/pagination.js b/packages/dataviews/src/pagination.js similarity index 98% rename from packages/edit-site/src/components/dataviews/pagination.js rename to packages/dataviews/src/pagination.js index 25672208d993c..1c41691a13d0a 100644 --- a/packages/edit-site/src/components/dataviews/pagination.js +++ b/packages/dataviews/src/pagination.js @@ -36,7 +36,7 @@ function Pagination( { ) } </Text> - { !! totalItems && ( + { !! totalItems && totalPages !== 1 && ( <HStack expanded={ false } spacing={ 3 }> <HStack justify="flex-start" diff --git a/packages/edit-site/src/components/dataviews/reset-filters.js b/packages/dataviews/src/reset-filters.js similarity index 100% rename from packages/edit-site/src/components/dataviews/reset-filters.js rename to packages/dataviews/src/reset-filters.js diff --git a/packages/edit-site/src/components/dataviews/search.js b/packages/dataviews/src/search.js similarity index 90% rename from packages/edit-site/src/components/dataviews/search.js rename to packages/dataviews/src/search.js index 17a882637a718..2e58b721d6e2e 100644 --- a/packages/edit-site/src/components/dataviews/search.js +++ b/packages/dataviews/src/search.js @@ -4,11 +4,7 @@ import { __ } from '@wordpress/i18n'; import { useEffect, useRef } from '@wordpress/element'; import { SearchControl } from '@wordpress/components'; - -/** - * Internal dependencies - */ -import useDebouncedInput from '../../utils/use-debounced-input'; +import { useDebouncedInput } from '@wordpress/compose'; export default function Search( { label, view, onChangeView } ) { const [ search, setSearch, debouncedSearch ] = useDebouncedInput( diff --git a/packages/dataviews/src/stories/fixtures.js b/packages/dataviews/src/stories/fixtures.js new file mode 100644 index 0000000000000..6b9073e2cc78d --- /dev/null +++ b/packages/dataviews/src/stories/fixtures.js @@ -0,0 +1,126 @@ +/** + * WordPress dependencies + */ +import { trash } from '@wordpress/icons'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { LAYOUT_TABLE } from '../constants'; + +export const data = [ + { + id: 1, + title: 'Apollo', + description: 'Apollo description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 2, + title: 'Space', + description: 'Space description', + image: 'https://live.staticflickr.com/5678/21911065441_92e2d44708_b.jpg', + }, + { + id: 3, + title: 'NASA', + description: 'NASA photo', + image: 'https://live.staticflickr.com/742/21712365770_8f70a2c91e_b.jpg', + }, + { + id: 4, + title: 'Neptune', + description: 'Neptune description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 5, + title: 'Mercury', + description: 'Mercury description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 6, + title: 'Venus', + description: 'Venus description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 7, + title: 'Earth', + description: 'Earth description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 8, + title: 'Mars', + description: 'Mars description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 9, + title: 'Jupiter', + description: 'Jupiter description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 10, + title: 'Saturn', + description: 'Saturn description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, + { + id: 11, + title: 'Uranus', + description: 'Uranus description', + image: 'https://live.staticflickr.com/5725/21726228300_51333bd62c_b.jpg', + }, +]; + +export const DEFAULT_VIEW = { + type: LAYOUT_TABLE, + search: '', + page: 1, + perPage: 10, + hiddenFields: [ 'image' ], + layout: {}, + filters: [], +}; + +export const actions = [ + { + id: 'delete', + label: 'Delete item', + isPrimary: true, + icon: trash, + hideModalHeader: true, + RenderModal: ( { item, closeModal } ) => { + return ( + <VStack spacing="5"> + <Text> + { `Are you sure you want to delete "${ item.title }"?` } + </Text> + <HStack justify="right"> + <Button variant="tertiary" onClick={ closeModal }> + Cancel + </Button> + <Button variant="primary" onClick={ closeModal }> + Delete + </Button> + </HStack> + </VStack> + ); + }, + }, + { + id: 'secondary', + label: 'Secondary action', + callback() {}, + }, +]; diff --git a/packages/dataviews/src/stories/index.story.js b/packages/dataviews/src/stories/index.story.js new file mode 100644 index 0000000000000..e0bea0c92c2b2 --- /dev/null +++ b/packages/dataviews/src/stories/index.story.js @@ -0,0 +1,137 @@ +/** + * WordPress dependencies + */ +import { useState, useMemo, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { DataViews, LAYOUT_GRID, LAYOUT_TABLE } from '../index'; + +import { DEFAULT_VIEW, actions, data } from './fixtures'; + +const meta = { + title: 'DataViews (Experimental)/DataViews', + component: DataViews, +}; +export default meta; + +const defaultConfigPerViewType = { + [ LAYOUT_TABLE ]: {}, + [ LAYOUT_GRID ]: { + mediaField: 'image', + primaryField: 'title', + }, +}; + +function normalizeSearchInput( input = '' ) { + return input.trim().toLowerCase(); +} + +const fields = [ + { + header: 'Image', + id: 'image', + render: ( { item } ) => { + return ( + <img src={ item.image } alt="" style={ { width: '100%' } } /> + ); + }, + width: 50, + enableSorting: false, + }, + { + header: 'Title', + id: 'title', + getValue: ( { item } ) => item.title, + maxWidth: 400, + enableHiding: false, + }, + { + header: 'Description', + id: 'description', + getValue: ( { item } ) => item.description, + maxWidth: 200, + enableSorting: false, + }, +]; + +export const Default = ( props ) => { + const [ view, setView ] = useState( DEFAULT_VIEW ); + const { shownData, paginationInfo } = useMemo( () => { + let filteredData = [ ...data ]; + // Handle global search. + if ( view.search ) { + const normalizedSearch = normalizeSearchInput( view.search ); + filteredData = filteredData.filter( ( item ) => { + return [ + normalizeSearchInput( item.title ), + normalizeSearchInput( item.description ), + ].some( ( field ) => field.includes( normalizedSearch ) ); + } ); + } + // Handle sorting. + if ( view.sort ) { + const stringSortingFields = [ 'title' ]; + const fieldId = view.sort.field; + if ( stringSortingFields.includes( fieldId ) ) { + const fieldToSort = fields.find( ( field ) => { + return field.id === fieldId; + } ); + filteredData.sort( ( a, b ) => { + const valueA = fieldToSort.getValue( { item: a } ) ?? ''; + const valueB = fieldToSort.getValue( { item: b } ) ?? ''; + return view.sort.direction === 'asc' + ? valueA.localeCompare( valueB ) + : valueB.localeCompare( valueA ); + } ); + } + } + // Handle pagination. + const start = ( view.page - 1 ) * view.perPage; + const totalItems = filteredData?.length || 0; + filteredData = filteredData?.slice( start, start + view.perPage ); + return { + shownData: filteredData, + paginationInfo: { + totalItems, + totalPages: Math.ceil( totalItems / view.perPage ), + }, + }; + }, [ view ] ); + const onChangeView = useCallback( + ( viewUpdater ) => { + let updatedView = + typeof viewUpdater === 'function' + ? viewUpdater( view ) + : viewUpdater; + if ( updatedView.type !== view.type ) { + updatedView = { + ...updatedView, + layout: { + ...defaultConfigPerViewType[ updatedView.type ], + }, + }; + } + + setView( updatedView ); + }, + [ view, setView ] + ); + return ( + <DataViews + { ...props } + paginationInfo={ paginationInfo } + data={ shownData } + view={ view } + fields={ fields } + onChangeView={ onChangeView } + /> + ); +}; +Default.args = { + actions, + getItemId: ( item ) => item.id, + isLoading: false, + supportedLayouts: [ LAYOUT_TABLE, LAYOUT_GRID ], +}; diff --git a/packages/dataviews/src/style.scss b/packages/dataviews/src/style.scss new file mode 100644 index 0000000000000..3ff82d3238266 --- /dev/null +++ b/packages/dataviews/src/style.scss @@ -0,0 +1,245 @@ +.dataviews-wrapper { + width: 100%; + height: 100%; + overflow: auto; + box-sizing: border-box; + scroll-padding-bottom: $grid-unit-80; + + > div { + min-height: 100%; + } +} + +.dataviews__filters-view-actions { + padding: $grid-unit-15 $grid-unit-40; +} + +.dataviews-pagination { + margin-top: auto; + position: sticky; + bottom: 0; + background-color: $white; + padding: $grid-unit-15 $grid-unit-40; + border-top: $border-width solid $gray-100; + color: $gray-700; +} + +.dataviews-filters-options { + margin: $grid-unit-40 0 $grid-unit-20; +} + +.dataviews-table-view { + width: 100%; + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + position: relative; + color: $gray-700; + + a { + text-decoration: none; + color: $gray-900; + font-weight: 500; + } + th { + text-align: left; + color: var(--wp-components-color-foreground, $gray-900); + font-weight: normal; + font-size: $default-font-size; + } + td, + th { + padding: $grid-unit-15; + min-width: 160px; + &[data-field-id="actions"] { + text-align: right; + } + } + tr { + border-bottom: 1px solid $gray-100; + + td:first-child, + th:first-child { + padding-left: $grid-unit-40; + } + + td:last-child, + th:last-child { + padding-right: $grid-unit-40; + } + + &:last-child { + border-bottom: 0; + } + } + thead { + tr { + border: 0; + } + th { + position: sticky; + top: -1px; + background-color: lighten($gray-100, 4%); + box-shadow: inset 0 -#{$border-width} 0 $gray-100; + border-top: 1px solid $gray-100; + padding-top: $grid-unit-05; + padding-bottom: $grid-unit-05; + } + } +} + +.dataviews-grid-view { + margin-bottom: $grid-unit-30; + grid-template-columns: repeat(2, minmax(0, 1fr)) !important; + padding: 0 $grid-unit-40; + + @include break-xlarge() { + grid-template-columns: repeat(3, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency + } + + @include break-huge() { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency + } + + .dataviews-view-grid__card { + h3 { // Todo: A better way to target this + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .dataviews-view-grid__media { + width: 100%; + min-height: 200px; + aspect-ratio: 1/1; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + border-radius: $radius-block-ui * 2; + overflow: hidden; + + > * { + object-fit: cover; + width: 100%; + height: 100%; + } + } + + .dataviews-view-grid__primary-field { + min-height: $grid-unit-30; + + a { + color: $gray-900; + text-decoration: none; + font-weight: 500; + } + } + + .dataviews-view-grid__fields { + position: relative; + font-size: 12px; + line-height: 16px; + + .dataviews-view-grid__field { + .dataviews-view-grid__field-header { + color: $gray-700; + } + .dataviews-view-grid__field-value { + color: $gray-900; + } + } + } +} + +.dataviews-list-view { + margin: 0; + + li { + border-bottom: $border-width solid $gray-100; + margin: 0; + &:first-child { + border-top: $border-width solid $gray-100; + } + &:last-child { + border-bottom: 0; + } + } + + .dataviews-list-view__item { + padding: $grid-unit-15 $grid-unit-40; + cursor: default; + &:focus, + &:hover { + background-color: lighten($gray-100, 3%); + } + &:focus { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + h3 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .dataviews-list-view__item-selected, + .dataviews-list-view__item-selected:hover { + background-color: $gray-100; + + &:focus { + box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + } + + .dataviews-list-view__media-wrapper { + min-width: $grid-unit-40; + height: $grid-unit-40; + border-radius: $grid-unit-05; + overflow: hidden; + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0 0 0 $border-width rgba(0, 0, 0, 0.1); + border-radius: $grid-unit-05; + } + } + + .edit-site-page-pages__featured-image, + .dataviews-list-view__media-placeholder { + min-width: $grid-unit-40; + height: $grid-unit-40; + } + + .dataviews-list-view__media-placeholder { + background-color: $gray-200; + } + + .dataviews-list-view__fields { + color: $gray-700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .dataviews-list-view__field { + margin-right: $grid-unit-15; + + &:last-child { + margin-right: 0; + } + } + } +} + +.dataviews-action-modal { + z-index: z-index(".dataviews-action-modal"); +} + +.dataviews-no-results, +.dataviews-loading { + padding: 0 $grid-unit-40; +} diff --git a/packages/edit-site/src/components/dataviews/view-actions.js b/packages/dataviews/src/view-actions.js similarity index 81% rename from packages/edit-site/src/components/dataviews/view-actions.js rename to packages/dataviews/src/view-actions.js index bcc63c04117d7..a5330c08f299c 100644 --- a/packages/edit-site/src/components/dataviews/view-actions.js +++ b/packages/dataviews/src/view-actions.js @@ -6,21 +6,14 @@ import { Icon, privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { - chevronRightSmall, - check, - blockTable, - arrowUp, - arrowDown, - grid, - columns, -} from '@wordpress/icons'; +import { chevronRightSmall, check, arrowUp, arrowDown } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import { unlock } from '../../lock-unlock'; +import { unlock } from './lock-unlock'; +import { VIEW_LAYOUTS, LAYOUT_TABLE } from './constants'; const { DropdownMenuV2: DropdownMenu, @@ -30,32 +23,17 @@ const { DropdownSubMenuTriggerV2: DropdownSubMenuTrigger, } = unlock( componentsPrivateApis ); -const availableViews = [ - { - id: 'list', - label: __( 'List' ), - }, - { - id: 'grid', - label: __( 'Grid' ), - }, - { - id: 'side-by-side', - label: __( 'Side by side' ), - }, -]; - function ViewTypeMenu( { view, onChangeView, supportedLayouts } ) { - let _availableViews = availableViews; + let _availableViews = VIEW_LAYOUTS; if ( supportedLayouts ) { _availableViews = _availableViews.filter( ( _view ) => - supportedLayouts.includes( _view.id ) + supportedLayouts.includes( _view.type ) ); } if ( _availableViews.length === 1 ) { return null; } - const activeView = _availableViews.find( ( v ) => view.type === v.id ); + const activeView = _availableViews.find( ( v ) => view.type === v.type ); return ( <DropdownSubMenu trigger={ @@ -74,19 +52,22 @@ function ViewTypeMenu( { view, onChangeView, supportedLayouts } ) { { _availableViews.map( ( availableView ) => { return ( <DropdownMenuItem - key={ availableView.id } + key={ availableView.type } + role="menuitemradio" + aria-checked={ availableView.id === view.type } prefix={ - availableView.id === view.type && ( + availableView.type === view.type && ( <Icon icon={ check } /> ) } onSelect={ ( event ) => { // We need to handle this on DropDown component probably.. event.preventDefault(); - onChangeView( { ...view, type: availableView.id } ); + onChangeView( { + ...view, + type: availableView.type, + } ); } } - // TODO: check about role and a11y. - role="menuitemcheckbox" > { availableView.label } </DropdownMenuItem> @@ -118,6 +99,8 @@ function PageSizeMenu( { view, onChangeView } ) { return ( <DropdownMenuItem key={ size } + role="menuitemradio" + aria-checked={ view.perPage === size } prefix={ view.perPage === size && <Icon icon={ check } /> } @@ -126,8 +109,6 @@ function PageSizeMenu( { view, onChangeView } ) { event.preventDefault(); onChangeView( { ...view, perPage: size, page: 1 } ); } } - // TODO: check about role and a11y. - role="menuitemcheckbox" > { size } </DropdownMenuItem> @@ -139,7 +120,8 @@ function PageSizeMenu( { view, onChangeView } ) { function FieldsVisibilityMenu( { view, onChangeView, fields } ) { const hidableFields = fields.filter( - ( field ) => field.enableHiding !== false + ( field ) => + field.enableHiding !== false && field.id !== view.layout.mediaField ); if ( ! hidableFields?.length ) { return null; @@ -158,6 +140,7 @@ function FieldsVisibilityMenu( { view, onChangeView, fields } ) { return ( <DropdownMenuItem key={ field.id } + role="menuitemcheckbox" prefix={ ! view.hiddenFields?.includes( field.id ) && ( <Icon icon={ check } /> @@ -173,10 +156,12 @@ function FieldsVisibilityMenu( { view, onChangeView, fields } ) { ? view.hiddenFields.filter( ( id ) => id !== field.id ) - : [ ...view.hiddenFields, field.id ], + : [ + ...( view.hiddenFields || [] ), + field.id, + ], } ); } } - role="menuitemcheckbox" > { field.header } </DropdownMenuItem> @@ -239,28 +224,21 @@ function SortMenu( { fields, view, onChangeView } ) { return ( <DropdownMenuItem key={ direction } + role="menuitemradio" + aria-checked={ isActive } prefix={ <Icon icon={ info.icon } /> } suffix={ isActive && <Icon icon={ check } /> } onSelect={ ( event ) => { event.preventDefault(); - if ( - sortedDirection === direction - ) { - onChangeView( { - ...view, - sort: undefined, - } ); - } else { - onChangeView( { - ...view, - sort: { - field: field.id, - direction, - }, - } ); - } + onChangeView( { + ...view, + sort: { + field: field.id, + direction, + }, + } ); } } > { info.label } @@ -275,8 +253,6 @@ function SortMenu( { fields, view, onChangeView } ) { ); } -const VIEW_TYPE_ICONS = { list: blockTable, grid, 'side-by-side': columns }; - export default function ViewActions( { fields, view, @@ -290,7 +266,10 @@ export default function ViewActions( { variant="tertiary" size="compact" icon={ - VIEW_TYPE_ICONS[ view.type ] || VIEW_TYPE_ICONS.list + VIEW_LAYOUTS.find( ( v ) => v.type === view.type ) + ?.icon || + VIEW_LAYOUTS.find( ( v ) => v.type === LAYOUT_TABLE ) + .icon } label={ __( 'View options' ) } /> diff --git a/packages/edit-site/src/components/dataviews/view-grid.js b/packages/dataviews/src/view-grid.js similarity index 81% rename from packages/edit-site/src/components/dataviews/view-grid.js rename to packages/dataviews/src/view-grid.js index 597f3b13bd309..e2c34ba6749fa 100644 --- a/packages/edit-site/src/components/dataviews/view-grid.js +++ b/packages/dataviews/src/view-grid.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { + FlexBlock, __experimentalGrid as Grid, __experimentalHStack as HStack, __experimentalVStack as VStack, @@ -13,7 +14,14 @@ import { useAsyncList } from '@wordpress/compose'; */ import ItemActions from './item-actions'; -export function ViewGrid( { data, fields, view, actions, getItemId } ) { +export default function ViewGrid( { + data, + fields, + view, + actions, + getItemId, + deferredRendering, +} ) { const mediaField = fields.find( ( field ) => field.id === view.layout.mediaField ); @@ -28,6 +36,7 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { ) ); const shownData = useAsyncList( data, { step: 3 } ); + const usedData = deferredRendering ? shownData : data; return ( <Grid gap={ 8 } @@ -35,20 +44,22 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { alignment="top" className="dataviews-grid-view" > - { shownData.map( ( item, index ) => ( + { usedData.map( ( item, index ) => ( <VStack spacing={ 3 } key={ getItemId?.( item ) || index } className="dataviews-view-grid__card" > <div className="dataviews-view-grid__media"> - { mediaField?.render( { item, view } ) } + { mediaField?.render( { item } ) } </div> <HStack - className="dataviews-view-grid__title" + className="dataviews-view-grid__primary-field" justify="space-between" > - { primaryField?.render( { item, view } ) } + <FlexBlock> + { primaryField?.render( { item } ) } + </FlexBlock> <ItemActions item={ item } actions={ actions } @@ -62,7 +73,6 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { { visibleFields.map( ( field ) => { const renderedValue = field.render( { item, - view, } ); if ( ! renderedValue ) { return null; @@ -77,7 +87,7 @@ export function ViewGrid( { data, fields, view, actions, getItemId } ) { { field.header } </div> <div className="dataviews-view-grid__field-value"> - { field.render( { item, view } ) } + { field.render( { item } ) } </div> </VStack> ); diff --git a/packages/dataviews/src/view-list.js b/packages/dataviews/src/view-list.js new file mode 100644 index 0000000000000..516264946d1f6 --- /dev/null +++ b/packages/dataviews/src/view-list.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useAsyncList } from '@wordpress/compose'; +import { + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { ENTER, SPACE } from '@wordpress/keycodes'; + +export default function ViewList( { + view, + fields, + data, + getItemId, + onSelectionChange, + selection, + deferredRendering, +} ) { + const shownData = useAsyncList( data, { step: 3 } ); + const usedData = deferredRendering ? shownData : data; + const mediaField = fields.find( + ( field ) => field.id === view.layout.mediaField + ); + const primaryField = fields.find( + ( field ) => field.id === view.layout.primaryField + ); + const visibleFields = fields.filter( + ( field ) => + ! view.hiddenFields.includes( field.id ) && + ! [ view.layout.primaryField, view.layout.mediaField ].includes( + field.id + ) + ); + + const onEnter = ( item ) => ( event ) => { + const { keyCode } = event; + if ( [ ENTER, SPACE ].includes( keyCode ) ) { + onSelectionChange( [ item ] ); + } + }; + + return ( + <ul className="dataviews-list-view"> + { usedData.map( ( item, index ) => { + return ( + <li key={ getItemId?.( item ) || index }> + <div + role="button" + tabIndex={ 0 } + aria-pressed={ selection.includes( item.id ) } + onKeyDown={ onEnter( item ) } + className={ classNames( + 'dataviews-list-view__item', + { + 'dataviews-list-view__item-selected': + selection.includes( item.id ), + } + ) } + onClick={ () => onSelectionChange( [ item ] ) } + > + <HStack spacing={ 3 }> + <div className="dataviews-list-view__media-wrapper"> + { mediaField?.render( { item } ) || ( + <div className="dataviews-list-view__media-placeholder"></div> + ) } + </div> + <HStack> + <VStack spacing={ 1 }> + { primaryField?.render( { item } ) } + <div className="dataviews-list-view__fields"> + { visibleFields.map( ( field ) => { + return ( + <span + key={ field.id } + className="dataviews-list-view__field" + > + { field.render( { + item, + } ) } + </span> + ); + } ) } + </div> + </VStack> + </HStack> + </HStack> + </div> + </li> + ); + } ) } + </ul> + ); +} diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js new file mode 100644 index 0000000000000..3f5891f076791 --- /dev/null +++ b/packages/dataviews/src/view-table.js @@ -0,0 +1,425 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useAsyncList } from '@wordpress/compose'; +import { + chevronDown, + chevronUp, + unseen, + check, + arrowUp, + arrowDown, + chevronRightSmall, + funnel, +} from '@wordpress/icons'; +import { + Button, + Icon, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; +import { Children, Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from './lock-unlock'; +import ItemActions from './item-actions'; +import { ENUMERATION_TYPE, OPERATOR_IN, OPERATOR_NOT_IN } from './constants'; + +const { + DropdownMenuV2: DropdownMenu, + DropdownMenuGroupV2: DropdownMenuGroup, + DropdownMenuItemV2: DropdownMenuItem, + DropdownMenuSeparatorV2: DropdownMenuSeparator, + DropdownSubMenuV2: DropdownSubMenu, + DropdownSubMenuTriggerV2: DropdownSubMenuTrigger, +} = unlock( componentsPrivateApis ); + +const sortingItemsInfo = { + asc: { icon: arrowUp, label: __( 'Sort ascending' ) }, + desc: { icon: arrowDown, label: __( 'Sort descending' ) }, +}; +const sortIcons = { asc: chevronUp, desc: chevronDown }; + +function HeaderMenu( { field, view, onChangeView } ) { + const isSortable = field.enableSorting !== false; + const isHidable = field.enableHiding !== false; + if ( ! isSortable && ! isHidable ) { + return field.header; + } + const isSorted = view.sort?.field === field.id; + let filter, filterInView; + const otherFilters = []; + if ( field.type === ENUMERATION_TYPE ) { + let columnOperators = field.filterBy?.operators; + if ( ! columnOperators || ! Array.isArray( columnOperators ) ) { + columnOperators = [ OPERATOR_IN, OPERATOR_NOT_IN ]; + } + const operators = columnOperators.filter( ( operator ) => + [ OPERATOR_IN, OPERATOR_NOT_IN ].includes( operator ) + ); + if ( operators.length >= 0 ) { + filter = { + field: field.id, + operators, + elements: field.elements || [], + }; + filterInView = { + field: filter.field, + operator: filter.operators[ 0 ], + value: undefined, + }; + } + } + const isFilterable = !! filter; + + if ( isFilterable ) { + const columnFilters = view.filters; + columnFilters.forEach( ( columnFilter ) => { + if ( columnFilter.field === filter.field ) { + filterInView = { + ...columnFilter, + }; + } else { + otherFilters.push( columnFilter ); + } + } ); + } + return ( + <DropdownMenu + align="start" + trigger={ + <Button + icon={ isSorted && sortIcons[ view.sort.direction ] } + iconPosition="right" + text={ field.header } + style={ { padding: 0 } } + size="compact" + /> + } + > + <WithSeparators> + { isSortable && ( + <DropdownMenuGroup> + { Object.entries( sortingItemsInfo ).map( + ( [ direction, info ] ) => { + const isActive = + isSorted && + view.sort.direction === direction; + return ( + <DropdownMenuItem + key={ direction } + role="menuitemradio" + aria-checked={ isActive } + prefix={ <Icon icon={ info.icon } /> } + suffix={ + isActive && <Icon icon={ check } /> + } + onSelect={ ( event ) => { + event.preventDefault(); + onChangeView( { + ...view, + sort: { + field: field.id, + direction, + }, + } ); + } } + > + { info.label } + </DropdownMenuItem> + ); + } + ) } + </DropdownMenuGroup> + ) } + { isHidable && ( + <DropdownMenuItem + role="menuitemradio" + aria-checked={ false } + prefix={ <Icon icon={ unseen } /> } + onSelect={ ( event ) => { + event.preventDefault(); + onChangeView( { + ...view, + hiddenFields: view.hiddenFields.concat( + field.id + ), + } ); + } } + > + { __( 'Hide' ) } + </DropdownMenuItem> + ) } + { isFilterable && ( + <DropdownMenuGroup> + <DropdownSubMenu + key={ filter.field } + trigger={ + <DropdownSubMenuTrigger + prefix={ <Icon icon={ funnel } /> } + suffix={ + <Icon icon={ chevronRightSmall } /> + } + > + { __( 'Filter by' ) } + </DropdownSubMenuTrigger> + } + > + <WithSeparators> + <DropdownMenuGroup> + { filter.elements.map( ( element ) => { + let isActive = false; + if ( filterInView ) { + // Intentionally use loose comparison, so it does type conversion. + // This covers the case where a top-level filter for the same field converts a number into a string. + /* eslint-disable eqeqeq */ + isActive = + element.value == + filterInView.value; + /* eslint-enable eqeqeq */ + } + + return ( + <DropdownMenuItem + key={ element.value } + role="menuitemradio" + aria-checked={ isActive } + prefix={ + isActive && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => { + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + filterInView?.operator, + value: isActive + ? undefined + : element.value, + }, + ], + } ); + } } + > + { element.label } + </DropdownMenuItem> + ); + } ) } + </DropdownMenuGroup> + { filter.operators.length > 1 && ( + <DropdownSubMenu + trigger={ + <DropdownSubMenuTrigger + suffix={ + <> + { filterInView.operator === + OPERATOR_IN + ? __( 'Is' ) + : __( 'Is not' ) } + <Icon + icon={ + chevronRightSmall + } + />{ ' ' } + </> + } + > + { __( 'Conditions' ) } + </DropdownSubMenuTrigger> + } + > + <DropdownMenuItem + key="in-filter" + role="menuitemradio" + aria-checked={ + filterInView?.operator === + OPERATOR_IN + } + prefix={ + filterInView?.operator === + OPERATOR_IN && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + OPERATOR_IN, + value: filterInView?.value, + }, + ], + } ) + } + > + { __( 'Is' ) } + </DropdownMenuItem> + <DropdownMenuItem + key="not-in-filter" + role="menuitemradio" + aria-checked={ + filterInView?.operator === + OPERATOR_NOT_IN + } + prefix={ + filterInView?.operator === + OPERATOR_NOT_IN && ( + <Icon icon={ check } /> + ) + } + onSelect={ () => + onChangeView( { + ...view, + filters: [ + ...otherFilters, + { + field: filter.field, + operator: + OPERATOR_NOT_IN, + value: filterInView?.value, + }, + ], + } ) + } + > + { __( 'Is not' ) } + </DropdownMenuItem> + </DropdownSubMenu> + ) } + </WithSeparators> + </DropdownSubMenu> + </DropdownMenuGroup> + ) } + </WithSeparators> + </DropdownMenu> + ); +} + +function WithSeparators( { children } ) { + return Children.toArray( children ) + .filter( Boolean ) + .map( ( child, i ) => ( + <Fragment key={ i }> + { i > 0 && <DropdownMenuSeparator /> } + { child } + </Fragment> + ) ); +} + +function ViewTable( { + view, + onChangeView, + fields, + actions, + data, + getItemId, + isLoading = false, + deferredRendering, +} ) { + const visibleFields = fields.filter( + ( field ) => + ! view.hiddenFields.includes( field.id ) && + ! [ view.layout.mediaField, view.layout.primaryField ].includes( + field.id + ) + ); + const shownData = useAsyncList( data ); + const usedData = deferredRendering ? shownData : data; + const hasData = !! usedData?.length; + if ( isLoading ) { + // TODO:Add spinner or progress bar.. + return ( + <div className="dataviews-loading"> + <h3>{ __( 'Loading' ) }</h3> + </div> + ); + } + const sortValues = { asc: 'ascending', desc: 'descending' }; + return ( + <div className="dataviews-table-view-wrapper"> + { hasData && ( + <table className="dataviews-table-view"> + <thead> + <tr> + { visibleFields.map( ( field ) => ( + <th + key={ field.id } + style={ { + width: field.width || undefined, + minWidth: field.minWidth || undefined, + maxWidth: field.maxWidth || undefined, + } } + data-field-id={ field.id } + aria-sort={ + view.sort?.field === field.id && + sortValues[ view.sort.direction ] + } + scope="col" + > + <HeaderMenu + field={ field } + view={ view } + onChangeView={ onChangeView } + /> + </th> + ) ) } + { !! actions?.length && ( + <th data-field-id="actions"> + { __( 'Actions' ) } + </th> + ) } + </tr> + </thead> + <tbody> + { usedData.map( ( item, index ) => ( + <tr key={ getItemId?.( item ) || index }> + { visibleFields.map( ( field ) => ( + <td + key={ field.id } + style={ { + width: field.width || undefined, + minWidth: + field.minWidth || undefined, + maxWidth: + field.maxWidth || undefined, + } } + > + { field.render( { + item, + } ) } + </td> + ) ) } + { !! actions?.length && ( + <td> + <ItemActions + item={ item } + actions={ actions } + /> + </td> + ) } + </tr> + ) ) } + </tbody> + </table> + ) } + { ! hasData && ( + <div className="dataviews-no-results"> + <p>{ __( 'No results' ) }</p> + </div> + ) } + </div> + ); +} + +export default ViewTable; diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index 47dd1a0b7adf4..bd328430313ce 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -4,6 +4,7 @@ const BUNDLED_PACKAGES = [ '@wordpress/interface', '@wordpress/undo-manager', '@wordpress/sync', + '@wordpress/dataviews', ]; /** diff --git a/packages/e2e-test-utils-playwright/src/editor/preview.ts b/packages/e2e-test-utils-playwright/src/editor/preview.ts index 97d3ef1d1d660..c697ca714fe96 100644 --- a/packages/e2e-test-utils-playwright/src/editor/preview.ts +++ b/packages/e2e-test-utils-playwright/src/editor/preview.ts @@ -19,9 +19,7 @@ export async function openPreviewPage( this: Editor ): Promise< Page > { const editorTopBar = this.page.locator( 'role=region[name="Editor top bar"i]' ); - const previewButton = editorTopBar.locator( - 'role=button[name="Preview"i]' - ); + const previewButton = editorTopBar.locator( 'role=button[name="View"i]' ); await previewButton.click(); diff --git a/packages/e2e-test-utils/src/disable-pre-publish-checks.js b/packages/e2e-test-utils/src/disable-pre-publish-checks.js index 2c12a0aaaa99e..25660e48c9555 100644 --- a/packages/e2e-test-utils/src/disable-pre-publish-checks.js +++ b/packages/e2e-test-utils/src/disable-pre-publish-checks.js @@ -10,7 +10,7 @@ import { toggleMoreMenu } from './toggle-more-menu'; export async function disablePrePublishChecks() { await togglePreferencesOption( 'General', - 'Include pre-publish checklist', + 'Enable pre-publish flow', false ); await toggleMoreMenu( 'close' ); diff --git a/packages/e2e-test-utils/src/enable-pre-publish-checks.js b/packages/e2e-test-utils/src/enable-pre-publish-checks.js index a9c8b572302b1..5a65ba6fc1353 100644 --- a/packages/e2e-test-utils/src/enable-pre-publish-checks.js +++ b/packages/e2e-test-utils/src/enable-pre-publish-checks.js @@ -8,10 +8,6 @@ import { toggleMoreMenu } from './toggle-more-menu'; * Enables Pre-publish checks. */ export async function enablePrePublishChecks() { - await togglePreferencesOption( - 'General', - 'Include pre-publish checklist', - true - ); + await togglePreferencesOption( 'General', 'Enable pre-publish flow', true ); await toggleMoreMenu( 'close' ); } diff --git a/packages/e2e-test-utils/src/preview.js b/packages/e2e-test-utils/src/preview.js index 1d96eda176675..24c5dc69dd0e2 100644 --- a/packages/e2e-test-utils/src/preview.js +++ b/packages/e2e-test-utils/src/preview.js @@ -10,13 +10,13 @@ export async function openPreviewPage( editorPage = page ) { let openTabs = await browser.pages(); const expectedTabsCount = openTabs.length + 1; await page.waitForSelector( - '.block-editor-post-preview__button-toggle:not([disabled])' + '.editor-preview-dropdown__toggle:not([disabled])' ); - await editorPage.click( '.block-editor-post-preview__button-toggle' ); + await editorPage.click( '.editor-preview-dropdown__toggle' ); await editorPage.waitForSelector( - '.edit-post-header-preview__button-external' + '.editor-preview-dropdown__button-external' ); - await editorPage.click( '.edit-post-header-preview__button-external' ); + await editorPage.click( '.editor-preview-dropdown__button-external' ); // Wait for the new tab to open. while ( openTabs.length < expectedTabsCount ) { diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php index 6dceffa32da8f..3fbddf623db60 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php @@ -3,6 +3,7 @@ * HTML for testing the router navigate function. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php index 0bcc14ccb266f..33c319e130fe2 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-regions/render.php @@ -3,6 +3,7 @@ * HTML for testing the hydration of router regions. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php index 57200f295c33b..06deea9e1169d 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -3,6 +3,7 @@ * HTML for testing the hydration of the serialized store. * * @package gutenberg-test-interactive-blocks + * * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable */ diff --git a/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js b/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js deleted file mode 100644 index a0a83fbf90e61..0000000000000 --- a/packages/e2e-tests/specs/editor/plugins/innerblocks-locking-all-embed.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * WordPress dependencies - */ -import { - activatePlugin, - createNewPost, - deactivatePlugin, - insertBlock, - createEmbeddingMatcher, - createJSONResponse, - setUpResponseMocking, -} from '@wordpress/e2e-test-utils'; - -const MOCK_RESPONSES = [ - { - match: createEmbeddingMatcher( 'https://twitter.com/wordpress' ), - onRequestMatch: createJSONResponse( { - url: 'https://twitter.com/wordpress', - html: '<p>Mock success response.</p>', - type: 'rich', - provider_name: 'Twitter', - provider_url: 'https://twitter.com', - version: '1.0', - } ), - }, -]; - -describe( 'Embed block inside a locked all parent', () => { - beforeAll( async () => { - await activatePlugin( 'gutenberg-test-innerblocks-locking-all-embed' ); - } ); - - beforeEach( async () => { - await setUpResponseMocking( MOCK_RESPONSES ); - await createNewPost(); - } ); - - afterAll( async () => { - await deactivatePlugin( - 'gutenberg-test-innerblocks-locking-all-embed' - ); - } ); - - it( 'embed block should be able to embed external content', async () => { - await insertBlock( 'Test Inner Blocks Locking All Embed' ); - const embedInputSelector = - '.components-placeholder__input[aria-label="Embed URL"]'; - await page.waitForSelector( embedInputSelector ); - await page.click( embedInputSelector ); - // This URL should not have a trailing slash. - await page.keyboard.type( 'https://twitter.com/wordpress' ); - await page.keyboard.press( 'Enter' ); - // The twitter block should appear correctly. - await page.waitForSelector( 'figure.wp-block-embed' ); - } ); -} ); diff --git a/packages/e2e-tests/specs/editor/various/change-detection.test.js b/packages/e2e-tests/specs/editor/various/change-detection.test.js index 62057c4cbb2bc..0eb673671222f 100644 --- a/packages/e2e-tests/specs/editor/various/change-detection.test.js +++ b/packages/e2e-tests/specs/editor/various/change-detection.test.js @@ -370,7 +370,11 @@ describe( 'Change detection', () => { it( 'consecutive edits to the same attribute should mark the post as dirty after a save', async () => { // Open the sidebar block settings. await openDocumentSettingsSidebar(); - await page.click( '.edit-post-sidebar__panel-tab[data-label="Block"]' ); + + const blockInspectorTab = await page.waitForXPath( + '//button[@role="tab"][contains(text(), "Block")]' + ); + await blockInspectorTab.click(); // Insert a paragraph. await clickBlockAppender(); diff --git a/packages/e2e-tests/specs/editor/various/editor-modes.test.js b/packages/e2e-tests/specs/editor/various/editor-modes.test.js index 81878ebf7208e..aea6536f605bb 100644 --- a/packages/e2e-tests/specs/editor/various/editor-modes.test.js +++ b/packages/e2e-tests/specs/editor/various/editor-modes.test.js @@ -102,21 +102,24 @@ describe( 'Editing modes (visual/HTML)', () => { expect( title ).toBe( 'Paragraph' ); // The Block inspector should be active. - let blockInspectorTab = await page.$( - '.edit-post-sidebar__panel-tab.is-active[data-label="Block"]' + let [ blockInspectorTab ] = await page.$x( + '//button[@role="tab"][@aria-selected="true"][contains(text(), "Block")]' ); expect( blockInspectorTab ).not.toBeNull(); await switchEditorModeTo( 'Code' ); // The Block inspector should not be active anymore. - blockInspectorTab = await page.$( - '.edit-post-sidebar__panel-tab.is-active[data-label="Block"]' + [ blockInspectorTab ] = await page.$x( + '//button[@role="tab"][@aria-selected="true"][contains(text(), "Block")]' ); - expect( blockInspectorTab ).toBeNull(); + expect( blockInspectorTab ).toBeUndefined(); // No block is selected. - await page.click( '.edit-post-sidebar__panel-tab[data-label="Block"]' ); + const inactiveBlockInspectorTab = await page.waitForXPath( + '//button[@role="tab"][contains(text(), "Block")]' + ); + inactiveBlockInspectorTab.click(); const noBlocksElement = await page.$( '.block-editor-block-inspector__no-blocks' ); diff --git a/packages/e2e-tests/specs/editor/various/preferences.test.js b/packages/e2e-tests/specs/editor/various/preferences.test.js index 98249637c7e96..54990a4004422 100644 --- a/packages/e2e-tests/specs/editor/various/preferences.test.js +++ b/packages/e2e-tests/specs/editor/various/preferences.test.js @@ -17,7 +17,7 @@ describe( 'preferences', () => { async function getActiveSidebarTabText() { try { return await page.$eval( - '.edit-post-sidebar__panel-tab.is-active', + 'div[aria-label="Editor settings"] [role="tab"][aria-selected="true"]', ( node ) => node.textContent ); } catch ( error ) { @@ -29,11 +29,15 @@ describe( 'preferences', () => { } it( 'remembers sidebar dismissal between sessions', async () => { + const blockTab = await page.waitForXPath( + `//button[@role="tab"][contains(text(), 'Block')]` + ); + // Open by default. expect( await getActiveSidebarTabText() ).toBe( 'Post' ); // Change to "Block" tab. - await page.click( '.edit-post-sidebar__panel-tab[aria-label="Block"]' ); + await blockTab.click(); expect( await getActiveSidebarTabText() ).toBe( 'Block' ); // Regression test: Reload resets to document tab. @@ -46,7 +50,7 @@ describe( 'preferences', () => { // Dismiss. await page.click( - '.edit-post-sidebar__panel-tabs [aria-label="Close Settings"]' + 'div[aria-label="Editor settings"] div[role="tablist"] + button[aria-label="Close Settings"]' ); expect( await getActiveSidebarTabText() ).toBe( null ); diff --git a/packages/e2e-tests/specs/editor/various/sidebar.test.js b/packages/e2e-tests/specs/editor/various/sidebar.test.js index 2e5d46eec2f7a..0cd39093aabb8 100644 --- a/packages/e2e-tests/specs/editor/various/sidebar.test.js +++ b/packages/e2e-tests/specs/editor/various/sidebar.test.js @@ -13,7 +13,8 @@ import { } from '@wordpress/e2e-test-utils'; const SIDEBAR_SELECTOR = '.edit-post-sidebar'; -const ACTIVE_SIDEBAR_TAB_SELECTOR = '.edit-post-sidebar__panel-tab.is-active'; +const ACTIVE_SIDEBAR_TAB_SELECTOR = + 'div[aria-label="Editor settings"] [role="tab"][aria-selected="true"]'; const ACTIVE_SIDEBAR_BUTTON_TEXT = 'Post'; describe( 'Sidebar', () => { @@ -99,22 +100,24 @@ describe( 'Sidebar', () => { // Tab lands at first (presumed selected) option "Post". await page.keyboard.press( 'Tab' ); - const isActiveDocumentTab = await page.evaluate( - () => - document.activeElement.textContent === 'Post' && - document.activeElement.classList.contains( 'is-active' ) + + // The Post tab should be focused and selected. + const [ documentInspectorTab ] = await page.$x( + '//button[@role="tab"][@aria-selected="true"][contains(text(), "Post")]' ); - expect( isActiveDocumentTab ).toBe( true ); + expect( documentInspectorTab ).toBeDefined(); + expect( documentInspectorTab ).toHaveFocus(); - // Tab into and activate "Block". - await page.keyboard.press( 'Tab' ); + // Arrow key into and activate "Block". + await page.keyboard.press( 'ArrowRight' ); await page.keyboard.press( 'Space' ); - const isActiveBlockTab = await page.evaluate( - () => - document.activeElement.textContent === 'Block' && - document.activeElement.classList.contains( 'is-active' ) + + // The Block tab should be focused and selected. + const [ blockInspectorTab ] = await page.$x( + '//button[@role="tab"][@aria-selected="true"][contains(text(), "Block")]' ); - expect( isActiveBlockTab ).toBe( true ); + expect( blockInspectorTab ).toBeDefined(); + expect( blockInspectorTab ).toHaveFocus(); } ); it( 'should be possible to programmatically remove Document Settings panels', async () => { diff --git a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js b/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js deleted file mode 100644 index fe5334d2a1276..0000000000000 --- a/packages/e2e-tests/specs/site-editor/multi-entity-saving.test.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - disablePrePublishChecks, - getOption, - insertBlock, - publishPost, - setOption, - trashAllPosts, - activateTheme, - clickButton, - createReusableBlock, - deleteAllTemplates, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'Multi-entity save flow', () => { - // Selectors - usable between Post/Site editors. - const checkedBoxSelector = '.components-checkbox-control__checked'; - const checkboxInputSelector = '.components-checkbox-control__input'; - const entitiesSaveSelector = '.editor-entities-saved-states__save-button'; - const savePanelSelector = '.entities-saved-states__panel'; - const closePanelButtonSelector = - '.editor-post-publish-panel__header-cancel-button button:not(:disabled)'; - - // Reusable assertions across Post/Site editors. - const assertAllBoxesChecked = async () => { - const checkedBoxes = await page.$$( checkedBoxSelector ); - const checkboxInputs = await page.$$( checkboxInputSelector ); - expect( checkedBoxes.length - checkboxInputs.length ).toBe( 0 ); - }; - const assertExistence = async ( selector, shouldBePresent ) => { - const element = await page.$( selector ); - if ( shouldBePresent ) { - expect( element ).not.toBeNull(); - } else { - expect( element ).toBeNull(); - } - }; - - let originalSiteTitle, originalBlogDescription; - - beforeAll( async () => { - await activateTheme( 'emptytheme' ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - await trashAllPosts( 'wp_block' ); - - // Get the current Site Title and Site Tagline, so that we can reset - // them back to the original values once the test suite has finished. - originalSiteTitle = await getOption( 'blogname' ); - originalBlogDescription = await getOption( 'blogdescription' ); - } ); - - afterAll( async () => { - await activateTheme( 'twentytwentyone' ); - - // Reset the Site Title and Site Tagline back to their original values. - await setOption( 'blogname', originalSiteTitle ); - await setOption( 'blogdescription', originalBlogDescription ); - } ); - - describe( 'Post Editor', () => { - // Selectors - Post editor specific. - const draftSavedSelector = '.editor-post-saved-state.is-saved'; - const multiSaveSelector = - '.editor-post-publish-button__button.has-changes-dot'; - const savePostSelector = '.editor-post-publish-button__button'; - const enabledSavePostSelector = `${ savePostSelector }[aria-disabled=false]`; - const publishA11ySelector = - '.edit-post-layout__toggle-publish-panel-button'; - const saveA11ySelector = - '.edit-post-layout__toggle-entities-saved-states-panel-button'; - const publishPanelSelector = '.editor-post-publish-panel'; - - // Reusable assertions inside Post editor. - const assertMultiSaveEnabled = async () => { - const multiSaveButton = - await page.waitForSelector( multiSaveSelector ); - expect( multiSaveButton ).not.toBeNull(); - }; - const assertMultiSaveDisabled = async () => { - const multiSaveButton = await page.waitForSelector( - multiSaveSelector, - { hidden: true } - ); - expect( multiSaveButton ).toBeNull(); - }; - - it( 'Save flow should work as expected.', async () => { - await createNewPost(); - // Edit the page some. - await canvas().waitForSelector( '.editor-post-title' ); - await canvas().click( '.editor-post-title' ); - await page.keyboard.type( 'Test Post...' ); - await page.keyboard.press( 'Enter' ); - - // Should not trigger multi-entity save button with only post edited. - await assertMultiSaveDisabled(); - - // Should only have publish panel a11y button active with only post edited. - await assertExistence( publishA11ySelector, true ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( publishPanelSelector, false ); - await assertExistence( savePanelSelector, false ); - - // Add a reusable block and edit it. - await createReusableBlock( 'Hi!', 'Test' ); - await canvas().waitForSelector( 'p[data-type="core/paragraph"]' ); - await canvas().click( 'p[data-type="core/paragraph"]' ); - await page.keyboard.type( 'Oh!' ); - - // Should trigger multi-entity save button once template part edited. - await assertMultiSaveEnabled(); - - // Should only have save panel a11y button active after child entities edited. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, true ); - await assertExistence( publishPanelSelector, false ); - await assertExistence( savePanelSelector, false ); - - // Opening panel has boxes checked by default. - await page.click( savePostSelector ); - await page.waitForSelector( savePanelSelector ); - await assertAllBoxesChecked(); - - // Should not show other panels (or their a11y buttons) while save panel opened. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( publishPanelSelector, false ); - - // Publish panel should open after saving. - await page.click( entitiesSaveSelector ); - await page.waitForSelector( publishPanelSelector ); - - // No other panels (or their a11y buttons) should be present with publish panel open. - await assertExistence( publishA11ySelector, false ); - await assertExistence( saveA11ySelector, false ); - await assertExistence( savePanelSelector, false ); - - // Close publish panel. - const closePanelButton = await page.waitForSelector( - closePanelButtonSelector - ); - await closePanelButton.click(); - - // Verify saving is disabled. - const draftSaved = await page.waitForSelector( draftSavedSelector ); - expect( draftSaved ).not.toBeNull(); - await assertMultiSaveDisabled(); - await assertExistence( saveA11ySelector, false ); - - await publishPost(); - // Wait for the success notice specifically for the published post. - // `publishPost()` has a similar check but it only checks for the - // existence of any snackbars. In this case, there's another "Site updated" - // notice which will be sufficient for that and thus creating a false-positive. - await page.waitForXPath( - '//*[@id="a11y-speak-polite"][contains(text(), "Post published")]' - ); - - // Unselect the blocks to avoid clicking the block toolbar. - await page.evaluate( () => { - wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); - } ); - - // Update the post. - await canvas().click( '.editor-post-title' ); - await page.keyboard.type( '...more title!' ); - - // Verify update button is enabled. - const enabledSaveButton = await page.waitForSelector( - enabledSavePostSelector - ); - expect( enabledSaveButton ).not.toBeNull(); - // Verify multi-entity saving not enabled. - await assertMultiSaveDisabled(); - await assertExistence( saveA11ySelector, false ); - - // Update reusable block again. - await canvas().click( 'p[data-type="core/paragraph"]' ); - // We need to click again due to the clickthrough overlays in reusable blocks. - await canvas().click( 'p[data-type="core/paragraph"]' ); - await page.keyboard.type( 'R!' ); - - // Multi-entity saving should be enabled. - await assertMultiSaveEnabled(); - await assertExistence( saveA11ySelector, true ); - } ); - - it( 'Site blocks should save individually', async () => { - await createNewPost(); - await disablePrePublishChecks(); - - await insertBlock( 'Site Title' ); - // Ensure title is retrieved before typing. - await page.waitForXPath( - `//a[contains(text(), "${ originalSiteTitle }")]` - ); - const editableSiteTitleSelector = - '.wp-block-site-title a[contenteditable="true"]'; - await canvas().waitForSelector( editableSiteTitleSelector ); - await canvas().focus( editableSiteTitleSelector ); - await page.keyboard.type( '...' ); - - await insertBlock( 'Site Tagline' ); - // Wait for the placeholder. - await canvas().waitForXPath( - '//span[@data-rich-text-placeholder="Write site tagline…"]' - ); - const editableSiteTagLineSelector = - '.wp-block-site-tagline[contenteditable="true"]'; - await canvas().waitForSelector( editableSiteTagLineSelector ); - await canvas().focus( editableSiteTagLineSelector ); - await page.keyboard.type( 'Just another WordPress site' ); - - await clickButton( 'Publish' ); - await page.waitForSelector( savePanelSelector ); - let checkboxInputs = await page.$$( checkboxInputSelector ); - expect( checkboxInputs ).toHaveLength( 3 ); - - await checkboxInputs[ 1 ].click(); - await page.click( entitiesSaveSelector ); - - // Wait for the snackbar notice that the post has been published. - await page.waitForSelector( '.components-snackbar' ); - - await clickButton( 'Update…' ); - await page.waitForSelector( savePanelSelector ); - - await page.waitForSelector( checkboxInputSelector ); - checkboxInputs = await page.$$( checkboxInputSelector ); - - expect( checkboxInputs ).toHaveLength( 1 ); - } ); - } ); -} ); diff --git a/packages/e2e-tests/specs/site-editor/site-editor-export.test.js b/packages/e2e-tests/specs/site-editor/site-editor-export.test.js deleted file mode 100644 index 0e560e9b7e0ad..0000000000000 --- a/packages/e2e-tests/specs/site-editor/site-editor-export.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * External dependencies - */ -import fs from 'fs'; -import path from 'path'; -import os from 'os'; - -/** - * WordPress dependencies - */ -import { - deleteAllTemplates, - activateTheme, - visitSiteEditor, - enterEditMode, - clickOnMoreMenuItem, -} from '@wordpress/e2e-test-utils'; - -async function waitForFileExists( filePath, timeout = 10000 ) { - const start = Date.now(); - while ( ! fs.existsSync( filePath ) ) { - // Puppeteer doesn't have an API for managing file downloads. - // We are using `waitForTimeout` to add delays between check of file existence. - // eslint-disable-next-line no-restricted-syntax - await page.waitForTimeout( 1000 ); - if ( Date.now() - start > timeout ) { - throw Error( 'waitForFileExists timeout' ); - } - } -} - -describe( 'Site Editor Templates Export', () => { - beforeAll( async () => { - await activateTheme( 'emptytheme' ); - await deleteAllTemplates( 'wp_template' ); - await deleteAllTemplates( 'wp_template_part' ); - } ); - - afterAll( async () => { - await activateTheme( 'twentytwentyone' ); - } ); - - beforeEach( async () => { - await visitSiteEditor(); - await enterEditMode(); - } ); - - it( 'clicking export should download emptytheme.zip file', async () => { - const directory = fs.mkdtempSync( - path.join( os.tmpdir(), 'test-edit-site-export-' ) - ); - await page._client.send( 'Page.setDownloadBehavior', { - behavior: 'allow', - downloadPath: directory, - } ); - - await clickOnMoreMenuItem( 'Export', 'site-editor' ); - const filePath = path.join( directory, 'emptytheme.zip' ); - await waitForFileExists( filePath ); - expect( fs.existsSync( filePath ) ).toBe( true ); - fs.unlinkSync( filePath ); - } ); -} ); diff --git a/packages/edit-post/src/components/device-preview/index.js b/packages/edit-post/src/components/device-preview/index.js deleted file mode 100644 index a10688d185023..0000000000000 --- a/packages/edit-post/src/components/device-preview/index.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * WordPress dependencies - */ -import { Icon, MenuGroup } from '@wordpress/components'; -import { PostPreviewButton, store as editorStore } from '@wordpress/editor'; -import { external } from '@wordpress/icons'; -import { __ } from '@wordpress/i18n'; -import { __experimentalPreviewOptions as PreviewOptions } from '@wordpress/block-editor'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../store'; - -export default function DevicePreview() { - const { - hasActiveMetaboxes, - isPostSaveable, - isViewable, - deviceType, - showIconLabels, - } = useSelect( ( select ) => { - const { getEditedPostAttribute } = select( editorStore ); - const { getPostType } = select( coreStore ); - const postType = getPostType( getEditedPostAttribute( 'type' ) ); - - return { - hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), - isPostSaveable: select( editorStore ).isEditedPostSaveable(), - isViewable: postType?.viewable ?? false, - deviceType: - select( editPostStore ).__experimentalGetPreviewDeviceType(), - showIconLabels: - select( editPostStore ).isFeatureActive( 'showIconLabels' ), - }; - }, [] ); - const { __experimentalSetPreviewDeviceType: setPreviewDeviceType } = - useDispatch( editPostStore ); - - return ( - <PreviewOptions - isEnabled={ isPostSaveable } - className="edit-post-post-preview-dropdown" - deviceType={ deviceType } - setDeviceType={ setPreviewDeviceType } - label={ __( 'Preview' ) } - showIconLabels={ showIconLabels } - > - { ( { onClose } ) => - isViewable && ( - <MenuGroup> - <div className="edit-post-header-preview__grouping-external"> - <PostPreviewButton - className="edit-post-header-preview__button-external" - role="menuitem" - forceIsAutosaveable={ hasActiveMetaboxes } - textContent={ - <> - { __( 'Preview in new tab' ) } - <Icon icon={ external } /> - </> - } - onPreview={ onClose } - /> - </div> - </MenuGroup> - ) - } - </PreviewOptions> - ); -} diff --git a/packages/edit-post/src/components/header/document-actions/index.js b/packages/edit-post/src/components/header/document-actions/index.js deleted file mode 100644 index 105b31e3122ac..0000000000000 --- a/packages/edit-post/src/components/header/document-actions/index.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, isRTL } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { BlockIcon, store as blockEditorStore } from '@wordpress/block-editor'; -import { - Button, - VisuallyHidden, - __experimentalHStack as HStack, - __experimentalText as Text, -} from '@wordpress/components'; -import { layout, chevronLeftSmall, chevronRightSmall } from '@wordpress/icons'; -import { store as commandsStore } from '@wordpress/commands'; -import { displayShortcut } from '@wordpress/keycodes'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; - -function DocumentActions() { - const { template } = useSelect( ( select ) => { - const { getEditedPostTemplate } = select( editPostStore ); - - return { - template: getEditedPostTemplate(), - }; - }, [] ); - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { setIsEditingTemplate } = useDispatch( editPostStore ); - const { open: openCommandCenter } = useDispatch( commandsStore ); - - if ( ! template ) { - return null; - } - - let templateTitle = __( 'Default' ); - if ( template?.title ) { - templateTitle = template.title; - } else if ( !! template ) { - templateTitle = template.slug; - } - - return ( - <div className="edit-post-document-actions"> - <Button - className="edit-post-document-actions__back" - onClick={ () => { - clearSelectedBlock(); - setIsEditingTemplate( false ); - } } - icon={ isRTL() ? chevronRightSmall : chevronLeftSmall } - > - { __( 'Back' ) } - </Button> - <Button - className="edit-post-document-actions__command" - onClick={ () => openCommandCenter() } - > - <HStack - className="edit-post-document-actions__title" - spacing={ 1 } - justify="center" - > - <BlockIcon icon={ layout } /> - <Text size="body" as="h1"> - <VisuallyHidden as="span"> - { __( 'Editing template: ' ) } - </VisuallyHidden> - { templateTitle } - </Text> - </HStack> - <span className="edit-post-document-actions__shortcut"> - { displayShortcut.primary( 'k' ) } - </span> - </Button> - </div> - ); -} - -export default DocumentActions; diff --git a/packages/edit-post/src/components/header/document-actions/style.scss b/packages/edit-post/src/components/header/document-actions/style.scss deleted file mode 100644 index 7eb77f9c0bd88..0000000000000 --- a/packages/edit-post/src/components/header/document-actions/style.scss +++ /dev/null @@ -1,64 +0,0 @@ -.edit-post-document-actions { - display: flex; - align-items: center; - gap: $grid-unit; - height: $button-size; - justify-content: space-between; - // Flex items will, by default, refuse to shrink below a minimum - // intrinsic width. In order to shrink this flexbox item, and - // subsequently truncate child text, we set an explicit min-width. - // See https://dev.w3.org/csswg/css-flexbox/#min-size-auto - min-width: 0; - background: $gray-100; - border-radius: 4px; - width: min(100%, 450px); - - .components-button { - &:hover { - color: var(--wp-block-synced-color); - background: $gray-200; - } - } -} - -.edit-post-document-actions__command { - flex-grow: 1; - color: var(--wp-block-synced-color); - overflow: hidden; -} - -.edit-post-document-actions__title { - flex-grow: 1; - color: var(--wp-block-synced-color); - overflow: hidden; - - &:hover { - color: var(--wp-block-synced-color); - } - - .block-editor-block-icon { - flex-shrink: 0; - } - - h1 { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--wp-block-synced-color); - } -} - -.edit-post-document-actions__shortcut { - color: $gray-800; -} - -.edit-post-document-actions__back.components-button.has-icon.has-text { - min-width: $button-size; - flex-shrink: 0; - color: $gray-700; - gap: 0; - - &:hover { - color: currentColor; - } -} diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index 9c650f8660da1..9c007da8e115f 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -105,6 +105,7 @@ function HeaderToolbar( { hasFixedToolbar, setListViewToggleElement } ) { variant={ showIconLabels ? 'tertiary' : undefined } aria-expanded={ isListViewOpen } ref={ setListViewToggleElement } + size="compact" /> </> ); @@ -159,17 +160,20 @@ function HeaderToolbar( { hasFixedToolbar, setListViewToggleElement } ) { showIconLabels ? 'tertiary' : undefined } disabled={ isTextModeEnabled } + size="compact" /> ) } <ToolbarItem as={ EditorHistoryUndo } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + size="compact" /> <ToolbarItem as={ EditorHistoryRedo } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + size="compact" /> { overflowItems } </> diff --git a/packages/edit-post/src/components/header/header-toolbar/style.scss b/packages/edit-post/src/components/header/header-toolbar/style.scss index 960199a156451..717d5cd760db5 100644 --- a/packages/edit-post/src/components/header/header-toolbar/style.scss +++ b/packages/edit-post/src/components/header/header-toolbar/style.scss @@ -39,9 +39,12 @@ // here to the original button styles .edit-post-header-toolbar__left > .components-button.has-icon, .edit-post-header-toolbar__left > .components-dropdown > .components-button.has-icon { - height: $button-size; - min-width: $button-size; - padding: 6px; + // @todo: override toolbar group inherited paddings from components/block-tools/style.scss. + // This is best fixed by making the mover control area a proper single toolbar group. + // It needs specificity due to style inherited from .components-accessible-toolbar .components-button.has-icon.has-icon. + height: $button-size-compact; + min-width: $button-size-compact; + padding: 4px; &.is-pressed { background: $gray-900; @@ -49,7 +52,7 @@ &:focus:not(:disabled) { box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color), inset 0 0 0 $border-width $white; - outline: 1px solid transparent; + outline: $border-width solid transparent; } &::before { @@ -79,14 +82,15 @@ .edit-post-header-toolbar__left { display: inline-flex; align-items: center; - padding-left: $grid-unit-10; + padding-left: $grid-unit-20; + gap: $grid-unit-10; // Some plugins add buttons here despite best practices. // Push them a bit rightwards to fit the top toolbar. margin-right: $grid-unit-10; - @include break-small() { - padding-left: $grid-unit-30; + @include break-medium() { + padding-left: $grid-unit-50 * 0.5; } @include break-wide() { @@ -95,16 +99,14 @@ } .edit-post-header-toolbar .edit-post-header-toolbar__left > .edit-post-header-toolbar__inserter-toggle.has-icon { - margin-right: $grid-unit-10; - // Special dimensions for this button. - min-width: 32px; - width: 32px; - height: 32px; + min-width: $button-size-compact; + width: $button-size-compact; + height: $button-size-compact; padding: 0; .show-icon-labels & { width: auto; - height: 36px; + height: $button-size-compact; padding: 0 $grid-unit-10; } } diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index c1c8222394979..c86b24b4b7ccf 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -7,10 +7,16 @@ import classnames from 'classnames'; * WordPress dependencies */ import { - privateApis as blockEditorPrivateApis, + BlockToolbar, store as blockEditorStore, } from '@wordpress/block-editor'; -import { PostSavedState, PostPreviewButton } from '@wordpress/editor'; +import { + PostSavedState, + PostPreviewButton, + store as editorStore, + DocumentBar, + privateApis as editorPrivateApis, +} from '@wordpress/editor'; import { useEffect, useRef, useState } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; @@ -31,14 +37,12 @@ import FullscreenModeClose from './fullscreen-mode-close'; import HeaderToolbar from './header-toolbar'; import MoreMenu from './more-menu'; import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; -import { default as DevicePreview } from '../device-preview'; import ViewLink from '../view-link'; import MainDashboardButton from './main-dashboard-button'; import { store as editPostStore } from '../../store'; -import DocumentActions from './document-actions'; import { unlock } from '../../lock-unlock'; -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); +const { PreviewDropdown } = unlock( editorPrivateApis ); const slideY = { hidden: { y: '-50px' }, @@ -73,7 +77,8 @@ function Header( { blockSelectionStart: select( blockEditorStore ).getBlockSelectionStart(), hasActiveMetaboxes: select( editPostStore ).hasMetaBoxes(), - isEditingTemplate: select( editPostStore ).isEditingTemplate(), + isEditingTemplate: + select( editorStore ).getRenderingMode() === 'template-only', isPublishSidebarOpened: select( editPostStore ).isPublishSidebarOpened(), hasFixedToolbar: getPreference( 'core/edit-post', 'fixedToolbar' ), @@ -119,11 +124,13 @@ function Header( { className={ classnames( 'selected-block-tools-wrapper', { - 'is-collapsed': isBlockToolsCollapsed, + 'is-collapsed': + isEditingTemplate && + isBlockToolsCollapsed, } ) } > - <BlockContextualToolbar isFixed /> + <BlockToolbar hideDragHandle /> </div> <Popover.Slot ref={ blockToolbarRef } @@ -150,12 +157,14 @@ function Header( { <div className={ classnames( 'edit-post-header__center', { 'is-collapsed': + isEditingTemplate && + hasBlockSelected && ! isBlockToolsCollapsed && - isLargeViewport && - isEditingTemplate, + hasFixedToolbar && + isLargeViewport, } ) } > - { isEditingTemplate && <DocumentActions /> } + { isEditingTemplate && <DocumentBar /> } </div> </motion.div> <motion.div @@ -174,8 +183,14 @@ function Header( { showIconLabels={ showIconLabels } /> ) } - <DevicePreview /> - <PostPreviewButton forceIsAutosaveable={ hasActiveMetaboxes } /> + <PreviewDropdown + showIconLabels={ showIconLabels } + forceIsAutosaveable={ hasActiveMetaboxes } + /> + <PostPreviewButton + className="edit-post-header__post-preview-button" + forceIsAutosaveable={ hasActiveMetaboxes } + /> <ViewLink /> <PostPublishButtonOrToggle forceIsDirty={ hasActiveMetaboxes } diff --git a/packages/edit-post/src/components/header/mode-switcher/index.js b/packages/edit-post/src/components/header/mode-switcher/index.js index b8d7f912180b6..34521bba19e96 100644 --- a/packages/edit-post/src/components/header/mode-switcher/index.js +++ b/packages/edit-post/src/components/header/mode-switcher/index.js @@ -44,7 +44,8 @@ function ModeSwitcher() { select( editorStore ).getEditorSettings().richEditingEnabled, isCodeEditingEnabled: select( editorStore ).getEditorSettings().codeEditingEnabled, - isEditingTemplate: select( editPostStore ).isEditingTemplate(), + isEditingTemplate: + select( editorStore ).getRenderingMode() === 'template-only', mode: select( editPostStore ).getEditorMode(), } ), [] diff --git a/packages/edit-post/src/components/header/more-menu/index.js b/packages/edit-post/src/components/header/more-menu/index.js index 6085ce65e51e5..3ac1178b8815a 100644 --- a/packages/edit-post/src/components/header/more-menu/index.js +++ b/packages/edit-post/src/components/header/more-menu/index.js @@ -26,6 +26,7 @@ const MoreMenu = ( { showIconLabels } ) => { toggleProps={ { showTooltip: ! showIconLabels, ...( showIconLabels && { variant: 'tertiary' } ), + size: 'compact', } } > { ( { onClose } ) => ( diff --git a/packages/edit-post/src/components/header/style.scss b/packages/edit-post/src/components/header/style.scss index 382364b3e3580..acd6dde411d92 100644 --- a/packages/edit-post/src/components/header/style.scss +++ b/packages/edit-post/src/components/header/style.scss @@ -52,12 +52,43 @@ } } - .block-editor-block-contextual-toolbar.is-fixed { - border: none; - } - .selected-block-tools-wrapper { overflow-x: hidden; + display: flex; + + .block-editor-block-contextual-toolbar { + border-bottom: 0; + } + + &::after { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + + // Modified group borders + .components-toolbar-group, + .components-toolbar { + border-right: none; + + &::after { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + + & .components-toolbar-group.components-toolbar-group { + &::after { + display: none; + } + } + } &.is-collapsed { display: none; @@ -93,34 +124,7 @@ padding-right: $grid-unit-20 - ($grid-unit-15 * 0.5); } - gap: $grid-unit-05; - - @include break-small() { - gap: $grid-unit-10; - } -} - -.edit-post-header-preview__grouping-external { - display: flex; - position: relative; - padding-bottom: 0; -} - -.edit-post-header-preview__button-external { - padding-left: $grid-unit-10; - - margin-right: auto; - width: 100%; - display: flex; - justify-content: flex-start; - - svg { - margin-left: auto; - } -} - -.edit-post-post-preview-dropdown .components-popover__content { - padding-bottom: 0; + gap: $grid-unit-10; } /** @@ -189,6 +193,22 @@ } } +.show-icon-labels { + + .edit-post-header__toolbar .block-editor-block-mover { + border-left: none; + + &::before { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + } +} + .edit-post-header__dropdown { .components-menu-item__button.components-menu-item__button, .components-button.editor-history__undo, @@ -231,6 +251,12 @@ } } +.edit-post-header__post-preview-button { + @include break-small { + display: none; + } +} + .is-distraction-free { .interface-interface-skeleton__header { border-bottom: none; @@ -245,13 +271,13 @@ // hide some parts - & > .edit-post-header__settings > .editor-post-preview { + & > .edit-post-header__settings > .edit-post-header__post-preview-button { visibility: hidden; } & > .edit-post-header__toolbar .edit-post-header-toolbar__inserter-toggle, & > .edit-post-header__toolbar .edit-post-header-toolbar__document-overview-toggle, - & > .edit-post-header__settings > .block-editor-post-preview__dropdown, + & > .edit-post-header__settings > .editor-preview-dropdown, & > .edit-post-header__settings > .interface-pinned-items { display: none; } diff --git a/packages/edit-post/src/components/header/writing-menu/index.js b/packages/edit-post/src/components/header/writing-menu/index.js index de6acf67c1983..26cc6bc587165 100644 --- a/packages/edit-post/src/components/header/writing-menu/index.js +++ b/packages/edit-post/src/components/header/writing-menu/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { useDispatch, useRegistry } from '@wordpress/data'; import { MenuGroup } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; import { useViewportMatch } from '@wordpress/compose'; @@ -10,7 +10,6 @@ import { PreferenceToggleMenuItem, store as preferencesStore, } from '@wordpress/preferences'; -import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -19,11 +18,6 @@ import { store as postEditorStore } from '../../../store'; function WritingMenu() { const registry = useRegistry(); - const isDistractionFree = useSelect( - ( select ) => - select( blockEditorStore ).getSettings().isDistractionFree, - [] - ); const { setIsInserterOpened, setIsListViewOpened, closeGeneralSidebar } = useDispatch( postEditorStore ); @@ -38,6 +32,10 @@ function WritingMenu() { } ); }; + const turnOffDistractionFree = () => { + setPreference( 'core/edit-post', 'distractionFree', false ); + }; + const isLargeViewport = useViewportMatch( 'medium' ); if ( ! isLargeViewport ) { return null; @@ -47,8 +45,8 @@ function WritingMenu() { <MenuGroup label={ _x( 'View', 'noun' ) }> <PreferenceToggleMenuItem scope="core/edit-post" - disabled={ isDistractionFree } name="fixedToolbar" + onToggle={ turnOffDistractionFree } label={ __( 'Top toolbar' ) } info={ __( 'Access all block and document tools in a single place' @@ -56,6 +54,16 @@ function WritingMenu() { messageActivated={ __( 'Top toolbar activated' ) } messageDeactivated={ __( 'Top toolbar deactivated' ) } /> + <PreferenceToggleMenuItem + scope="core/edit-post" + name="distractionFree" + onToggle={ toggleDistractionFree } + label={ __( 'Distraction free' ) } + info={ __( 'Write with calmness' ) } + messageActivated={ __( 'Distraction free mode activated' ) } + messageDeactivated={ __( 'Distraction free mode deactivated' ) } + shortcut={ displayShortcut.primaryShift( '\\' ) } + /> <PreferenceToggleMenuItem scope="core/edit-post" name="focusMode" @@ -73,16 +81,6 @@ function WritingMenu() { messageDeactivated={ __( 'Fullscreen mode deactivated' ) } shortcut={ displayShortcut.secondary( 'f' ) } /> - <PreferenceToggleMenuItem - scope="core/edit-post" - name="distractionFree" - onToggle={ toggleDistractionFree } - label={ __( 'Distraction free' ) } - info={ __( 'Write with calmness' ) } - messageActivated={ __( 'Distraction free mode activated' ) } - messageDeactivated={ __( 'Distraction free mode deactivated' ) } - shortcut={ displayShortcut.primaryShift( '\\' ) } - /> </MenuGroup> ); } diff --git a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap index b98bd562f0a6a..79990664a2427 100644 --- a/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap @@ -898,7 +898,7 @@ exports[`KeyboardShortcutHelpModal should match snapshot when the modal is activ class="edit-post-keyboard-shortcut-help-modal__shortcut-term" > <kbd - aria-label="Shift + Alt + 1 6" + aria-label="Shift + Alt + 1-6" class="edit-post-keyboard-shortcut-help-modal__shortcut-key-combination" > <kbd diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index 9c854dc33636d..eb67cc82783e4 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -21,6 +21,7 @@ import { useSelect, useDispatch } from '@wordpress/data'; import { useBlockCommands, BlockBreadcrumb, + BlockToolbar, privateApis as blockEditorPrivateApis, store as blockEditorStore, } from '@wordpress/block-editor'; @@ -138,7 +139,9 @@ function Layout() { const isMobileViewport = useViewportMatch( 'medium', '<' ); const isHugeViewport = useViewportMatch( 'huge', '>=' ); - const isLargeViewport = useViewportMatch( 'large' ); + const isWideViewport = useViewportMatch( 'large' ); + const isLargeViewport = useViewportMatch( 'medium' ); + const { openGeneralSidebar, closeGeneralSidebar, setIsInserterOpened } = useDispatch( editPostStore ); const { createErrorNotice } = useDispatch( noticesStore ); @@ -148,7 +151,6 @@ function Layout() { isRichEditingEnabled, sidebarIsOpened, hasActiveMetaboxes, - hasFixedToolbar, previousShortcut, nextShortcut, hasBlockSelected, @@ -157,7 +159,7 @@ function Layout() { showIconLabels, isDistractionFree, showBlockBreadcrumbs, - isTemplateMode, + showMetaBoxes, documentLabel, } = useSelect( ( select ) => { const { getEditorSettings, getPostTypeLabel } = select( editorStore ); @@ -165,9 +167,8 @@ function Layout() { const postTypeLabel = getPostTypeLabel(); return { - isTemplateMode: select( editPostStore ).isEditingTemplate(), - hasFixedToolbar: - select( editPostStore ).isFeatureActive( 'fixedToolbar' ), + showMetaBoxes: + select( editorStore ).getRenderingMode() === 'post-only', sidebarIsOpened: !! ( select( interfaceStore ).getActiveComplementaryArea( editPostStore.name @@ -218,12 +219,12 @@ function Layout() { if ( sidebarIsOpened && ! isHugeViewport ) { setIsInserterOpened( false ); } - }, [ sidebarIsOpened, isHugeViewport ] ); + }, [ isHugeViewport, setIsInserterOpened, sidebarIsOpened ] ); useEffect( () => { if ( isInserterOpened && ! isHugeViewport ) { closeGeneralSidebar(); } - }, [ isInserterOpened, isHugeViewport ] ); + }, [ closeGeneralSidebar, isInserterOpened, isHugeViewport ] ); // Local state for save panel. // Note 'truthy' callback implies an open panel. @@ -252,9 +253,8 @@ function Layout() { const className = classnames( 'edit-post-layout', 'is-mode-' + mode, { 'is-sidebar-opened': sidebarIsOpened, - 'has-fixed-toolbar': hasFixedToolbar, 'has-metaboxes': hasActiveMetaboxes, - 'is-distraction-free': isDistractionFree && isLargeViewport, + 'is-distraction-free': isDistractionFree && isWideViewport, 'is-entity-save-view-open': !! entitiesSavedStatesCallback, } ); @@ -301,7 +301,7 @@ function Layout() { <EditorKeyboardShortcuts /> <InterfaceSkeleton - isDistractionFree={ isDistractionFree && isLargeViewport } + isDistractionFree={ isDistractionFree && isWideViewport } className={ className } labels={ { ...interfaceLabels, @@ -345,10 +345,11 @@ function Layout() { { ( mode === 'text' || ! isRichEditingEnabled ) && ( <TextEditor /> ) } + { ! isLargeViewport && <BlockToolbar hideDragHandle /> } { isRichEditingEnabled && mode === 'visual' && ( <VisualEditor styles={ styles } /> ) } - { ! isDistractionFree && ! isTemplateMode && ( + { ! isDistractionFree && showMetaBoxes && ( <div className="edit-post-layout__metaboxes"> <MetaBoxes location="normal" /> <MetaBoxes location="advanced" /> diff --git a/packages/edit-post/src/components/preferences-modal/index.js b/packages/edit-post/src/components/preferences-modal/index.js index c08dda81f8e59..833a10fce13c3 100644 --- a/packages/edit-post/src/components/preferences-modal/index.js +++ b/packages/edit-post/src/components/preferences-modal/index.js @@ -76,6 +76,10 @@ export default function EditPostPreferencesModal() { closeGeneralSidebar(); }; + const turnOffDistractionFree = () => { + setPreference( 'core/edit-post', 'distractionFree', false ); + }; + const sections = useMemo( () => [ { @@ -86,49 +90,16 @@ export default function EditPostPreferencesModal() { { isLargeViewport && ( <PreferencesModalSection title={ __( 'Publishing' ) } - description={ __( - 'Change options related to publishing.' - ) } > <EnablePublishSidebarOption help={ __( 'Review settings, such as visibility and tags.' ) } - label={ __( - 'Include pre-publish checklist' - ) } + label={ __( 'Enable pre-publish flow' ) } /> </PreferencesModalSection> ) } - - <PreferencesModalSection - title={ __( 'Appearance' ) } - description={ __( - 'Customize options related to the block editor interface and editing flow.' - ) } - > - <EnableFeature - featureName="distractionFree" - onToggle={ toggleDistractionFree } - help={ __( - 'Reduce visual distractions by hiding the toolbar and other elements to focus on writing.' - ) } - label={ __( 'Distraction free' ) } - /> - <EnableFeature - featureName="focusMode" - help={ __( - 'Highlights the current block and fades other content.' - ) } - label={ __( 'Spotlight mode' ) } - /> - <EnableFeature - featureName="showIconLabels" - label={ __( 'Show button text labels' ) } - help={ __( - 'Show text instead of icons on buttons.' - ) } - /> + <PreferencesModalSection title={ __( 'Interface' ) }> <EnableFeature featureName="showListViewByDefault" help={ __( @@ -136,74 +107,20 @@ export default function EditPostPreferencesModal() { ) } label={ __( 'Always open list view' ) } /> - <EnableFeature - featureName="themeStyles" - help={ __( - 'Make the editor look like your theme.' - ) } - label={ __( 'Use theme styles' ) } - /> { showBlockBreadcrumbsOption && ( <EnableFeature featureName="showBlockBreadcrumbs" help={ __( - 'Shows block breadcrumbs at the bottom of the editor.' + 'Display the block hierarchy trail at the bottom of the editor.' ) } - label={ __( 'Display block breadcrumbs' ) } + label={ __( 'Show block breadcrumbs' ) } /> ) } </PreferencesModalSection> - </> - ), - }, - { - name: 'blocks', - tabLabel: __( 'Blocks' ), - content: ( - <> - <PreferencesModalSection - title={ __( 'Block interactions' ) } - description={ __( - 'Customize how you interact with blocks in the block library and editing canvas.' - ) } - > - <EnableFeature - featureName="mostUsedBlocks" - help={ __( - 'Places the most frequent blocks in the block library.' - ) } - label={ __( 'Show most used blocks' ) } - /> - <EnableFeature - featureName="keepCaretInsideBlock" - help={ __( - 'Aids screen readers by stopping text caret from leaving blocks.' - ) } - label={ __( - 'Contain text cursor inside block' - ) } - /> - </PreferencesModalSection> - <PreferencesModalSection - title={ __( 'Visible blocks' ) } - description={ __( - "Disable blocks that you don't want to appear in the inserter. They can always be toggled back on later." - ) } - > - <BlockManager /> - </PreferencesModalSection> - </> - ), - }, - { - name: 'panels', - tabLabel: __( 'Panels' ), - content: ( - <> <PreferencesModalSection title={ __( 'Document settings' ) } description={ __( - 'Choose what displays in the panel.' + 'Select what settings are shown in the document panel.' ) } > <EnablePluginDocumentSettingPanelOption.Slot /> @@ -242,12 +159,108 @@ export default function EditPostPreferencesModal() { /> </PageAttributesCheck> </PreferencesModalSection> - <MetaBoxesSection - title={ __( 'Additional' ) } - description={ __( - 'Add extra areas to the editor.' + <MetaBoxesSection title={ __( 'Advanced' ) } /> + </> + ), + }, + { + name: 'appearance', + tabLabel: __( 'Appearance' ), + content: ( + <PreferencesModalSection + title={ __( 'Appearance' ) } + description={ __( + 'Customize the editor interface to suit your needs.' + ) } + > + <EnableFeature + featureName="fixedToolbar" + onToggle={ turnOffDistractionFree } + help={ __( + 'Access all block and document tools in a single place.' + ) } + label={ __( 'Top toolbar' ) } + /> + <EnableFeature + featureName="distractionFree" + onToggle={ toggleDistractionFree } + help={ __( + 'Reduce visual distractions by hiding the toolbar and other elements to focus on writing.' + ) } + label={ __( 'Distraction free' ) } + /> + <EnableFeature + featureName="focusMode" + help={ __( + 'Highlights the current block and fades other content.' + ) } + label={ __( 'Spotlight mode' ) } + /> + <EnableFeature + featureName="themeStyles" + help={ __( + 'Make the editor look like your theme.' ) } + label={ __( 'Use theme styles' ) } /> + </PreferencesModalSection> + ), + }, + { + name: 'accessibility', + tabLabel: __( 'Accessibility' ), + content: ( + <> + <PreferencesModalSection + title={ __( 'Navigation' ) } + description={ __( + 'Optimize the editing experience for enhanced control.' + ) } + > + <EnableFeature + featureName="keepCaretInsideBlock" + help={ __( + 'Keeps the text cursor within the block boundaries, aiding users with screen readers by preventing unintentional cursor movement outside the block.' + ) } + label={ __( + 'Contain text cursor inside block' + ) } + /> + </PreferencesModalSection> + <PreferencesModalSection title={ __( 'Interface' ) }> + <EnableFeature + featureName="showIconLabels" + label={ __( 'Show button text labels' ) } + help={ __( + 'Show text instead of icons on buttons across the interface.' + ) } + /> + </PreferencesModalSection> + </> + ), + }, + { + name: 'blocks', + tabLabel: __( 'Blocks' ), + content: ( + <> + <PreferencesModalSection title={ __( 'Inserter' ) }> + <EnableFeature + featureName="mostUsedBlocks" + help={ __( + 'Adds a category with the most frequently used blocks in the inserter.' + ) } + label={ __( 'Show most used blocks' ) } + /> + </PreferencesModalSection> + <PreferencesModalSection + title={ __( 'Manage block visibility' ) } + description={ __( + "Disable blocks that you don't want to appear in the inserter. They can always be toggled back on later." + ) } + > + <BlockManager /> + </PreferencesModalSection> </> ), }, diff --git a/packages/edit-post/src/components/preferences-modal/test/index.js b/packages/edit-post/src/components/preferences-modal/test/index.js index a0946b478d8f2..01ac1a88fbe7d 100644 --- a/packages/edit-post/src/components/preferences-modal/test/index.js +++ b/packages/edit-post/src/components/preferences-modal/test/index.js @@ -1,13 +1,12 @@ /** * External dependencies */ -import { render, screen, within } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; /** * WordPress dependencies */ import { useSelect } from '@wordpress/data'; -import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies @@ -19,56 +18,6 @@ jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() ); jest.mock( '@wordpress/compose/src/hooks/use-viewport-match', () => jest.fn() ); describe( 'EditPostPreferencesModal', () => { - describe( 'should match snapshot when the modal is active', () => { - afterEach( () => { - useViewportMatch.mockClear(); - } ); - it( 'large viewports', async () => { - useSelect.mockImplementation( () => [ true, true, false ] ); - useViewportMatch.mockImplementation( () => true ); - render( <EditPostPreferencesModal /> ); - const tabPanel = await screen.findByRole( 'tabpanel', { - name: 'General', - } ); - - expect( - within( tabPanel ).getByLabelText( - 'Include pre-publish checklist' - ) - ).toBeInTheDocument(); - } ); - it( 'small viewports', async () => { - useSelect.mockImplementation( () => [ true, true, false ] ); - useViewportMatch.mockImplementation( () => false ); - render( <EditPostPreferencesModal /> ); - - // The tabpanel is not rendered in small viewports. - expect( - screen.queryByRole( 'tabpanel', { - name: 'General', - } ) - ).not.toBeInTheDocument(); - - const dialog = screen.getByRole( 'dialog', { - name: 'Preferences', - } ); - - // Checkbox toggle controls are not rendered in small viewports. - expect( - within( dialog ).queryByLabelText( - 'Include pre-publish checklist' - ) - ).not.toBeInTheDocument(); - - // Individual preference nav buttons are rendered in small viewports. - expect( - within( dialog ).getByRole( 'button', { - name: 'General', - } ) - ).toBeInTheDocument(); - } ); - } ); - it( 'should not render when the modal is not active', () => { useSelect.mockImplementation( () => [ false, false, false ] ); render( <EditPostPreferencesModal /> ); diff --git a/packages/edit-post/src/components/sidebar/post-status/index.js b/packages/edit-post/src/components/sidebar/post-status/index.js index 1b24de6082d16..0d14265b15f82 100644 --- a/packages/edit-post/src/components/sidebar/post-status/index.js +++ b/packages/edit-post/src/components/sidebar/post-status/index.js @@ -13,6 +13,7 @@ import { PostSwitchToDraftButton, PostSyncStatus, PostURLPanel, + PostTemplatePanel, } from '@wordpress/editor'; /** @@ -26,7 +27,6 @@ import PostFormat from '../post-format'; import PostPendingStatus from '../post-pending-status'; import PluginPostStatusInfo from '../plugin-post-status-info'; import { store as editPostStore } from '../../../store'; -import PostTemplate from '../post-template'; /** * Module Constants @@ -62,7 +62,7 @@ export default function PostStatus() { <> <PostVisibility /> <PostSchedulePanel /> - <PostTemplate /> + <PostTemplatePanel /> <PostURLPanel /> <PostSyncStatus /> <PostSticky /> diff --git a/packages/edit-post/src/components/sidebar/post-template/form.js b/packages/edit-post/src/components/sidebar/post-template/form.js deleted file mode 100644 index 14c1e6dee76c7..0000000000000 --- a/packages/edit-post/src/components/sidebar/post-template/form.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState, useMemo } from '@wordpress/element'; -import { __experimentalInspectorPopoverHeader as InspectorPopoverHeader } from '@wordpress/block-editor'; -import { __ } from '@wordpress/i18n'; -import { addTemplate } from '@wordpress/icons'; -import { Notice, SelectControl, Button } from '@wordpress/components'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../../store'; -import PostTemplateCreateModal from './create-modal'; - -export default function PostTemplateForm( { onClose } ) { - const { - isPostsPage, - availableTemplates, - fetchedTemplates, - selectedTemplateSlug, - canCreate, - canEdit, - } = useSelect( ( select ) => { - const { canUser, getEntityRecord, getEntityRecords } = - select( coreStore ); - const editorSettings = select( editorStore ).getEditorSettings(); - const siteSettings = canUser( 'read', 'settings' ) - ? getEntityRecord( 'root', 'site' ) - : undefined; - const _isPostsPage = - select( editorStore ).getCurrentPostId() === - siteSettings?.page_for_posts; - const canCreateTemplates = canUser( 'create', 'templates' ); - - return { - isPostsPage: _isPostsPage, - availableTemplates: editorSettings.availableTemplates, - fetchedTemplates: canCreateTemplates - ? getEntityRecords( 'postType', 'wp_template', { - post_type: select( editorStore ).getCurrentPostType(), - per_page: -1, - } ) - : undefined, - selectedTemplateSlug: - select( editorStore ).getEditedPostAttribute( 'template' ), - canCreate: - canCreateTemplates && - ! _isPostsPage && - editorSettings.supportsTemplateMode, - canEdit: - canCreateTemplates && - editorSettings.supportsTemplateMode && - !! select( editPostStore ).getEditedPostTemplate(), - }; - }, [] ); - - const options = useMemo( - () => - Object.entries( { - ...availableTemplates, - ...Object.fromEntries( - ( fetchedTemplates ?? [] ).map( ( { slug, title } ) => [ - slug, - title.rendered, - ] ) - ), - } ).map( ( [ slug, title ] ) => ( { value: slug, label: title } ) ), - [ availableTemplates, fetchedTemplates ] - ); - - const selectedOption = - options.find( ( option ) => option.value === selectedTemplateSlug ) ?? - options.find( ( option ) => ! option.value ); // The default option has '' value. - - const { editPost } = useDispatch( editorStore ); - const { __unstableSwitchToTemplateMode } = useDispatch( editPostStore ); - - const [ isCreateModalOpen, setIsCreateModalOpen ] = useState( false ); - - return ( - <div className="edit-post-post-template__form"> - <InspectorPopoverHeader - title={ __( 'Template' ) } - help={ __( - 'Templates define the way content is displayed when viewing your site.' - ) } - actions={ - canCreate - ? [ - { - icon: addTemplate, - label: __( 'Add template' ), - onClick: () => setIsCreateModalOpen( true ), - }, - ] - : [] - } - onClose={ onClose } - /> - { isPostsPage ? ( - <Notice - className="edit-post-post-template__notice" - status="warning" - isDismissible={ false } - > - { __( 'The posts page template cannot be changed.' ) } - </Notice> - ) : ( - <SelectControl - __nextHasNoMarginBottom - hideLabelFromVision - label={ __( 'Template' ) } - value={ selectedOption?.value ?? '' } - options={ options } - onChange={ ( slug ) => - editPost( { template: slug || '' } ) - } - /> - ) } - { canEdit && ( - <p> - <Button - variant="link" - onClick={ () => __unstableSwitchToTemplateMode() } - > - { __( 'Edit template' ) } - </Button> - </p> - ) } - { isCreateModalOpen && ( - <PostTemplateCreateModal - onClose={ () => setIsCreateModalOpen( false ) } - /> - ) } - </div> - ); -} diff --git a/packages/edit-post/src/components/sidebar/post-template/index.js b/packages/edit-post/src/components/sidebar/post-template/index.js deleted file mode 100644 index fa1579fa3a82a..0000000000000 --- a/packages/edit-post/src/components/sidebar/post-template/index.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState, useMemo } from '@wordpress/element'; -import { Dropdown, Button } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { - store as editorStore, - privateApis as editorPrivateApis, -} from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import PostTemplateForm from './form'; -import { store as editPostStore } from '../../../store'; -import { unlock } from '../../../lock-unlock'; - -const { PostPanelRow } = unlock( editorPrivateApis ); - -export default function PostTemplate() { - // Use internal state instead of a ref to make sure that the component - // re-renders when the popover's anchor updates. - const [ popoverAnchor, setPopoverAnchor ] = useState( null ); - // Memoize popoverProps to avoid returning a new object every time. - const popoverProps = useMemo( - () => ( { anchor: popoverAnchor, placement: 'bottom-end' } ), - [ popoverAnchor ] - ); - - const isVisible = useSelect( ( select ) => { - const postTypeSlug = select( editorStore ).getCurrentPostType(); - const postType = select( coreStore ).getPostType( postTypeSlug ); - if ( ! postType?.viewable ) { - return false; - } - - const settings = select( editorStore ).getEditorSettings(); - const hasTemplates = - !! settings.availableTemplates && - Object.keys( settings.availableTemplates ).length > 0; - if ( hasTemplates ) { - return true; - } - - if ( ! settings.supportsTemplateMode ) { - return false; - } - - const canCreateTemplates = - select( coreStore ).canUser( 'create', 'templates' ) ?? false; - return canCreateTemplates; - }, [] ); - - if ( ! isVisible ) { - return null; - } - - return ( - <PostPanelRow label={ __( 'Template' ) } ref={ setPopoverAnchor }> - <Dropdown - popoverProps={ popoverProps } - contentClassName="edit-post-post-template__dialog" - focusOnMount - renderToggle={ ( { isOpen, onToggle } ) => ( - <PostTemplateToggle - isOpen={ isOpen } - onClick={ onToggle } - /> - ) } - renderContent={ ( { onClose } ) => ( - <PostTemplateForm onClose={ onClose } /> - ) } - /> - </PostPanelRow> - ); -} - -function PostTemplateToggle( { isOpen, onClick } ) { - const templateTitle = useSelect( ( select ) => { - const templateSlug = - select( editorStore ).getEditedPostAttribute( 'template' ); - - const { supportsTemplateMode, availableTemplates } = - select( editorStore ).getEditorSettings(); - if ( ! supportsTemplateMode && availableTemplates[ templateSlug ] ) { - return availableTemplates[ templateSlug ]; - } - const template = - select( coreStore ).canUser( 'create', 'templates' ) && - select( editPostStore ).getEditedPostTemplate(); - return ( - template?.title || - template?.slug || - availableTemplates?.[ templateSlug ] - ); - }, [] ); - - return ( - <Button - className="edit-post-post-template__toggle" - variant="tertiary" - aria-expanded={ isOpen } - aria-label={ - templateTitle - ? sprintf( - // translators: %s: Name of the currently selected template. - __( 'Select template: %s' ), - templateTitle - ) - : __( 'Select template' ) - } - onClick={ onClick } - > - { templateTitle ?? __( 'Default template' ) } - </Button> - ); -} diff --git a/packages/edit-post/src/components/sidebar/post-template/style.scss b/packages/edit-post/src/components/sidebar/post-template/style.scss deleted file mode 100644 index 91f82d4d0f9f3..0000000000000 --- a/packages/edit-post/src/components/sidebar/post-template/style.scss +++ /dev/null @@ -1,22 +0,0 @@ -.components-button.edit-post-post-template__toggle { - display: inline-block; - width: 100%; - overflow: hidden; - text-overflow: ellipsis; -} - -.edit-post-post-template__dialog { - z-index: z-index(".edit-post-post-template__dialog"); -} - -.edit-post-post-template__form { - // sidebar width - popover padding - form margin - min-width: $sidebar-width - $grid-unit-20 - $grid-unit-20; - margin: $grid-unit-10; -} - -.edit-post-post-template__create-form { - @include break-medium() { - width: $grid-unit * 40; - } -} diff --git a/packages/edit-post/src/components/sidebar/settings-header/index.js b/packages/edit-post/src/components/sidebar/settings-header/index.js index f4f7a34db0bc3..368bd3e9e50db 100644 --- a/packages/edit-post/src/components/sidebar/settings-header/index.js +++ b/packages/edit-post/src/components/sidebar/settings-header/index.js @@ -1,92 +1,40 @@ /** * WordPress dependencies */ -import { Button } from '@wordpress/components'; -import { __, _x, sprintf } from '@wordpress/i18n'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { __, _x } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies */ -import { store as editPostStore } from '../../../store'; +import { unlock } from '../../../lock-unlock'; +import { sidebars } from '../settings-sidebar'; -const SettingsHeader = ( { sidebarName } ) => { - const { openGeneralSidebar } = useDispatch( editPostStore ); - const openDocumentSettings = () => - openGeneralSidebar( 'edit-post/document' ); - const openBlockSettings = () => openGeneralSidebar( 'edit-post/block' ); +const { Tabs } = unlock( componentsPrivateApis ); +const SettingsHeader = () => { const { documentLabel, isTemplateMode } = useSelect( ( select ) => { - const postTypeLabel = select( editorStore ).getPostTypeLabel(); + const { getPostTypeLabel, getRenderingMode } = select( editorStore ); return { // translators: Default label for the Document sidebar tab, not selected. - documentLabel: postTypeLabel || _x( 'Document', 'noun' ), - isTemplateMode: select( editPostStore ).isEditingTemplate(), + documentLabel: getPostTypeLabel() || _x( 'Document', 'noun' ), + isTemplateMode: getRenderingMode() === 'template-only', }; }, [] ); - const [ documentAriaLabel, documentActiveClass ] = - sidebarName === 'edit-post/document' - ? // translators: ARIA label for the Document sidebar tab, selected. %s: Document label. - [ sprintf( __( '%s (selected)' ), documentLabel ), 'is-active' ] - : [ documentLabel, '' ]; - - const [ blockAriaLabel, blockActiveClass ] = - sidebarName === 'edit-post/block' - ? // translators: ARIA label for the Block Settings Sidebar tab, selected. - [ __( 'Block (selected)' ), 'is-active' ] - : // translators: ARIA label for the Block Settings Sidebar tab, not selected. - [ __( 'Block' ), '' ]; - - const [ templateAriaLabel, templateActiveClass ] = - sidebarName === 'edit-post/document' - ? [ __( 'Template (selected)' ), 'is-active' ] - : [ __( 'Template' ), '' ]; - - /* Use a list so screen readers will announce how many tabs there are. */ return ( - <ul> - { ! isTemplateMode && ( - <li> - <Button - onClick={ openDocumentSettings } - className={ `edit-post-sidebar__panel-tab ${ documentActiveClass }` } - aria-label={ documentAriaLabel } - data-label={ documentLabel } - > - { documentLabel } - </Button> - </li> - ) } - { isTemplateMode && ( - <li> - <Button - onClick={ openDocumentSettings } - className={ `edit-post-sidebar__panel-tab ${ templateActiveClass }` } - aria-label={ templateAriaLabel } - data-label={ __( 'Template' ) } - > - { __( 'Template' ) } - </Button> - </li> - ) } - <li> - <Button - onClick={ openBlockSettings } - className={ `edit-post-sidebar__panel-tab ${ blockActiveClass }` } - aria-label={ blockAriaLabel } - // translators: Data label for the Block Settings Sidebar tab. - data-label={ __( 'Block' ) } - > - { - // translators: Text label for the Block Settings Sidebar tab. - __( 'Block' ) - } - </Button> - </li> - </ul> + <Tabs.TabList> + <Tabs.Tab tabId={ sidebars.document }> + { isTemplateMode ? __( 'Template' ) : documentLabel } + </Tabs.Tab> + <Tabs.Tab tabId={ sidebars.block }> + { /* translators: Text label for the Block Settings Sidebar tab. */ } + { __( 'Block' ) } + </Tabs.Tab> + </Tabs.TabList> ); }; diff --git a/packages/edit-post/src/components/sidebar/settings-header/style.scss b/packages/edit-post/src/components/sidebar/settings-header/style.scss deleted file mode 100644 index aaf7698cb6ddb..0000000000000 --- a/packages/edit-post/src/components/sidebar/settings-header/style.scss +++ /dev/null @@ -1,74 +0,0 @@ -// This tab style CSS is duplicated verbatim in -// /packages/components/src/tab-panel/style.scss -.components-button.edit-post-sidebar__panel-tab { - position: relative; - border-radius: 0; - height: $grid-unit-60; - background: transparent; - border: none; - box-shadow: none; - cursor: pointer; - padding: 3px $grid-unit-20; // Use padding to offset the is-active border, this benefits Windows High Contrast mode - margin-left: 0; - font-weight: 500; - - &:focus:not(:disabled) { - position: relative; - box-shadow: none; - outline: none; - } - - // Tab indicator - &::after { - content: ""; - position: absolute; - right: 0; - bottom: 0; - left: 0; - pointer-events: none; - - // Draw the indicator. - background: var(--wp-admin-theme-color); - height: calc(0 * var(--wp-admin-border-width-focus)); - border-radius: 0; - - // Animation - transition: all 0.1s linear; - @include reduce-motion("transition"); - } - - // Active. - &.is-active::after { - height: calc(1 * var(--wp-admin-border-width-focus)); - - // Windows high contrast mode. - outline: 2px solid transparent; - outline-offset: -1px; - } - - // Focus. - &::before { - content: ""; - position: absolute; - top: $grid-unit-15; - right: $grid-unit-15; - bottom: $grid-unit-15; - left: $grid-unit-15; - pointer-events: none; - - // Draw the indicator. - box-shadow: 0 0 0 0 transparent; - border-radius: $radius-block-ui; - - // Animation - transition: all 0.1s linear; - @include reduce-motion("transition"); - } - - &:focus-visible::before { - box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); - - // Windows high contrast mode. - outline: 2px solid transparent; - } -} diff --git a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js index 8450fec022593..9fa27c6ac2ade 100644 --- a/packages/edit-post/src/components/sidebar/settings-sidebar/index.js +++ b/packages/edit-post/src/components/sidebar/settings-sidebar/index.js @@ -5,12 +5,13 @@ import { BlockInspector, store as blockEditorStore, } from '@wordpress/block-editor'; -import { useSelect } from '@wordpress/data'; -import { Platform } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { Platform, useCallback, useContext } from '@wordpress/element'; import { isRTL, __ } from '@wordpress/i18n'; import { drawerLeft, drawerRight } from '@wordpress/icons'; import { store as interfaceStore } from '@wordpress/interface'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -28,52 +29,43 @@ import PluginDocumentSettingPanel from '../plugin-document-setting-panel'; import PluginSidebarEditPost from '../plugin-sidebar'; import TemplateSummary from '../template-summary'; import { store as editPostStore } from '../../../store'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { unlock } from '../../../lock-unlock'; + +const { Tabs } = unlock( componentsPrivateApis ); const SIDEBAR_ACTIVE_BY_DEFAULT = Platform.select( { web: true, native: false, } ); +export const sidebars = { + document: 'edit-post/document', + block: 'edit-post/block', +}; -const SettingsSidebar = () => { - const { sidebarName, keyboardShortcut, isTemplateMode } = useSelect( - ( select ) => { - // The settings sidebar is used by the edit-post/document and edit-post/block sidebars. - // sidebarName represents the sidebar that is active or that should be active when the SettingsSidebar toggle button is pressed. - // If one of the two sidebars is active the component will contain the content of that sidebar. - // When neither of the two sidebars is active we can not simply return null, because the PluginSidebarEditPost - // component, besides being used to render the sidebar, also renders the toggle button. In that case sidebarName - // should contain the sidebar that will be active when the toggle button is pressed. If a block - // is selected, that should be edit-post/block otherwise it's edit-post/document. - let sidebar = select( interfaceStore ).getActiveComplementaryArea( - editPostStore.name - ); - if ( - ! [ 'edit-post/document', 'edit-post/block' ].includes( - sidebar - ) - ) { - if ( select( blockEditorStore ).getBlockSelectionStart() ) { - sidebar = 'edit-post/block'; - } - sidebar = 'edit-post/document'; - } - const shortcut = select( - keyboardShortcutsStore - ).getShortcutRepresentation( 'core/edit-post/toggle-sidebar' ); - return { - sidebarName: sidebar, - keyboardShortcut: shortcut, - isTemplateMode: select( editPostStore ).isEditingTemplate(), - }; - }, - [] - ); +const SidebarContent = ( { + sidebarName, + keyboardShortcut, + isTemplateMode, +} ) => { + // Because `PluginSidebarEditPost` renders a `ComplementaryArea`, we + // need to forward the `Tabs` context so it can be passed through the + // underlying slot/fill. + const tabsContextValue = useContext( Tabs.Context ); return ( <PluginSidebarEditPost identifier={ sidebarName } - header={ <SettingsHeader sidebarName={ sidebarName } /> } + header={ + <Tabs.Context.Provider value={ tabsContextValue }> + <SettingsHeader /> + </Tabs.Context.Provider> + } closeLabel={ __( 'Close Settings' ) } + // This classname is added so we can apply a corrective negative + // margin to the panel. + // see https://github.com/WordPress/gutenberg/pull/55360#pullrequestreview-1737671049 + className="edit-post-sidebar__panel" headerClassName="edit-post-sidebar__panel-tabs" /* translators: button label text should, if possible, be under 16 characters. */ title={ __( 'Settings' ) } @@ -81,25 +73,96 @@ const SettingsSidebar = () => { icon={ isRTL() ? drawerLeft : drawerRight } isActiveByDefault={ SIDEBAR_ACTIVE_BY_DEFAULT } > - { ! isTemplateMode && sidebarName === 'edit-post/document' && ( - <> - <PostStatus /> - <PluginDocumentSettingPanel.Slot /> - <LastRevision /> - <PostTaxonomies /> - <FeaturedImage /> - <PostExcerpt /> - <DiscussionPanel /> - <PageAttributes /> - <MetaBoxes location="side" /> - </> - ) } - { isTemplateMode && sidebarName === 'edit-post/document' && ( - <TemplateSummary /> - ) } - { sidebarName === 'edit-post/block' && <BlockInspector /> } + <Tabs.Context.Provider value={ tabsContextValue }> + <Tabs.TabPanel tabId={ sidebars.document } focusable={ false }> + { ! isTemplateMode && ( + <> + <PostStatus /> + <PluginDocumentSettingPanel.Slot /> + <LastRevision /> + <PostTaxonomies /> + <FeaturedImage /> + <PostExcerpt /> + <DiscussionPanel /> + <PageAttributes /> + <MetaBoxes location="side" /> + </> + ) } + { isTemplateMode && <TemplateSummary /> } + </Tabs.TabPanel> + <Tabs.TabPanel tabId={ sidebars.block } focusable={ false }> + <BlockInspector /> + </Tabs.TabPanel> + </Tabs.Context.Provider> </PluginSidebarEditPost> ); }; +const SettingsSidebar = () => { + const { + sidebarName, + isSettingsSidebarActive, + keyboardShortcut, + isTemplateMode, + } = useSelect( ( select ) => { + // The settings sidebar is used by the edit-post/document and edit-post/block sidebars. + // sidebarName represents the sidebar that is active or that should be active when the SettingsSidebar toggle button is pressed. + // If one of the two sidebars is active the component will contain the content of that sidebar. + // When neither of the two sidebars is active we can not simply return null, because the PluginSidebarEditPost + // component, besides being used to render the sidebar, also renders the toggle button. In that case sidebarName + // should contain the sidebar that will be active when the toggle button is pressed. If a block + // is selected, that should be edit-post/block otherwise it's edit-post/document. + let sidebar = select( interfaceStore ).getActiveComplementaryArea( + editPostStore.name + ); + let isSettingsSidebar = true; + if ( ! [ sidebars.document, sidebars.block ].includes( sidebar ) ) { + isSettingsSidebar = false; + if ( select( blockEditorStore ).getBlockSelectionStart() ) { + sidebar = sidebars.block; + } + sidebar = sidebars.document; + } + const shortcut = select( + keyboardShortcutsStore + ).getShortcutRepresentation( 'core/edit-post/toggle-sidebar' ); + return { + sidebarName: sidebar, + isSettingsSidebarActive: isSettingsSidebar, + keyboardShortcut: shortcut, + isTemplateMode: + select( editorStore ).getRenderingMode() === 'template-only', + }; + }, [] ); + + const { openGeneralSidebar } = useDispatch( editPostStore ); + + const onTabSelect = useCallback( + ( newSelectedTabId ) => { + if ( !! newSelectedTabId ) { + openGeneralSidebar( newSelectedTabId ); + } + }, + [ openGeneralSidebar ] + ); + + return ( + <Tabs + // Due to how this component is controlled (via a value from the + // `interfaceStore`), when the sidebar closes the currently selected + // tab can't be found. This causes the component to continuously reset + // the selection to `null` in an infinite loop.Proactively setting + // the selected tab to `null` avoids that. + selectedTabId={ isSettingsSidebarActive ? sidebarName : null } + onSelect={ onTabSelect } + > + <SidebarContent + sidebarName={ sidebarName } + keyboardShortcut={ keyboardShortcut } + isTemplateMode={ isTemplateMode } + /> + </Tabs> + ); +}; + export default SettingsSidebar; diff --git a/packages/edit-post/src/components/sidebar/style.scss b/packages/edit-post/src/components/sidebar/style.scss index 7b10eaec0d224..1921c5cfd7b31 100644 --- a/packages/edit-post/src/components/sidebar/style.scss +++ b/packages/edit-post/src/components/sidebar/style.scss @@ -1,20 +1,8 @@ .components-panel__header.edit-post-sidebar__panel-tabs { - justify-content: flex-start; padding-left: 0; padding-right: $grid-unit-20; - border-top: 0; - margin-top: 0; - - ul { - display: flex; - } - li { - margin: 0; - } .components-button.has-icon { - display: none; - margin: 0 0 0 auto; padding: 0; min-width: $icon-size; height: $icon-size; @@ -24,3 +12,7 @@ } } } + +.edit-post-sidebar__panel { + margin-top: -1px; +} diff --git a/packages/edit-post/src/components/sidebar/template/style.scss b/packages/edit-post/src/components/sidebar/template/style.scss deleted file mode 100644 index 38d593f44d76d..0000000000000 --- a/packages/edit-post/src/components/sidebar/template/style.scss +++ /dev/null @@ -1,35 +0,0 @@ -.edit-post-template__modal { - .components-base-control { - @include break-medium() { - width: $grid-unit * 40; - } - } -} - -.edit-post-template__modal-actions { - margin-top: $grid-unit-15; -} - -.edit-post-template-modal__tip { - padding: $grid-unit-20 $grid-unit-30; - background: $gray-100; - border-radius: $radius-block-ui; - - @include break-medium() { - width: $grid-unit * 30; - } -} - -.edit-post-template__notice { - margin: 0 0 $grid-unit-10 0; - - .components-notice__content { - margin: 0; - } -} - -.edit-post-template__actions { - button:not(:last-child) { - margin-right: $grid-unit-10; - } -} diff --git a/packages/edit-post/src/components/start-page-options/index.js b/packages/edit-post/src/components/start-page-options/index.js index 77264d27a5e7d..0ef3e166e8ee1 100644 --- a/packages/edit-post/src/components/start-page-options/index.js +++ b/packages/edit-post/src/components/start-page-options/index.js @@ -90,11 +90,11 @@ function StartPageOptionsModal( { onClose } ) { export default function StartPageOptions() { const [ isClosed, setIsClosed ] = useState( false ); const shouldEnableModal = useSelect( ( select ) => { - const { isCleanNewPost } = select( editorStore ); - const { isEditingTemplate, isFeatureActive } = select( editPostStore ); + const { isCleanNewPost, getRenderingMode } = select( editorStore ); + const { isFeatureActive } = select( editPostStore ); return ( - ! isEditingTemplate() && + getRenderingMode() === 'post-only' && ! isFeatureActive( 'welcomeGuide' ) && isCleanNewPost() ); diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index 25dcf941970ac..fd9b4a6ff8bb6 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -6,24 +6,13 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { PostTitle, store as editorStore } from '@wordpress/editor'; import { - BlockList, - BlockTools, - store as blockEditorStore, - __unstableUseTypewriter as useTypewriter, - __unstableUseTypingObserver as useTypingObserver, - __experimentalUseResizeCanvas as useResizeCanvas, - useSettings, - __experimentalRecursionProvider as RecursionProvider, - privateApis as blockEditorPrivateApis, -} from '@wordpress/block-editor'; -import { useEffect, useRef, useMemo } from '@wordpress/element'; -import { __unstableMotion as motion } from '@wordpress/components'; + store as editorStore, + privateApis as editorPrivateApis, +} from '@wordpress/editor'; +import { useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; -import { useMergeRefs } from '@wordpress/compose'; -import { parse, store as blocksStore } from '@wordpress/blocks'; -import { store as coreStore } from '@wordpress/core-data'; +import { store as blocksStore } from '@wordpress/blocks'; /** * Internal dependencies @@ -31,398 +20,76 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as editPostStore } from '../../store'; import { unlock } from '../../lock-unlock'; -const { - LayoutStyle, - useLayoutClasses, - useLayoutStyles, - ExperimentalBlockCanvas: BlockCanvas, -} = unlock( blockEditorPrivateApis ); +const { EditorCanvas } = unlock( editorPrivateApis ); const isGutenbergPlugin = process.env.IS_GUTENBERG_PLUGIN ? true : false; -/** - * Given an array of nested blocks, find the first Post Content - * block inside it, recursing through any nesting levels, - * and return its attributes. - * - * @param {Array} blocks A list of blocks. - * - * @return {Object | undefined} The Post Content block. - */ -function getPostContentAttributes( blocks ) { - for ( let i = 0; i < blocks.length; i++ ) { - if ( blocks[ i ].name === 'core/post-content' ) { - return blocks[ i ].attributes; - } - if ( blocks[ i ].innerBlocks.length ) { - const nestedPostContent = getPostContentAttributes( - blocks[ i ].innerBlocks - ); - - if ( nestedPostContent ) { - return nestedPostContent; - } - } - } -} - -function checkForPostContentAtRootLevel( blocks ) { - for ( let i = 0; i < blocks.length; i++ ) { - if ( blocks[ i ].name === 'core/post-content' ) { - return true; - } - } - return false; -} - export default function VisualEditor( { styles } ) { const { - deviceType, isWelcomeGuideVisible, - isTemplateMode, - postContentAttributes, - editedPostTemplate = {}, - wrapperBlockName, - wrapperUniqueId, + renderingMode, isBlockBasedTheme, hasV3BlocksOnly, } = useSelect( ( select ) => { - const { - isFeatureActive, - isEditingTemplate, - getEditedPostTemplate, - __experimentalGetPreviewDeviceType, - } = select( editPostStore ); - const { getCurrentPostId, getCurrentPostType, getEditorSettings } = - select( editorStore ); + const { isFeatureActive } = select( editPostStore ); + const { getEditorSettings, getRenderingMode } = select( editorStore ); const { getBlockTypes } = select( blocksStore ); - const _isTemplateMode = isEditingTemplate(); - const postTypeSlug = getCurrentPostType(); - let _wrapperBlockName; - - if ( postTypeSlug === 'wp_block' ) { - _wrapperBlockName = 'core/block'; - } else if ( ! _isTemplateMode ) { - _wrapperBlockName = 'core/post-content'; - } - const editorSettings = getEditorSettings(); - const supportsTemplateMode = editorSettings.supportsTemplateMode; - const postType = select( coreStore ).getPostType( postTypeSlug ); - const canEditTemplate = select( coreStore ).canUser( - 'create', - 'templates' - ); return { - deviceType: __experimentalGetPreviewDeviceType(), isWelcomeGuideVisible: isFeatureActive( 'welcomeGuide' ), - isTemplateMode: _isTemplateMode, - postContentAttributes: getEditorSettings().postContentAttributes, - // Post template fetch returns a 404 on classic themes, which - // messes with e2e tests, so check it's a block theme first. - editedPostTemplate: - postType?.viewable && supportsTemplateMode && canEditTemplate - ? getEditedPostTemplate() - : undefined, - wrapperBlockName: _wrapperBlockName, - wrapperUniqueId: getCurrentPostId(), + renderingMode: getRenderingMode(), isBlockBasedTheme: editorSettings.__unstableIsBlockBasedTheme, hasV3BlocksOnly: getBlockTypes().every( ( type ) => { return type.apiVersion >= 3; } ), }; }, [] ); - const { isCleanNewPost } = useSelect( editorStore ); const hasMetaBoxes = useSelect( ( select ) => select( editPostStore ).hasMetaBoxes(), [] ); - const { - hasRootPaddingAwareAlignments, - isFocusMode, - themeHasDisabledLayoutStyles, - themeSupportsLayout, - } = useSelect( ( select ) => { - const _settings = select( blockEditorStore ).getSettings(); - return { - themeHasDisabledLayoutStyles: _settings.disableLayoutStyles, - themeSupportsLayout: _settings.supportsLayout, - isFocusMode: _settings.focusMode, - hasRootPaddingAwareAlignments: - _settings.__experimentalFeatures?.useRootPaddingAwareAlignments, - }; - }, [] ); - const desktopCanvasStyles = { - height: '100%', - width: '100%', - marginLeft: 'auto', - marginRight: 'auto', - display: 'flex', - flexFlow: 'column', - // Default background color so that grey - // .edit-post-editor-regions__content color doesn't show through. - background: 'white', - }; - const templateModeStyles = { - ...desktopCanvasStyles, - borderRadius: '2px 2px 0 0', - border: '1px solid #ddd', - borderBottom: 0, - }; - const resizedCanvasStyles = useResizeCanvas( deviceType, isTemplateMode ); - const [ globalLayoutSettings ] = useSettings( 'layout' ); - const previewMode = 'is-' + deviceType.toLowerCase() + '-preview'; - - let animatedStyles = isTemplateMode - ? templateModeStyles - : desktopCanvasStyles; - if ( resizedCanvasStyles ) { - animatedStyles = resizedCanvasStyles; - } let paddingBottom; // Add a constant padding for the typewritter effect. When typing at the // bottom, there needs to be room to scroll up. - if ( ! hasMetaBoxes && ! resizedCanvasStyles && ! isTemplateMode ) { + if ( ! hasMetaBoxes && renderingMode === 'post-only' ) { paddingBottom = '40vh'; } - const ref = useRef(); - const contentRef = useMergeRefs( [ ref, useTypewriter() ] ); - - // fallbackLayout is used if there is no Post Content, - // and for Post Title. - const fallbackLayout = useMemo( () => { - if ( isTemplateMode ) { - return { type: 'default' }; - } - - if ( themeSupportsLayout ) { - // We need to ensure support for wide and full alignments, - // so we add the constrained type. - return { ...globalLayoutSettings, type: 'constrained' }; - } - // Set default layout for classic themes so all alignments are supported. - return { type: 'default' }; - }, [ isTemplateMode, themeSupportsLayout, globalLayoutSettings ] ); - - const newestPostContentAttributes = useMemo( () => { - if ( ! editedPostTemplate?.content && ! editedPostTemplate?.blocks ) { - return postContentAttributes; - } - // When in template editing mode, we can access the blocks directly. - if ( editedPostTemplate?.blocks ) { - return getPostContentAttributes( editedPostTemplate?.blocks ); - } - // If there are no blocks, we have to parse the content string. - // Best double-check it's a string otherwise the parse function gets unhappy. - const parseableContent = - typeof editedPostTemplate?.content === 'string' - ? editedPostTemplate?.content - : ''; - - return getPostContentAttributes( parse( parseableContent ) ) || {}; - }, [ - editedPostTemplate?.content, - editedPostTemplate?.blocks, - postContentAttributes, - ] ); - - const hasPostContentAtRootLevel = useMemo( () => { - if ( ! editedPostTemplate?.content && ! editedPostTemplate?.blocks ) { - return false; - } - // When in template editing mode, we can access the blocks directly. - if ( editedPostTemplate?.blocks ) { - return checkForPostContentAtRootLevel( editedPostTemplate?.blocks ); - } - // If there are no blocks, we have to parse the content string. - // Best double-check it's a string otherwise the parse function gets unhappy. - const parseableContent = - typeof editedPostTemplate?.content === 'string' - ? editedPostTemplate?.content - : ''; - - return ( - checkForPostContentAtRootLevel( parse( parseableContent ) ) || false - ); - }, [ editedPostTemplate?.content, editedPostTemplate?.blocks ] ); - - const { layout = {}, align = '' } = newestPostContentAttributes || {}; - - const postContentLayoutClasses = useLayoutClasses( - newestPostContentAttributes, - 'core/post-content' - ); - - const blockListLayoutClass = classnames( - { - 'is-layout-flow': ! themeSupportsLayout, - }, - themeSupportsLayout && postContentLayoutClasses, - align && `align${ align }` - ); - - const postContentLayoutStyles = useLayoutStyles( - newestPostContentAttributes, - 'core/post-content', - '.block-editor-block-list__layout.is-root-container' - ); - - // Update type for blocks using legacy layouts. - const postContentLayout = useMemo( () => { - return layout && - ( layout?.type === 'constrained' || - layout?.inherit || - layout?.contentSize || - layout?.wideSize ) - ? { ...globalLayoutSettings, ...layout, type: 'constrained' } - : { ...globalLayoutSettings, ...layout, type: 'default' }; - }, [ - layout?.type, - layout?.inherit, - layout?.contentSize, - layout?.wideSize, - globalLayoutSettings, - ] ); - - // If there is a Post Content block we use its layout for the block list; - // if not, this must be a classic theme, in which case we use the fallback layout. - const blockListLayout = postContentAttributes - ? postContentLayout - : fallbackLayout; - - const postEditorLayout = - blockListLayout?.type === 'default' && ! hasPostContentAtRootLevel - ? fallbackLayout - : blockListLayout; - - const observeTypingRef = useTypingObserver(); - const titleRef = useRef(); - useEffect( () => { - if ( isWelcomeGuideVisible || ! isCleanNewPost() ) { - return; - } - titleRef?.current?.focus(); - }, [ isWelcomeGuideVisible, isCleanNewPost ] ); - styles = useMemo( () => [ ...styles, { // We should move this in to future to the body. - css: - `.edit-post-visual-editor__post-title-wrapper{margin-top:4rem}` + - ( paddingBottom - ? `body{padding-bottom:${ paddingBottom }}` - : '' ), + css: paddingBottom + ? `body{padding-bottom:${ paddingBottom }}` + : '', }, ], - [ styles ] + [ styles, paddingBottom ] ); - // Add some styles for alignwide/alignfull Post Content and its children. - const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--global--wide-size); margin-left: auto; margin-right: auto;} - .is-root-container.alignwide:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: var(--wp--style--global--wide-size);} - .is-root-container.alignfull { max-width: none; margin-left: auto; margin-right: auto;} - .is-root-container.alignfull:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: none;}`; - const isToBeIframed = ( ( hasV3BlocksOnly || ( isGutenbergPlugin && isBlockBasedTheme ) ) && ! hasMetaBoxes ) || - isTemplateMode || - deviceType === 'Tablet' || - deviceType === 'Mobile'; + renderingMode === 'template-only'; return ( - <BlockTools - __unstableContentRef={ ref } + <div className={ classnames( 'edit-post-visual-editor', { - 'is-template-mode': isTemplateMode, + 'is-template-mode': renderingMode === 'template-only', 'has-inline-canvas': ! isToBeIframed, } ) } > - <motion.div - className="edit-post-visual-editor__content-area" - animate={ { - padding: isTemplateMode ? '48px 48px 0' : 0, - } } - > - <motion.div - animate={ animatedStyles } - initial={ desktopCanvasStyles } - className={ previewMode } - > - <BlockCanvas - shouldIframe={ isToBeIframed } - contentRef={ contentRef } - styles={ styles } - height="100%" - > - { themeSupportsLayout && - ! themeHasDisabledLayoutStyles && - ! isTemplateMode && ( - <> - <LayoutStyle - selector=".edit-post-visual-editor__post-title-wrapper" - layout={ fallbackLayout } - /> - <LayoutStyle - selector=".block-editor-block-list__layout.is-root-container" - layout={ postEditorLayout } - /> - { align && ( - <LayoutStyle css={ alignCSS } /> - ) } - { postContentLayoutStyles && ( - <LayoutStyle - layout={ postContentLayout } - css={ postContentLayoutStyles } - /> - ) } - </> - ) } - { ! isTemplateMode && ( - <div - className={ classnames( - 'edit-post-visual-editor__post-title-wrapper', - { - 'is-focus-mode': isFocusMode, - 'has-global-padding': - hasRootPaddingAwareAlignments, - } - ) } - contentEditable={ false } - ref={ observeTypingRef } - > - <PostTitle ref={ titleRef } /> - </div> - ) } - <RecursionProvider - blockName={ wrapperBlockName } - uniqueId={ wrapperUniqueId } - > - <BlockList - className={ - isTemplateMode - ? 'wp-site-blocks' - : `${ blockListLayoutClass } wp-block-post-content` // Ensure root level blocks receive default/flow blockGap styling rules. - } - layout={ blockListLayout } - dropZoneElement={ - // When iframed, pass in the html element of the iframe to - // ensure the drop zone extends to the edges of the iframe. - isToBeIframed - ? ref.current?.parentNode - : ref.current - } - /> - </RecursionProvider> - </BlockCanvas> - </motion.div> - </motion.div> - </BlockTools> + <EditorCanvas + disableIframe={ ! isToBeIframed } + styles={ styles } + // We should auto-focus the canvas (title) on load. + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={ ! isWelcomeGuideVisible } + /> + </div> ); } diff --git a/packages/edit-post/src/components/visual-editor/style.scss b/packages/edit-post/src/components/visual-editor/style.scss index 237bbf25f2c79..46838c97f8799 100644 --- a/packages/edit-post/src/components/visual-editor/style.scss +++ b/packages/edit-post/src/components/visual-editor/style.scss @@ -38,21 +38,6 @@ // See also https://www.w3.org/TR/CSS22/visudet.html#the-height-property. } -// Ideally this wrapper div is not needed but if we want to match the positioning of blocks -// .block-editor-block-list__layout and block-editor-block-list__block -// We need to have two DOM elements. -.edit-post-visual-editor__post-title-wrapper { - .editor-post-title { - // Center. - margin-left: auto; - margin-right: auto; - } - - // Add extra margin at the top, to push down the Title area in the post editor. - margin-top: 4rem; - margin-bottom: var(--wp--style--block-gap); -} - .edit-post-visual-editor__content-area { width: 100%; height: 100%; diff --git a/packages/edit-post/src/components/welcome-guide/index.js b/packages/edit-post/src/components/welcome-guide/index.js index 3be635c81e316..9543fde137308 100644 --- a/packages/edit-post/src/components/welcome-guide/index.js +++ b/packages/edit-post/src/components/welcome-guide/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -12,8 +13,9 @@ import { store as editPostStore } from '../../store'; export default function WelcomeGuide() { const { isActive, isTemplateMode } = useSelect( ( select ) => { - const { isFeatureActive, isEditingTemplate } = select( editPostStore ); - const _isTemplateMode = isEditingTemplate(); + const { isFeatureActive } = select( editPostStore ); + const { getRenderingMode } = select( editorStore ); + const _isTemplateMode = getRenderingMode() === 'template-only'; const feature = _isTemplateMode ? 'welcomeGuideTemplate' : 'welcomeGuide'; diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index d1ef111e7dc4c..0abf3328635a8 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -14,6 +14,7 @@ import { SlotFillProvider } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { store as preferencesStore } from '@wordpress/preferences'; import { CommandMenu } from '@wordpress/commands'; +import { useViewportMatch } from '@wordpress/compose'; /** * Internal dependencies @@ -26,6 +27,8 @@ import { unlock } from './lock-unlock'; const { ExperimentalEditorProvider } = unlock( editorPrivateApis ); function Editor( { postId, postType, settings, initialEdits, ...props } ) { + const isLargeViewport = useViewportMatch( 'medium' ); + const { hasFixedToolbar, focusMode, @@ -36,13 +39,11 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { hiddenBlockTypes, blockTypes, keepCaretInsideBlock, - isTemplateMode, template, } = useSelect( ( select ) => { const { isFeatureActive, - isEditingTemplate, getEditedPostTemplate, getHiddenBlockTypes, } = select( editPostStore ); @@ -68,9 +69,9 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { getEditorSettings().supportsTemplateMode; const isViewable = getPostType( postType )?.viewable ?? false; const canEditTemplate = canUser( 'create', 'templates' ); - return { - hasFixedToolbar: isFeatureActive( 'fixedToolbar' ), + hasFixedToolbar: + isFeatureActive( 'fixedToolbar' ) || ! isLargeViewport, focusMode: isFeatureActive( 'focusMode' ), isDistractionFree: isFeatureActive( 'distractionFree' ), hasInlineToolbar: isFeatureActive( 'inlineToolbar' ), @@ -81,7 +82,6 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { hiddenBlockTypes: getHiddenBlockTypes(), blockTypes: getBlockTypes(), keepCaretInsideBlock: isFeatureActive( 'keepCaretInsideBlock' ), - isTemplateMode: isEditingTemplate(), template: supportsTemplateMode && isViewable && canEditTemplate ? getEditedPostTemplate() @@ -89,7 +89,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { post: postObject, }; }, - [ postType, postId ] + [ postType, postId, isLargeViewport ] ); const { updatePreferredStyleVariations, setIsInserterOpened } = @@ -156,7 +156,7 @@ function Editor( { postId, postType, settings, initialEdits, ...props } ) { post={ post } initialEdits={ initialEdits } useSubRegistry={ false } - __unstableTemplate={ isTemplateMode ? template : undefined } + __unstableTemplate={ template } { ...props } > <ErrorBoundary> diff --git a/packages/edit-post/src/editor.native.js b/packages/edit-post/src/editor.native.js index b031601186c72..17e2ad1780f1d 100644 --- a/packages/edit-post/src/editor.native.js +++ b/packages/edit-post/src/editor.native.js @@ -192,18 +192,12 @@ class Editor extends Component { export default compose( [ withSelect( ( select ) => { - const { - isFeatureActive, - getEditorMode, - __experimentalGetPreviewDeviceType, - getHiddenBlockTypes, - } = select( editPostStore ); + const { isFeatureActive, getEditorMode, getHiddenBlockTypes } = + select( editPostStore ); const { getBlockTypes } = select( blocksStore ); return { - hasFixedToolbar: - isFeatureActive( 'fixedToolbar' ) || - __experimentalGetPreviewDeviceType() !== 'Desktop', + hasFixedToolbar: isFeatureActive( 'fixedToolbar' ), focusMode: isFeatureActive( 'focusMode' ), mode: getEditorMode(), hiddenBlockTypes: getHiddenBlockTypes(), diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index fe967eeeed337..64ddf1d9fb08b 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -15,6 +15,7 @@ import { registerLegacyWidgetBlock, registerWidgetGroupBlock, } from '@wordpress/widgets'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -93,7 +94,7 @@ export function initializeEditor( 'removeTemplatePartsFromInserter', ( canInsert, blockType ) => { if ( - ! select( editPostStore ).isEditingTemplate() && + select( editorStore ).getRenderingMode() === 'post-only' && blockType.name === 'core/template-part' ) { return false; @@ -118,7 +119,7 @@ export function initializeEditor( { getBlockParentsByBlockName } ) => { if ( - ! select( editPostStore ).isEditingTemplate() && + select( editorStore ).getRenderingMode() === 'post-only' && blockType.name === 'core/post-content' ) { return ( diff --git a/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js b/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js index 24eec1114a0a7..e43f7910b5ffc 100644 --- a/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js +++ b/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js @@ -4,15 +4,12 @@ import { useSelect } from '@wordpress/data'; import { PreferenceToggleMenuItem } from '@wordpress/preferences'; import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { store as editPostStore } from '../../store'; +import { store as editorStore } from '@wordpress/editor'; export default function WelcomeGuideMenuItem() { const isTemplateMode = useSelect( - ( select ) => select( editPostStore ).isEditingTemplate(), + ( select ) => + select( editorStore ).getRenderingMode() === 'template-only', [] ); diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js index 27e45ab0edf9d..eae1030fad024 100644 --- a/packages/edit-post/src/store/actions.js +++ b/packages/edit-post/src/store/actions.js @@ -7,7 +7,6 @@ import { store as interfaceStore } from '@wordpress/interface'; import { store as preferencesStore } from '@wordpress/preferences'; import { speak } from '@wordpress/a11y'; import { store as noticesStore } from '@wordpress/notices'; -import { store as coreStore } from '@wordpress/core-data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as editorStore } from '@wordpress/editor'; import deprecated from '@wordpress/deprecated'; @@ -457,18 +456,27 @@ export function metaBoxUpdatesFailure() { } /** - * Returns an action object used to toggle the width of the editing canvas. + * Action that changes the width of the editing canvas. + * + * @deprecated * * @param {string} deviceType * * @return {Object} Action object. */ -export function __experimentalSetPreviewDeviceType( deviceType ) { - return { - type: 'SET_PREVIEW_DEVICE_TYPE', - deviceType, +export const __experimentalSetPreviewDeviceType = + ( deviceType ) => + ( { registry } ) => { + deprecated( + "dispatch( 'core/edit-post' ).__experimentalSetPreviewDeviceType", + { + since: '6.5', + version: '6.7', + hint: 'registry.dispatch( editorStore ).setDeviceType', + } + ); + registry.dispatch( editorStore ).setDeviceType( deviceType ); }; -} /** * Returns an action object used to open/close the inserter. @@ -513,58 +521,36 @@ export const setIsListViewOpened = /** * Returns an action object used to switch to template editing. * - * @param {boolean} value Is editing template. - * @return {Object} Action object. + * @deprecated */ -export function setIsEditingTemplate( value ) { - return { - type: 'SET_IS_EDITING_TEMPLATE', - value, - }; +export function setIsEditingTemplate() { + deprecated( "dispatch( 'core/edit-post' ).setIsEditingTemplate", { + since: '6.5', + alternative: "dispatch( 'core/editor').setRenderingMode", + } ); + return { type: 'NOTHING' }; } /** * Switches to the template mode. - * - * @param {boolean} newTemplate Is new template. */ export const __unstableSwitchToTemplateMode = - ( newTemplate = false ) => - ( { registry, select, dispatch } ) => { - dispatch( setIsEditingTemplate( true ) ); - const isWelcomeGuideActive = select.isFeatureActive( - 'welcomeGuideTemplate' - ); - if ( ! isWelcomeGuideActive ) { - const message = newTemplate - ? __( "Custom template created. You're in template mode now." ) - : __( - 'Editing template. Changes made here affect all posts and pages that use the template.' - ); - registry.dispatch( noticesStore ).createSuccessNotice( message, { - type: 'snackbar', - } ); - } + () => + ( { registry } ) => { + registry.dispatch( editorStore ).setRenderingMode( 'template-only' ); }; /** * Create a block based template. * - * @param {Object?} template Template to create and assign. + * @deprecated */ -export const __unstableCreateTemplate = - ( template ) => - async ( { registry } ) => { - const savedTemplate = await registry - .dispatch( coreStore ) - .saveEntityRecord( 'postType', 'wp_template', template ); - const post = registry.select( editorStore ).getCurrentPost(); - registry - .dispatch( coreStore ) - .editEntityRecord( 'postType', post.type, post.id, { - template: savedTemplate.slug, - } ); - }; +export function __unstableCreateTemplate() { + deprecated( "dispatch( 'core/edit-post' ).__unstableCreateTemplate", { + since: '6.5', + } ); + return { type: 'NOTHING' }; +} let metaBoxesInitialized = false; diff --git a/packages/edit-post/src/store/reducer.js b/packages/edit-post/src/store/reducer.js index 622b2e2667f7f..1072919d388db 100644 --- a/packages/edit-post/src/store/reducer.js +++ b/packages/edit-post/src/store/reducer.js @@ -98,23 +98,6 @@ export function metaBoxLocations( state = {}, action ) { return state; } -/** - * Reducer returning the editing canvas device type. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function deviceType( state = 'Desktop', action ) { - switch ( action.type ) { - case 'SET_PREVIEW_DEVICE_TYPE': - return action.deviceType; - } - - return state; -} - /** * Reducer to set the block inserter panel open or closed. * @@ -153,20 +136,6 @@ export function listViewPanel( state = false, action ) { return state; } -/** - * Reducer tracking whether template editing is on or off. - * - * @param {boolean} state - * @param {Object} action - */ -function isEditingTemplate( state = false, action ) { - switch ( action.type ) { - case 'SET_IS_EDITING_TEMPLATE': - return action.value; - } - return state; -} - /** * Reducer tracking whether meta boxes are initialized. * @@ -193,8 +162,6 @@ export default combineReducers( { metaBoxes, publishSidebarActive, removedPanels, - deviceType, blockInserterPanel, listViewPanel, - isEditingTemplate, } ); diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index 570c03d930a7e..115dcd9bcd78e 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -450,13 +450,25 @@ export function isSavingMetaBoxes( state ) { /** * Returns the current editing canvas device type. * + * @deprecated + * * @param {Object} state Global application state. * * @return {string} Device type. */ -export function __experimentalGetPreviewDeviceType( state ) { - return state.deviceType; -} +export const __experimentalGetPreviewDeviceType = createRegistrySelector( + ( select ) => () => { + deprecated( + `select( 'core/edit-site' ).__experimentalGetPreviewDeviceType`, + { + since: '6.5', + version: '6.7', + alternative: `select( 'core/editor' ).getDeviceType`, + } + ); + return select( editorStore ).getDeviceType(); + } +); /** * Returns true if the inserter is opened. @@ -498,13 +510,15 @@ export function isListViewOpened( state ) { /** * Returns true if the template editing mode is enabled. * - * @param {Object} state Global application state. - * - * @return {boolean} Whether we're editing the template. + * @deprecated */ -export function isEditingTemplate( state ) { - return state.isEditingTemplate; -} +export const isEditingTemplate = createRegistrySelector( ( select ) => () => { + deprecated( `select( 'core/edit-post' ).isEditingTemplate`, { + since: '6.5', + alternative: `select( 'core/editor' ).getRenderingMode`, + } ); + return select( editorStore ).getRenderingMode() !== 'post-only'; +} ); /** * Returns true if meta boxes are initialized. diff --git a/packages/edit-post/src/store/test/actions.js b/packages/edit-post/src/store/test/actions.js index 76f527935cdf3..39b889f2fdcbc 100644 --- a/packages/edit-post/src/store/test/actions.js +++ b/packages/edit-post/src/store/test/actions.js @@ -146,34 +146,6 @@ describe( 'actions', () => { ).toBe( true ); } ); - describe( '__unstableSwitchToTemplateMode', () => { - it( 'welcome guide is active', () => { - // Activate `welcomeGuideTemplate` feature. - registry - .dispatch( editPostStore ) - .toggleFeature( 'welcomeGuideTemplate' ); - registry.dispatch( editPostStore ).__unstableSwitchToTemplateMode(); - expect( - registry.select( editPostStore ).isEditingTemplate() - ).toBeTruthy(); - const notices = registry.select( noticesStore ).getNotices(); - expect( notices ).toHaveLength( 0 ); - } ); - - it( 'welcome guide is inactive', () => { - expect( - registry.select( editPostStore ).isEditingTemplate() - ).toBeFalsy(); - registry.dispatch( editPostStore ).__unstableSwitchToTemplateMode(); - expect( - registry.select( editPostStore ).isEditingTemplate() - ).toBeTruthy(); - const notices = registry.select( noticesStore ).getNotices(); - expect( notices ).toHaveLength( 1 ); - expect( notices[ 0 ].content ).toMatch( 'template' ); - } ); - } ); - describe( 'hideBlockTypes', () => { it( 'adds the hidden block type to the preferences', () => { registry diff --git a/packages/edit-post/src/style.scss b/packages/edit-post/src/style.scss index e015d084afae1..88916bf70f76d 100644 --- a/packages/edit-post/src/style.scss +++ b/packages/edit-post/src/style.scss @@ -2,7 +2,6 @@ @import "./components/header/style.scss"; @import "./components/header/fullscreen-mode-close/style.scss"; @import "./components/header/header-toolbar/style.scss"; -@import "./components/header/document-actions/style.scss"; @import "./components/keyboard-shortcut-help-modal/style.scss"; @import "./components/layout/style.scss"; @import "./components/block-manager/style.scss"; @@ -12,9 +11,7 @@ @import "./components/sidebar/last-revision/style.scss"; @import "./components/sidebar/post-format/style.scss"; @import "./components/sidebar/post-slug/style.scss"; -@import "./components/sidebar/post-template/style.scss"; @import "./components/sidebar/post-visibility/style.scss"; -@import "./components/sidebar/settings-header/style.scss"; @import "./components/sidebar/template-summary/style.scss"; @import "./components/text-editor/style.scss"; @import "./components/visual-editor/style.scss"; diff --git a/packages/edit-post/src/test/__snapshots__/editor.native.js.snap b/packages/edit-post/src/test/__snapshots__/editor.native.js.snap new file mode 100644 index 0000000000000..8b820cd38f11b --- /dev/null +++ b/packages/edit-post/src/test/__snapshots__/editor.native.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor appends media correctly for allowed types 1`] = ` +"<!-- wp:image --> +<figure class="wp-block-image"><img src="https://test-site.files.wordpress.com/local-image-1.jpeg" alt=""/></figure> +<!-- /wp:image --> + +<!-- wp:image --> +<figure class="wp-block-image"><img src="https://test-site.files.wordpress.com/local-image-3.jpeg" alt=""/></figure> +<!-- /wp:image -->" +`; + +exports[`Editor appends media correctly for allowed types and skips unsupported ones 1`] = ` +"<!-- wp:image --> +<figure class="wp-block-image"><img src="https://test-site.files.wordpress.com/local-image-1.jpeg" alt=""/></figure> +<!-- /wp:image --> + +<!-- wp:video --> +<figure class="wp-block-video"><video controls src="file:///local-video-4.mp4"></video></figure> +<!-- /wp:video -->" +`; diff --git a/packages/edit-post/src/test/editor.native.js b/packages/edit-post/src/test/editor.native.js index 4911fb5128655..7faeb2e51ab4b 100644 --- a/packages/edit-post/src/test/editor.native.js +++ b/packages/edit-post/src/test/editor.native.js @@ -6,93 +6,127 @@ import { addBlock, fireEvent, getBlock, + getEditorHtml, initializeEditor, - render, + screen, setupCoreBlocks, } from 'test/helpers'; /** * WordPress dependencies */ -import RNReactNativeGutenbergBridge, { +import { + requestMediaImport, + subscribeMediaAppend, subscribeParentToggleHTMLMode, } from '@wordpress/react-native-bridge'; -// Force register 'core/editor' store. -import { store } from '@wordpress/editor'; // eslint-disable-line no-unused-vars - -/** - * Internal dependencies - */ -import '..'; -import Editor from '../editor'; -const unsupportedBlock = ` -<!-- wp:notablock --> -<p>Not supported</p> -<!-- /wp:notablock --> -`; +setupCoreBlocks(); -beforeAll( () => { - jest.useFakeTimers( { legacyFakeTimers: true } ); +let toggleModeCallback; +subscribeParentToggleHTMLMode.mockImplementation( ( callback ) => { + toggleModeCallback = callback; } ); -afterAll( () => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); +let mediaAppendCallback; +subscribeMediaAppend.mockImplementation( ( callback ) => { + mediaAppendCallback = callback; } ); -setupCoreBlocks(); +const MEDIA = [ + { + localId: 1, + mediaUrl: 'file:///local-image-1.jpeg', + mediaType: 'image', + serverId: 2000, + serverUrl: 'https://test-site.files.wordpress.com/local-image-1.jpeg', + }, + { + localId: 2, + mediaUrl: 'file:///local-file-1.pdf', + mediaType: 'other', + serverId: 2001, + serverUrl: 'https://test-site.files.wordpress.com/local-file-1.pdf', + }, + { + localId: 3, + mediaUrl: 'file:///local-image-3.jpeg', + mediaType: 'image', + serverId: 2002, + serverUrl: 'https://test-site.files.wordpress.com/local-image-3.jpeg', + }, + { + localId: 4, + mediaUrl: 'file:///local-video-4.mp4', + mediaType: 'video', + serverId: 2003, + serverUrl: 'https://test-site.files.wordpress.com/local-video-4.mp4', + }, +]; describe( 'Editor', () => { - it( 'detects unsupported block and sends hasUnsupportedBlocks true to native', () => { - RNReactNativeGutenbergBridge.editorDidMount = jest.fn(); - - const appContainer = renderEditorWith( unsupportedBlock ); - // For some reason resetEditorBlocks() is asynchronous when dispatching editEntityRecord. - act( () => { - jest.runAllTicks(); - } ); - appContainer.unmount(); - - expect( - RNReactNativeGutenbergBridge.editorDidMount - ).toHaveBeenCalledTimes( 1 ); - expect( - RNReactNativeGutenbergBridge.editorDidMount - ).toHaveBeenCalledWith( [ 'core/notablock' ] ); + afterEach( () => { + jest.clearAllMocks(); } ); it( 'toggles the editor from Visual to HTML mode', async () => { // Arrange - let toggleMode; - subscribeParentToggleHTMLMode.mockImplementation( ( callback ) => { - toggleMode = callback; - } ); - const screen = await initializeEditor(); + await initializeEditor(); await addBlock( screen, 'Paragraph' ); // Act const paragraphBlock = getBlock( screen, 'Paragraph' ); fireEvent.press( paragraphBlock ); act( () => { - toggleMode(); + toggleModeCallback(); } ); // Assert const htmlEditor = screen.getByLabelText( 'html-view-content' ); expect( htmlEditor ).toBeVisible(); + + act( () => { + toggleModeCallback(); + } ); } ); -} ); -// Utilities. -const renderEditorWith = ( content ) => { - return render( - <Editor - initialHtml={ content } - initialHtmlModeEnabled={ false } - initialTitle={ '' } - postType="post" - postId="1" - /> - ); -}; + it( 'appends media correctly for allowed types', async () => { + // Arrange + requestMediaImport + .mockImplementationOnce( ( _, callback ) => + callback( MEDIA[ 0 ].id, MEDIA[ 0 ].serverUrl ) + ) + .mockImplementationOnce( ( _, callback ) => + callback( MEDIA[ 2 ].id, MEDIA[ 2 ].serverUrl ) + ); + await initializeEditor(); + + // Act + await act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); + await act( () => mediaAppendCallback( MEDIA[ 2 ] ) ); + + // Assert + expect( getEditorHtml() ).toMatchSnapshot(); + } ); + + it( 'appends media correctly for allowed types and skips unsupported ones', async () => { + // Arrange + requestMediaImport + .mockImplementationOnce( ( _, callback ) => + callback( MEDIA[ 0 ].id, MEDIA[ 0 ].serverUrl ) + ) + .mockImplementationOnce( ( _, callback ) => + callback( MEDIA[ 3 ].id, MEDIA[ 3 ].serverUrl ) + ); + await initializeEditor(); + + // Act + await act( () => mediaAppendCallback( MEDIA[ 0 ] ) ); + // Unsupported type (PDF file) + await act( () => mediaAppendCallback( MEDIA[ 1 ] ) ); + await act( () => mediaAppendCallback( MEDIA[ 3 ] ) ); + + // Assert + expect( getEditorHtml() ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index d724c44ad40e4..eba0a06012da7 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -27,7 +27,6 @@ "react-native": "src/index", "dependencies": { "@babel/runtime": "^7.16.0", - "@tanstack/react-table": "^8.10.3", "@wordpress/a11y": "file:../a11y", "@wordpress/api-fetch": "file:../api-fetch", "@wordpress/blob": "file:../blob", @@ -40,6 +39,7 @@ "@wordpress/core-commands": "file:../core-commands", "@wordpress/core-data": "file:../core-data", "@wordpress/data": "file:../data", + "@wordpress/dataviews": "file:../dataviews", "@wordpress/date": "file:../date", "@wordpress/deprecated": "file:../deprecated", "@wordpress/dom": "file:../dom", diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js index 643f1e6f81866..44554eac0dcd6 100644 --- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js +++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js @@ -15,12 +15,12 @@ import { } from '@wordpress/components'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; +import { useDebouncedInput } from '@wordpress/compose'; /** * Internal dependencies */ import { unlock } from '../../lock-unlock'; -import useDebouncedInput from '../../utils/use-debounced-input'; import { mapToIHasNameAndId } from './utils'; const { diff --git a/packages/edit-site/src/components/block-editor/editor-canvas.js b/packages/edit-site/src/components/block-editor/editor-canvas.js index 235eaf6617aa8..01bc4cdfa2ddf 100644 --- a/packages/edit-site/src/components/block-editor/editor-canvas.js +++ b/packages/edit-site/src/components/block-editor/editor-canvas.js @@ -6,46 +6,44 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { - __experimentalUseResizeCanvas as useResizeCanvas, - privateApis as blockEditorPrivateApis, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; import { ENTER, SPACE } from '@wordpress/keycodes'; -import { useState, useEffect } from '@wordpress/element'; +import { useState, useEffect, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { privateApis as editorPrivateApis } from '@wordpress/editor'; /** * Internal dependencies */ import { unlock } from '../../lock-unlock'; import { store as editSiteStore } from '../../store'; +import { + FOCUSABLE_ENTITIES, + NAVIGATION_POST_TYPE, +} from '../../utils/constants'; -const { ExperimentalBlockCanvas: BlockCanvas } = unlock( - blockEditorPrivateApis -); +const { EditorCanvas: EditorCanvasRoot } = unlock( editorPrivateApis ); -function EditorCanvas( { - enableResizing, - settings, - children, - contentRef, - ...props -} ) { - const { canvasMode, deviceType, isZoomOutMode } = useSelect( - ( select ) => ( { - deviceType: - select( editSiteStore ).__experimentalGetPreviewDeviceType(), - isZoomOutMode: - select( blockEditorStore ).__unstableGetEditorMode() === - 'zoom-out', - canvasMode: unlock( select( editSiteStore ) ).getCanvasMode(), - } ), - [] - ); +function EditorCanvas( { enableResizing, settings, children, ...props } ) { + const { hasBlocks, isFocusMode, templateType, canvasMode, isZoomOutMode } = + useSelect( ( select ) => { + const { getBlockCount, __unstableGetEditorMode } = + select( blockEditorStore ); + const { getEditedPostType, getCanvasMode } = unlock( + select( editSiteStore ) + ); + const _templateType = getEditedPostType(); + + return { + templateType: _templateType, + isFocusMode: FOCUSABLE_ENTITIES.includes( _templateType ), + isZoomOutMode: __unstableGetEditorMode() === 'zoom-out', + canvasMode: getCanvasMode(), + hasBlocks: !! getBlockCount(), + }; + }, [] ); const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); - const deviceStyles = useResizeCanvas( deviceType ); const [ isFocused, setIsFocused ] = useState( false ); useEffect( () => { @@ -70,15 +68,48 @@ function EditorCanvas( { onClick: () => setCanvasMode( 'edit' ), readonly: true, }; + const isTemplateTypeNavigation = templateType === NAVIGATION_POST_TYPE; + const isNavigationFocusMode = isTemplateTypeNavigation && isFocusMode; + // Hide the appender when: + // - In navigation focus mode (should only allow the root Nav block). + // - In view mode (i.e. not editing). + const showBlockAppender = + ( isNavigationFocusMode && hasBlocks ) || canvasMode === 'view' + ? false + : undefined; + + const styles = useMemo( + () => [ + ...settings.styles, + { + // Forming a "block formatting context" to prevent margin collapsing. + // @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context + + css: `.is-root-container{display:flow-root;${ + // Some themes will have `min-height: 100vh` for the root container, + // which isn't a requirement in auto resize mode. + enableResizing ? 'min-height:0!important;' : '' + }}body{position:relative; ${ + canvasMode === 'view' + ? 'cursor: pointer; min-height: 100vh;' + : '' + }}}`, + }, + ], + [ settings.styles, enableResizing, canvasMode ] + ); return ( - <BlockCanvas - height="100%" + <EditorCanvasRoot + className={ classnames( 'edit-site-editor-canvas__block-list', { + 'is-navigation-block': isTemplateTypeNavigation, + } ) } + renderAppender={ showBlockAppender } + styles={ styles } iframeProps={ { expand: isZoomOutMode, scale: isZoomOutMode ? 0.45 : undefined, frameSize: isZoomOutMode ? 100 : undefined, - style: enableResizing ? {} : deviceStyles, className: classnames( 'edit-site-visual-editor__editor-canvas', { @@ -88,24 +119,9 @@ function EditorCanvas( { ...props, ...( canvasMode === 'view' ? viewModeProps : {} ), } } - styles={ settings.styles } - contentRef={ contentRef } > - <style>{ - // Forming a "block formatting context" to prevent margin collapsing. - // @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context - `.is-root-container{display:flow-root;${ - // Some themes will have `min-height: 100vh` for the root container, - // which isn't a requirement in auto resize mode. - enableResizing ? 'min-height:0!important;' : '' - }}body{position:relative; ${ - canvasMode === 'view' - ? 'cursor: pointer; min-height: 100vh;' - : '' - }}}` - }</style> { children } - </BlockCanvas> + </EditorCanvasRoot> ); } diff --git a/packages/edit-site/src/components/block-editor/site-editor-canvas.js b/packages/edit-site/src/components/block-editor/site-editor-canvas.js index 0d2d522c8b3e1..3bba8cc26d01f 100644 --- a/packages/edit-site/src/components/block-editor/site-editor-canvas.js +++ b/packages/edit-site/src/components/block-editor/site-editor-canvas.js @@ -5,14 +5,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { useRef } from '@wordpress/element'; -import { - BlockList, - BlockTools, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; import { useViewportMatch, useResizeObserver } from '@wordpress/compose'; + /** * Internal dependencies */ @@ -27,17 +22,8 @@ import { NAVIGATION_POST_TYPE, } from '../../utils/constants'; import { unlock } from '../../lock-unlock'; -import PageContentFocusNotifications from '../page-content-focus-notifications'; - -const LAYOUT = { - type: 'default', - // At the root level of the site editor, no alignments should be allowed. - alignments: [], -}; export default function SiteEditorCanvas() { - const { clearSelectedBlock } = useDispatch( blockEditorStore ); - const { templateType, isFocusMode, isViewMode } = useSelect( ( select ) => { const { getEditedPostType, getCanvasMode } = unlock( select( editSiteStore ) @@ -56,16 +42,6 @@ export default function SiteEditorCanvas() { const settings = useSiteEditorSettings(); - const { hasBlocks } = useSelect( ( select ) => { - const { getBlockCount } = select( blockEditorStore ); - - const blocks = getBlockCount(); - - return { - hasBlocks: !! blocks, - }; - }, [] ); - const isMobileViewport = useViewportMatch( 'small', '<' ); const enableResizing = isFocusMode && @@ -73,83 +49,43 @@ export default function SiteEditorCanvas() { // Disable resizing in mobile viewport. ! isMobileViewport; - const contentRef = useRef(); const isTemplateTypeNavigation = templateType === NAVIGATION_POST_TYPE; - const isNavigationFocusMode = isTemplateTypeNavigation && isFocusMode; - - // Hide the appender when: - // - In navigation focus mode (should only allow the root Nav block). - // - In view mode (i.e. not editing). - const showBlockAppender = - ( isNavigationFocusMode && hasBlocks ) || isViewMode - ? false - : undefined; - const forceFullHeight = isNavigationFocusMode; return ( - <> - <EditorCanvasContainer.Slot> - { ( [ editorCanvasView ] ) => - editorCanvasView ? ( - <div className="edit-site-visual-editor is-focus-mode"> - { editorCanvasView } - </div> - ) : ( - <BlockTools - className={ classnames( 'edit-site-visual-editor', { - 'is-focus-mode': - isFocusMode || !! editorCanvasView, - 'is-view-mode': isViewMode, - } ) } - __unstableContentRef={ contentRef } - onClick={ ( event ) => { - // Clear selected block when clicking on the gray background. - if ( event.target === event.currentTarget ) { - clearSelectedBlock(); - } - } } + <EditorCanvasContainer.Slot> + { ( [ editorCanvasView ] ) => + editorCanvasView ? ( + <div className="edit-site-visual-editor is-focus-mode"> + { editorCanvasView } + </div> + ) : ( + <div + className={ classnames( 'edit-site-visual-editor', { + 'is-focus-mode': isFocusMode || !! editorCanvasView, + 'is-view-mode': isViewMode, + } ) } + > + <BackButton /> + <ResizableEditor + enableResizing={ enableResizing } + height={ + sizes.height && ! forceFullHeight + ? sizes.height + : '100%' + } > - <BackButton /> - <ResizableEditor + <EditorCanvas enableResizing={ enableResizing } - height={ - sizes.height && ! forceFullHeight - ? sizes.height - : '100%' - } + settings={ settings } > - <EditorCanvas - enableResizing={ enableResizing } - settings={ settings } - contentRef={ contentRef } - > - { resizeObserver } - <BlockList - className={ classnames( - 'edit-site-block-editor__block-list wp-site-blocks', - { - 'is-navigation-block': - isTemplateTypeNavigation, - } - ) } - dropZoneElement={ - // Pass in the html element of the iframe to ensure that - // the drop zone extends to the very edges of the iframe, - // even if the template is shorter than the viewport. - contentRef.current?.parentNode - } - layout={ LAYOUT } - renderAppender={ showBlockAppender } - /> - </EditorCanvas> - </ResizableEditor> - </BlockTools> - ) - } - </EditorCanvasContainer.Slot> - <PageContentFocusNotifications contentRef={ contentRef } /> - </> + { resizeObserver } + </EditorCanvas> + </ResizableEditor> + </div> + ) + } + </EditorCanvasContainer.Slot> ); } diff --git a/packages/edit-site/src/components/block-editor/style.scss b/packages/edit-site/src/components/block-editor/style.scss index b110b1c274e77..1da43730d9575 100644 --- a/packages/edit-site/src/components/block-editor/style.scss +++ b/packages/edit-site/src/components/block-editor/style.scss @@ -14,7 +14,7 @@ // Navigation focus mode requires padding around the root Navigation block // for presentational purposes. -.edit-site-block-editor__block-list.is-navigation-block { +.edit-site-editor-canvas__block-list.is-navigation-block { padding: $grid-unit-30; } @@ -69,17 +69,6 @@ &.is-view-mode { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 8px 10px -6px rgba(0, 0, 0, 0.8); - - /* - Temporary to hide the contextual toolbar in view mode. - See: https://github.com/WordPress/gutenberg/pull/46298 - This rule can possibly be removed once the - contextual toolbar has been redesigned. - See: https://github.com/WordPress/gutenberg/issues/40450 - */ - .block-editor-block-contextual-toolbar.is-fixed { - display: none; - } } } @@ -96,6 +85,11 @@ } } +// The toolbar header in distraction mode sits over the back button, which renders it unreachable. +.is-distraction-free .edit-site-visual-editor__back-button { + display: none; +} + .resizable-editor__drag-handle { position: absolute; top: 0; diff --git a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js index cb3fb3f1cb333..2deb2d4cb5fa6 100644 --- a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js +++ b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { useViewportMatch } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; import { store as coreStore } from '@wordpress/core-data'; @@ -89,6 +90,7 @@ function useArchiveLabel( templateSlug ) { export function useSpecificEditorSettings() { const { setIsInserterOpened } = useDispatch( editSiteStore ); + const isLargeViewport = useViewportMatch( 'medium' ); const { templateSlug, focusMode, @@ -97,52 +99,60 @@ export function useSpecificEditorSettings() { keepCaretInsideBlock, canvasMode, settings, - } = useSelect( ( select ) => { - const { - getEditedPostType, - getEditedPostId, - getCanvasMode, - getSettings, - } = unlock( select( editSiteStore ) ); - const { get: getPreference } = select( preferencesStore ); - const { getEditedEntityRecord } = select( coreStore ); - const usedPostType = getEditedPostType(); - const usedPostId = getEditedPostId(); - const _record = getEditedEntityRecord( - 'postType', - usedPostType, - usedPostId - ); - return { - templateSlug: _record.slug, - focusMode: !! getPreference( 'core/edit-site', 'focusMode' ), - isDistractionFree: !! getPreference( - 'core/edit-site', - 'distractionFree' - ), - hasFixedToolbar: !! getPreference( - 'core/edit-site', - 'fixedToolbar' - ), - keepCaretInsideBlock: !! getPreference( - 'core/edit-site', - 'keepCaretInsideBlock' - ), - canvasMode: getCanvasMode(), - settings: getSettings(), - }; - }, [] ); + postWithTemplate, + } = useSelect( + ( select ) => { + const { + getEditedPostType, + getEditedPostId, + getEditedPostContext, + getCanvasMode, + getSettings, + } = unlock( select( editSiteStore ) ); + const { get: getPreference } = select( preferencesStore ); + const { getEditedEntityRecord } = select( coreStore ); + const usedPostType = getEditedPostType(); + const usedPostId = getEditedPostId(); + const _record = getEditedEntityRecord( + 'postType', + usedPostType, + usedPostId + ); + const _context = getEditedPostContext(); + return { + templateSlug: _record.slug, + focusMode: !! getPreference( 'core/edit-site', 'focusMode' ), + isDistractionFree: !! getPreference( + 'core/edit-site', + 'distractionFree' + ), + hasFixedToolbar: + !! getPreference( 'core/edit-site', 'fixedToolbar' ) || + ! isLargeViewport, + keepCaretInsideBlock: !! getPreference( + 'core/edit-site', + 'keepCaretInsideBlock' + ), + canvasMode: getCanvasMode(), + settings: getSettings(), + postWithTemplate: _context?.postId, + }; + }, + [ isLargeViewport ] + ); const archiveLabels = useArchiveLabel( templateSlug ); - + const defaultRenderingMode = postWithTemplate ? 'template-locked' : 'all'; const defaultEditorSettings = useMemo( () => { return { ...settings, + supportsTemplateMode: true, __experimentalSetIsInserterOpened: setIsInserterOpened, focusMode: canvasMode === 'view' && focusMode ? false : focusMode, isDistractionFree, hasFixedToolbar, keepCaretInsideBlock, + defaultRenderingMode, // I wonder if they should be set in the post editor too __experimentalArchiveTitleTypeLabel: archiveLabels.archiveTypeLabel, @@ -158,6 +168,7 @@ export function useSpecificEditorSettings() { canvasMode, archiveLabels.archiveTypeLabel, archiveLabels.archiveNameLabel, + defaultRenderingMode, ] ); return defaultEditorSettings; diff --git a/packages/edit-site/src/components/dataviews/constants.js b/packages/edit-site/src/components/dataviews/constants.js deleted file mode 100644 index 2af12b04c559d..0000000000000 --- a/packages/edit-site/src/components/dataviews/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -// Field types. -export const ENUMERATION_TYPE = 'enumeration'; - -// Filter operators. -export const OPERATOR_IN = 'in'; diff --git a/packages/edit-site/src/components/dataviews/filter-summary.js b/packages/edit-site/src/components/dataviews/filter-summary.js deleted file mode 100644 index ae92d0cc46273..0000000000000 --- a/packages/edit-site/src/components/dataviews/filter-summary.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * WordPress dependencies - */ -import { - Button, - privateApis as componentsPrivateApis, - Icon, -} from '@wordpress/components'; -import { chevronDown } from '@wordpress/icons'; -import { __, sprintf } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import { OPERATOR_IN } from './constants'; -import { unlock } from '../../lock-unlock'; - -const { - DropdownMenuV2: DropdownMenu, - DropdownMenuCheckboxItemV2: DropdownMenuCheckboxItem, -} = unlock( componentsPrivateApis ); - -export default function FilterSummary( { filter, view, onChangeView } ) { - const filterInView = view.filters.find( ( f ) => f.field === filter.field ); - const activeElement = filter.elements.find( - ( element ) => element.value === filterInView?.value - ); - - return ( - <DropdownMenu - key={ filter.field } - trigger={ - <Button variant="tertiary" size="compact" label={ filter.name }> - { activeElement !== undefined - ? sprintf( - /* translators: 1: Filter name. 2: filter value. e.g.: "Author is Admin". */ - __( '%1$s is %2$s' ), - filter.name, - activeElement.label - ) - : filter.name } - <Icon icon={ chevronDown } style={ { flexShrink: 0 } } /> - </Button> - } - > - { filter.elements.map( ( element ) => { - return ( - <DropdownMenuCheckboxItem - key={ element.value } - value={ element.value } - checked={ activeElement?.value === element.value } - onSelect={ () => - onChangeView( ( currentView ) => ( { - ...currentView, - page: 1, - filters: [ - ...view.filters.filter( - ( f ) => f.field !== filter.field - ), - { - field: filter.field, - operator: OPERATOR_IN, - value: - activeElement?.value === - element.value - ? undefined - : element.value, - }, - ], - } ) ) - } - > - { element.label } - </DropdownMenuCheckboxItem> - ); - } ) } - </DropdownMenu> - ); -} diff --git a/packages/edit-site/src/components/dataviews/index.js b/packages/edit-site/src/components/dataviews/index.js deleted file mode 100644 index eebdb77220c68..0000000000000 --- a/packages/edit-site/src/components/dataviews/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as DataViews, viewTypeSupportsMap } from './dataviews'; diff --git a/packages/edit-site/src/components/dataviews/style.scss b/packages/edit-site/src/components/dataviews/style.scss deleted file mode 100644 index 8460c95ef4316..0000000000000 --- a/packages/edit-site/src/components/dataviews/style.scss +++ /dev/null @@ -1,131 +0,0 @@ -.dataviews-wrapper { - width: 100%; - height: calc(100% - #{$grid-unit-40} * 2); - overflow: auto; - padding: $grid-unit-40 $grid-unit-40 0; - - > div { - min-height: 100%; - } -} - -.dataviews-pagination { - margin-top: auto; - position: sticky; - bottom: 0; - background-color: $white; - padding: $grid-unit-20 0; - border-top: $border-width solid $gray-200; -} - -.dataviews-filters-options { - margin: $grid-unit-40 0 $grid-unit-20; -} - -.dataviews-list-view { - width: 100%; - text-indent: 0; - border-color: inherit; - border-collapse: collapse; - position: relative; - - a { - text-decoration: none; - } - th { - text-align: left; - font-weight: normal; - padding: 0 $grid-unit-20 $grid-unit-20; - color: $gray-700; - } - td, - th { - padding: $grid-unit-15; - &[data-field-id="actions"] { - text-align: right; - } - } - tr { - border-bottom: 1px solid $gray-100; - - &:last-child { - border-bottom: 0; - } - } - thead { - tr { - border: 0; - } - th { - position: sticky; - top: - #{$grid-unit-40}; // Offset the container padding - background-color: $white; - box-shadow: inset 0 -#{$border-width} 0 $gray-200; - } - } -} - -.dataviews-grid-view { - margin-bottom: $grid-unit-30; - grid-template-columns: repeat(2, minmax(0, 1fr)) !important; - - @include break-xlarge() { - grid-template-columns: repeat(3, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency - } - - @include break-huge() { - grid-template-columns: repeat(4, minmax(0, 1fr)) !important; // Todo: eliminate !important dependency - } - - .dataviews-view-grid__card { - h3 { // Todo: A better way to target this - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .dataviews-view-grid__media { - width: 100%; - min-height: 200px; - aspect-ratio: 1/1; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); - border-radius: $radius-block-ui * 2; - overflow: hidden; - - > * { - object-fit: cover; - width: 100%; - height: 100%; - } - } - - .dataviews-view-grid__title { - min-height: $grid-unit-30; - - a { - color: $gray-900; - text-decoration: none; - font-weight: 500; - } - } - - .dataviews-view-grid__fields { - position: relative; - font-size: 12px; - line-height: 16px; - - .dataviews-view-grid__field { - .dataviews-view-grid__field-header { - color: $gray-700; - } - .dataviews-view-grid__field-value { - color: $gray-900; - } - } - } -} - -.dataviews-action-modal { - z-index: z-index(".dataviews-action-modal"); -} diff --git a/packages/edit-site/src/components/dataviews/view-list.js b/packages/edit-site/src/components/dataviews/view-list.js deleted file mode 100644 index c5d0bd0d340fe..0000000000000 --- a/packages/edit-site/src/components/dataviews/view-list.js +++ /dev/null @@ -1,512 +0,0 @@ -/** - * External dependencies - */ -import { - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - getPaginationRowModel, - useReactTable, - flexRender, -} from '@tanstack/react-table'; - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { useAsyncList } from '@wordpress/compose'; -import { - chevronDown, - chevronUp, - unseen, - check, - arrowUp, - arrowDown, - chevronRightSmall, - funnel, -} from '@wordpress/icons'; -import { - Button, - Icon, - privateApis as componentsPrivateApis, -} from '@wordpress/components'; -import { useMemo, Children, Fragment } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { unlock } from '../../lock-unlock'; -import ItemActions from './item-actions'; -import { ENUMERATION_TYPE, OPERATOR_IN } from './constants'; - -const { - DropdownMenuV2: DropdownMenu, - DropdownMenuGroupV2: DropdownMenuGroup, - DropdownMenuItemV2: DropdownMenuItem, - DropdownMenuSeparatorV2: DropdownMenuSeparator, - DropdownSubMenuV2: DropdownSubMenu, - DropdownSubMenuTriggerV2: DropdownSubMenuTrigger, -} = unlock( componentsPrivateApis ); - -const EMPTY_OBJECT = {}; -const sortingItemsInfo = { - asc: { icon: arrowUp, label: __( 'Sort ascending' ) }, - desc: { icon: arrowDown, label: __( 'Sort descending' ) }, -}; -const sortIcons = { asc: chevronUp, desc: chevronDown }; - -function HeaderMenu( { dataView, header } ) { - if ( header.isPlaceholder ) { - return null; - } - const text = flexRender( - header.column.columnDef.header, - header.getContext() - ); - const isSortable = !! header.column.getCanSort(); - const isHidable = !! header.column.getCanHide(); - if ( ! isSortable && ! isHidable ) { - return text; - } - const sortedDirection = header.column.getIsSorted(); - - let filter; - if ( header.column.columnDef.type === ENUMERATION_TYPE ) { - filter = { - field: header.column.columnDef.id, - elements: header.column.columnDef.elements || [], - }; - } - const isFilterable = !! filter; - - return ( - <DropdownMenu - align="start" - trigger={ - <Button - icon={ sortIcons[ header.column.getIsSorted() ] } - iconPosition="right" - text={ text } - style={ { padding: 0 } } - /> - } - > - <WithSeparators> - { isSortable && ( - <DropdownMenuGroup> - { Object.entries( sortingItemsInfo ).map( - ( [ direction, info ] ) => ( - <DropdownMenuItem - key={ direction } - prefix={ <Icon icon={ info.icon } /> } - suffix={ - sortedDirection === direction && ( - <Icon icon={ check } /> - ) - } - onSelect={ ( event ) => { - event.preventDefault(); - if ( sortedDirection === direction ) { - dataView.resetSorting(); - } else { - dataView.setSorting( [ - { - id: header.column.id, - desc: direction === 'desc', - }, - ] ); - } - } } - > - { info.label } - </DropdownMenuItem> - ) - ) } - </DropdownMenuGroup> - ) } - { isHidable && ( - <DropdownMenuItem - prefix={ <Icon icon={ unseen } /> } - onSelect={ ( event ) => { - event.preventDefault(); - header.column.getToggleVisibilityHandler()( event ); - } } - > - { __( 'Hide' ) } - </DropdownMenuItem> - ) } - { isFilterable && ( - <DropdownMenuGroup> - <DropdownSubMenu - key={ filter.field } - trigger={ - <DropdownSubMenuTrigger - prefix={ <Icon icon={ funnel } /> } - suffix={ - <Icon icon={ chevronRightSmall } /> - } - > - { __( 'Filter by' ) } - </DropdownSubMenuTrigger> - } - > - { filter.elements.map( ( element ) => { - let isActive = false; - const columnFilters = - dataView.getState().columnFilters; - const columnFilter = columnFilters.find( - ( f ) => - Object.keys( f )[ 0 ].split( - ':' - )[ 0 ] === filter.field - ); - - if ( columnFilter ) { - const value = - Object.values( columnFilter )[ 0 ]; - // Intentionally use loose comparison, so it does type conversion. - // This covers the case where a top-level filter for the same field converts a number into a string. - isActive = element.value == value; // eslint-disable-line eqeqeq - } - - return ( - <DropdownMenuItem - key={ element.value } - suffix={ - isActive && <Icon icon={ check } /> - } - onSelect={ () => { - const otherFilters = - columnFilters?.filter( - ( f ) => { - const [ - field, - operator, - ] = - Object.keys( - f - )[ 0 ].split( ':' ); - return ( - field !== - filter.field || - operator !== - OPERATOR_IN - ); - } - ); - - dataView.setColumnFilters( [ - ...otherFilters, - { - [ filter.field + ':in' ]: - isActive - ? undefined - : element.value, - }, - ] ); - } } - > - { element.label } - </DropdownMenuItem> - ); - } ) } - </DropdownSubMenu> - </DropdownMenuGroup> - ) } - </WithSeparators> - </DropdownMenu> - ); -} - -function WithSeparators( { children } ) { - return Children.toArray( children ) - .filter( Boolean ) - .map( ( child, i ) => ( - <Fragment key={ i }> - { i > 0 && <DropdownMenuSeparator /> } - { child } - </Fragment> - ) ); -} - -function ViewList( { - view, - onChangeView, - fields, - actions, - data, - getItemId, - isLoading = false, - paginationInfo, -} ) { - const columns = useMemo( () => { - const _columns = fields.map( ( field ) => { - const { render, getValue, ...column } = field; - column.cell = ( props ) => - render( { item: props.row.original, view } ); - if ( getValue ) { - column.accessorFn = ( item ) => getValue( { item } ); - } - return column; - } ); - if ( actions?.length ) { - _columns.push( { - header: __( 'Actions' ), - id: 'actions', - cell: ( props ) => { - return ( - <ItemActions - item={ props.row.original } - actions={ actions } - /> - ); - }, - enableHiding: false, - } ); - } - - return _columns; - }, [ fields, actions, view ] ); - - const columnVisibility = useMemo( () => { - if ( ! view.hiddenFields?.length ) { - return; - } - return view.hiddenFields.reduce( - ( accumulator, fieldId ) => ( { - ...accumulator, - [ fieldId ]: false, - } ), - {} - ); - }, [ view.hiddenFields ] ); - - /** - * Transform the filters from the view format into the tanstack columns filter format. - * - * Input: - * - * view.filters = [ - * { field: 'date', operator: 'before', value: '2020-01-01' }, - * { field: 'date', operator: 'after', value: '2020-01-01' }, - * ] - * - * Output: - * - * columnFilters = [ - * { "date:before": '2020-01-01' }, - * { "date:after": '2020-01-01' } - * ] - * - * @param {Array} filters The view filters to transform. - * @return {Array} The transformed TanStack column filters. - */ - const toTanStackColumnFilters = ( filters ) => - filters?.map( ( filter ) => ( { - [ filter.field + ':' + filter.operator ]: filter.value, - } ) ); - - /** - * Transform the filters from the view format into the tanstack columns filter format. - * - * Input: - * - * columnFilters = [ - * { "date:before": '2020-01-01'}, - * { "date:after": '2020-01-01' } - * ] - * - * Output: - * - * view.filters = [ - * { field: 'date', operator: 'before', value: '2020-01-01' }, - * { field: 'date', operator: 'after', value: '2020-01-01' }, - * ] - * - * @param {Array} filters The TanStack column filters to transform. - * @return {Array} The transformed view filters. - */ - const fromTanStackColumnFilters = ( filters ) => - filters.map( ( filter ) => { - const [ key, value ] = Object.entries( filter )[ 0 ]; - const [ field, operator ] = key.split( ':' ); - return { field, operator, value }; - } ); - - const shownData = useAsyncList( data ); - const dataView = useReactTable( { - data: shownData, - columns, - manualSorting: true, - manualFiltering: true, - manualPagination: true, - enableRowSelection: true, - state: { - sorting: view.sort - ? [ - { - id: view.sort.field, - desc: view.sort.direction === 'desc', - }, - ] - : [], - globalFilter: view.search, - columnFilters: toTanStackColumnFilters( view.filters ), - pagination: { - pageIndex: view.page, - pageSize: view.perPage, - }, - columnVisibility: columnVisibility ?? EMPTY_OBJECT, - }, - getRowId: getItemId, - onSortingChange: ( sortingUpdater ) => { - onChangeView( ( currentView ) => { - const sort = - typeof sortingUpdater === 'function' - ? sortingUpdater( - currentView.sort - ? [ - { - id: currentView.sort.field, - desc: - currentView.sort - .direction === 'desc', - }, - ] - : [] - ) - : sortingUpdater; - if ( ! sort.length ) { - return { - ...currentView, - sort: {}, - }; - } - const [ { id, desc } ] = sort; - return { - ...currentView, - sort: { field: id, direction: desc ? 'desc' : 'asc' }, - }; - } ); - }, - onColumnVisibilityChange: ( columnVisibilityUpdater ) => { - onChangeView( ( currentView ) => { - const hiddenFields = Object.entries( - columnVisibilityUpdater() - ).reduce( - ( accumulator, [ fieldId, value ] ) => { - if ( value ) { - return accumulator.filter( - ( id ) => id !== fieldId - ); - } - return [ ...accumulator, fieldId ]; - }, - [ ...( currentView.hiddenFields || [] ) ] - ); - return { - ...currentView, - hiddenFields, - }; - } ); - }, - onGlobalFilterChange: ( value ) => { - onChangeView( { ...view, search: value, page: 1 } ); - }, - onColumnFiltersChange: ( columnFiltersUpdater ) => { - onChangeView( { - ...view, - filters: fromTanStackColumnFilters( columnFiltersUpdater() ), - page: 1, - } ); - }, - onPaginationChange: ( paginationUpdater ) => { - onChangeView( ( currentView ) => { - const { pageIndex, pageSize } = paginationUpdater( { - pageIndex: currentView.page, - pageSize: currentView.perPage, - } ); - return { ...view, page: pageIndex, perPage: pageSize }; - } ); - }, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), - pageCount: paginationInfo.totalPages, - } ); - - const { rows } = dataView.getRowModel(); - const hasRows = !! rows?.length; - if ( isLoading ) { - // TODO:Add spinner or progress bar.. - return <h3>{ __( 'Loading' ) }</h3>; - } - return ( - <div className="dataviews-list-view-wrapper"> - { hasRows && ( - <table className="dataviews-list-view"> - <thead> - { dataView.getHeaderGroups().map( ( headerGroup ) => ( - <tr key={ headerGroup.id }> - { headerGroup.headers.map( ( header ) => ( - <th - key={ header.id } - colSpan={ header.colSpan } - style={ { - width: - header.column.columnDef.width || - undefined, - minWidth: - header.column.columnDef - .minWidth || undefined, - maxWidth: - header.column.columnDef - .maxWidth || undefined, - } } - data-field-id={ header.id } - > - <HeaderMenu - dataView={ dataView } - header={ header } - /> - </th> - ) ) } - </tr> - ) ) } - </thead> - <tbody> - { rows.map( ( row ) => ( - <tr key={ row.id }> - { row.getVisibleCells().map( ( cell ) => ( - <td - key={ cell.column.id } - style={ { - width: - cell.column.columnDef.width || - undefined, - minWidth: - cell.column.columnDef - .minWidth || undefined, - maxWidth: - cell.column.columnDef - .maxWidth || undefined, - } } - > - { flexRender( - cell.column.columnDef.cell, - cell.getContext() - ) } - </td> - ) ) } - </tr> - ) ) } - </tbody> - </table> - ) } - { ! hasRows && <p>{ __( 'no results' ) }</p> } - </div> - ); -} - -export default ViewList; diff --git a/packages/edit-site/src/components/dataviews/view-side-by-side.js b/packages/edit-site/src/components/dataviews/view-side-by-side.js deleted file mode 100644 index 47b1551b379b3..0000000000000 --- a/packages/edit-site/src/components/dataviews/view-side-by-side.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Internal dependencies - */ -import ViewList from './view-list'; - -export function ViewSideBySide( props ) { - // To do: change to email-like preview list. - return <ViewList { ...props } />; -} diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index ce12a2aa5fa6b..295f4ec3cf5c6 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -6,12 +6,13 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { useSelect, useDispatch } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { Notice } from '@wordpress/components'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, useViewportMatch } from '@wordpress/compose'; import { store as preferencesStore } from '@wordpress/preferences'; import { BlockBreadcrumb, + BlockToolbar, store as blockEditorStore, privateApis as blockEditorPrivateApis, BlockInspector, @@ -29,7 +30,6 @@ import { } from '@wordpress/editor'; import { __, sprintf } from '@wordpress/i18n'; import { store as coreDataStore } from '@wordpress/core-data'; -import { useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -93,6 +93,8 @@ export default function Editor( { listViewToggleElement, isLoading } ) { const { type: editedPostType } = editedPost; + const isLargeViewport = useViewportMatch( 'medium' ); + const { context, contextPost, @@ -149,7 +151,6 @@ export default function Editor( { listViewToggleElement, isLoading } ) { ), }; }, [] ); - const { setRenderingMode } = useDispatch( editorStore ); const isViewMode = canvasMode === 'view'; const isEditMode = canvasMode === 'edit'; @@ -169,8 +170,8 @@ export default function Editor( { listViewToggleElement, isLoading } ) { let title; if ( hasLoadedPost ) { title = sprintf( - // translators: A breadcrumb trail in browser tab. %1$s: title of template being edited, %2$s: type of template (Template or Template Part). - __( '%1$s ‹ %2$s ‹ Editor' ), + // translators: A breadcrumb trail for the Admin document title. %1$s: title of template being edited, %2$s: type of template (Template or Template Part). + __( '%1$s ‹ %2$s' ), getTitle(), POST_TYPE_LABELS[ editedPostType ] ?? POST_TYPE_LABELS[ TEMPLATE_POST_TYPE ] @@ -192,16 +193,6 @@ export default function Editor( { listViewToggleElement, isLoading } ) { ( ( postWithTemplate && !! contextPost && !! editedPost ) || ( ! postWithTemplate && !! editedPost ) ); - // This is the only reliable way I've found to reinitialize the rendering mode - // when the canvas mode or the edited entity changes. - useEffect( () => { - if ( canvasMode === 'edit' && postWithTemplate ) { - setRenderingMode( 'template-locked' ); - } else { - setRenderingMode( 'all' ); - } - }, [ canvasMode, postWithTemplate, setRenderingMode ] ); - return ( <> { ! isReady ? <CanvasLoader id={ loadingProgressId } /> : null } @@ -244,6 +235,9 @@ export default function Editor( { listViewToggleElement, isLoading } ) { <SidebarInspectorFill> <BlockInspector /> </SidebarInspectorFill> + { ! isLargeViewport && ( + <BlockToolbar hideDragHandle /> + ) } <SiteEditorCanvas /> <BlockRemovalWarningModal rules={ blockRemovalRules } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js b/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js index d0e797d78beef..8c2b36d3adee9 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/collection-font-variant.js @@ -2,15 +2,13 @@ * WordPress dependencies */ import { CheckboxControl, Flex } from '@wordpress/components'; -/** - * Internal dependencies - */ -import { getFontFaceVariantName } from './utils'; /** * Internal dependencies */ +import { getFontFaceVariantName } from './utils'; import FontFaceDemo from './font-demo'; +import { kebabCase } from '../../../../../block-editor/src/utils/object'; function CollectionFontVariant( { face, @@ -27,18 +25,26 @@ function CollectionFontVariant( { }; const displayName = font.name + ' ' + getFontFaceVariantName( face ); + const checkboxId = kebabCase( + `${ font.slug }-${ getFontFaceVariantName( face ) }` + ); return ( - <div className="font-library-modal__library-font-variant"> + <label + className="font-library-modal__library-font-variant" + htmlFor={ checkboxId } + > <Flex justify="space-between" align="center" gap="1rem"> <FontFaceDemo fontFace={ face } text={ displayName } /> <CheckboxControl checked={ selected } onChange={ handleToggleActivation } __nextHasNoMarginBottom={ true } + id={ checkboxId } + label={ false } /> </Flex> - </div> + </label> ); } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/context.js b/packages/edit-site/src/components/global-styles/font-library-modal/context.js index 33a5b0910f052..58b8621adcf0c 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/context.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/context.js @@ -229,7 +229,7 @@ function FontLibraryProvider( { children } ) { // Uninstall the font (remove the font files from the server and the post from the database). const response = await fetchUninstallFonts( [ font ] ); // Deactivate the font family (remove the font family from the global styles). - if ( ! response.errors ) { + if ( 0 === response.errors.length ) { deactivateFontFamily( font ); // Save the global styles to the database. await saveSpecifiedEntityEdits( diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js index 32e18c023cecb..010f3efdbeb91 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js @@ -3,16 +3,14 @@ */ import { useContext } from '@wordpress/element'; import { CheckboxControl, Flex } from '@wordpress/components'; -/** - * Internal dependencies - */ -import { getFontFaceVariantName } from './utils'; /** * Internal dependencies */ +import { getFontFaceVariantName } from './utils'; import { FontLibraryContext } from './context'; import FontFaceDemo from './font-demo'; +import { kebabCase } from '../../../../../block-editor/src/utils/object'; function LibraryFontVariant( { face, font } ) { const { isFontActivated, toggleActivateFont } = @@ -36,18 +34,26 @@ function LibraryFontVariant( { face, font } ) { }; const displayName = font.name + ' ' + getFontFaceVariantName( face ); + const checkboxId = kebabCase( + `${ font.slug }-${ getFontFaceVariantName( face ) }` + ); return ( - <div className="font-library-modal__library-font-variant"> + <label + className="font-library-modal__library-font-variant" + htmlFor={ checkboxId } + > <Flex justify="space-between" align="center" gap="1rem"> <FontFaceDemo fontFace={ face } text={ displayName } /> <CheckboxControl checked={ isIstalled } onChange={ handleToggleActivation } __nextHasNoMarginBottom={ true } + id={ checkboxId } + label={ false } /> </Flex> - </div> + </label> ); } diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/get-revision-changes.js b/packages/edit-site/src/components/global-styles/screen-revisions/get-revision-changes.js new file mode 100644 index 0000000000000..fed075eb923ff --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/get-revision-changes.js @@ -0,0 +1,171 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +const globalStylesChangesCache = new Map(); +const EMPTY_ARRAY = []; + +const translationMap = { + caption: __( 'Caption' ), + link: __( 'Link' ), + button: __( 'Button' ), + heading: __( 'Heading' ), + 'settings.color': __( 'Color settings' ), + 'settings.typography': __( 'Typography settings' ), + 'styles.color': __( 'Colors' ), + 'styles.spacing': __( 'Spacing' ), + 'styles.typography': __( 'Typography' ), +}; + +const isObject = ( obj ) => obj !== null && typeof obj === 'object'; + +/** + * Get the translation for a given global styles key. + * @param {string} key A key representing a path to a global style property or setting. + * @param {Record<string,string>} blockNames A key/value pair object of block names and their rendered titles. + * @return {string|undefined} A translated key or undefined if no translation exists. + */ +function getTranslation( key, blockNames ) { + if ( translationMap[ key ] ) { + return translationMap[ key ]; + } + + const keyArray = key.split( '.' ); + + if ( keyArray?.[ 0 ] === 'blocks' ) { + const blockName = blockNames[ keyArray[ 1 ] ]; + return blockName + ? sprintf( + // translators: %s: block name. + __( '%s block' ), + blockName + ) + : keyArray[ 1 ]; + } + + if ( keyArray?.[ 0 ] === 'elements' ) { + return sprintf( + // translators: %s: element name, e.g., heading button, link, caption. + __( '%s element' ), + translationMap[ keyArray[ 1 ] ] + ); + } + + return undefined; +} + +/** + * A deep comparison of two objects, optimized for comparing global styles. + * @param {Object} changedObject The changed object to compare. + * @param {Object} originalObject The original object to compare against. + * @param {string} parentPath A key/value pair object of block names and their rendered titles. + * @return {string[]} An array of paths whose values have changed. + */ +function deepCompare( changedObject, originalObject, parentPath = '' ) { + // We have two non-object values to compare. + if ( ! isObject( changedObject ) && ! isObject( originalObject ) ) { + /* + * Only return a path if the value has changed. + * And then only the path name up to 2 levels deep. + */ + return changedObject !== originalObject + ? parentPath.split( '.' ).slice( 0, 2 ).join( '.' ) + : undefined; + } + + // Enable comparison when an object doesn't have a corresponding property to compare. + changedObject = isObject( changedObject ) ? changedObject : {}; + originalObject = isObject( originalObject ) ? originalObject : {}; + + const allKeys = new Set( [ + ...Object.keys( changedObject ), + ...Object.keys( originalObject ), + ] ); + + let diffs = []; + for ( const key of allKeys ) { + const path = parentPath ? parentPath + '.' + key : key; + const changedPath = deepCompare( + changedObject[ key ], + originalObject[ key ], + path + ); + if ( changedPath ) { + diffs = diffs.concat( changedPath ); + } + } + return diffs; +} + +/** + * Get an array of translated summarized global styles changes. + * Results are cached using a Map() key of `JSON.stringify( { revision, previousRevision } )`. + * + * @param {Object} revision The changed object to compare. + * @param {Object} previousRevision The original object to compare against. + * @param {Record<string,string>} blockNames A key/value pair object of block names and their rendered titles. + * @return {string[]} An array of translated changes. + */ +export default function getRevisionChanges( + revision, + previousRevision, + blockNames +) { + const cacheKey = JSON.stringify( { revision, previousRevision } ); + + if ( globalStylesChangesCache.has( cacheKey ) ) { + return globalStylesChangesCache.get( cacheKey ); + } + + /* + * Compare the two revisions with normalized keys. + * The order of these keys determines the order in which + * they'll appear in the results. + */ + const changedValueTree = deepCompare( + { + styles: { + color: revision?.styles?.color, + typography: revision?.styles?.typography, + spacing: revision?.styles?.spacing, + }, + blocks: revision?.styles?.blocks, + elements: revision?.styles?.elements, + settings: revision?.settings, + }, + { + styles: { + color: previousRevision?.styles?.color, + typography: previousRevision?.styles?.typography, + spacing: previousRevision?.styles?.spacing, + }, + blocks: previousRevision?.styles?.blocks, + elements: previousRevision?.styles?.elements, + settings: previousRevision?.settings, + } + ); + + if ( ! changedValueTree.length ) { + globalStylesChangesCache.set( cacheKey, EMPTY_ARRAY ); + return EMPTY_ARRAY; + } + + // Remove duplicate results. + const result = [ ...new Set( changedValueTree ) ] + /* + * Translate the keys. + * Remove duplicate or empty translations. + */ + .reduce( ( acc, curr ) => { + const translation = getTranslation( curr, blockNames ); + if ( translation && ! acc.includes( translation ) ) { + acc.push( translation ); + } + return acc; + }, [] ); + + globalStylesChangesCache.set( cacheKey, result ); + + return result; +} diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/index.js b/packages/edit-site/src/components/global-styles/screen-revisions/index.js index 90bf68e579cb7..aa380c5a9fbd0 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/index.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/index.js @@ -7,7 +7,6 @@ import { __experimentalUseNavigator as useNavigator, __experimentalConfirmDialog as ConfirmDialog, Spinner, - __experimentalSpacer as Spacer, } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -135,7 +134,8 @@ function ScreenRevisions() { } }, [ shouldSelectFirstItem, firstRevision ] ); - // Only display load button if there is a revision to load and it is different from the current editor styles. + // Only display load button if there is a revision to load, + // and it is different from the current editor styles. const isLoadButtonEnabled = !! currentlySelectedRevisionId && ! selectedRevisionMatchesEditorStyles; const shouldShowRevisions = ! isLoading && revisions.length; @@ -156,7 +156,7 @@ function ScreenRevisions() { { isLoading && ( <Spinner className="edit-site-global-styles-screen-revisions__loading" /> ) } - { shouldShowRevisions ? ( + { shouldShowRevisions && ( <> <Revisions blocks={ blocks } @@ -168,6 +168,7 @@ function ScreenRevisions() { onChange={ selectRevision } selectedRevisionId={ currentlySelectedRevisionId } userRevisions={ revisions } + canApplyRevision={ isLoadButtonEnabled } /> { isLoadButtonEnabled && ( <SidebarFixedBottom> @@ -215,14 +216,6 @@ function ScreenRevisions() { </ConfirmDialog> ) } </> - ) : ( - <Spacer marginX={ 4 } data-testid="global-styles-no-revisions"> - { - // Adding an existing translation here in case these changes are shipped to WordPress 6.3. - // Later we could update to something better, e.g., "There are currently no style revisions.". - __( 'No results found.' ) - } - </Spacer> ) } </> ); diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js index 2786bf6d79121..0893006942572 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js +++ b/packages/edit-site/src/components/global-styles/screen-revisions/revisions-buttons.js @@ -6,28 +6,69 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { dateI18n, getDate, humanTimeDiff, getSettings } from '@wordpress/date'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { getBlockTypes } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import getRevisionChanges from './get-revision-changes'; const DAY_IN_MILLISECONDS = 60 * 60 * 1000 * 24; +const MAX_CHANGES = 7; + +function ChangesSummary( { revision, previousRevision, blockNames } ) { + const changes = getRevisionChanges( + revision, + previousRevision, + blockNames + ); + const changesLength = changes.length; + + if ( ! changesLength ) { + return null; + } + + // Truncate to `n` results if necessary. + if ( changesLength > MAX_CHANGES ) { + const deleteCount = changesLength - MAX_CHANGES; + const andMoreText = sprintf( + // translators: %d: number of global styles changes that are not displayed in the UI. + _n( '…and %d more change.', '…and %d more changes.', deleteCount ), + deleteCount + ); + changes.splice( MAX_CHANGES, deleteCount, andMoreText ); + } + + return ( + <span + data-testid="global-styles-revision-changes" + className="edit-site-global-styles-screen-revisions__changes" + > + { changes.join( ', ' ) } + </span> + ); +} /** * Returns a button label for the revision. * * @param {string|number} id A revision object. - * @param {boolean} isLatest Whether the revision is the most current. * @param {string} authorDisplayName Author name. * @param {string} formattedModifiedDate Revision modified date formatted. + * @param {boolean} areStylesEqual Whether the revision matches the current editor styles. * @return {string} Translated label. */ function getRevisionLabel( id, - isLatest, authorDisplayName, - formattedModifiedDate + formattedModifiedDate, + areStylesEqual ) { if ( 'parent' === id ) { return __( 'Reset the styles to the theme defaults' ); @@ -35,21 +76,23 @@ function getRevisionLabel( if ( 'unsaved' === id ) { return sprintf( - /* translators: %s author display name */ + /* translators: %s: author display name */ __( 'Unsaved changes by %s' ), authorDisplayName ); } - return isLatest + return areStylesEqual ? sprintf( - /* translators: %1$s author display name, %2$s: revision creation date */ - __( 'Changes saved by %1$s on %2$s (current)' ), + // translators: %1$s: author display name, %2$s: revision creation date. + __( + 'Changes saved by %1$s on %2$s. This revision matches current editor styles.' + ), authorDisplayName, formattedModifiedDate ) : sprintf( - /* translators: %1$s author display name, %2$s: revision creation date */ + // translators: %1$s: author display name, %2$s: revision creation date. __( 'Changes saved by %1$s on %2$s' ), authorDisplayName, formattedModifiedDate @@ -67,7 +110,12 @@ function getRevisionLabel( * @param {props} Component props. * @return {JSX.Element} The modal component. */ -function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { +function RevisionsButtons( { + userRevisions, + selectedRevisionId, + onChange, + canApplyRevision, +} ) { const { currentThemeName, currentUser } = useSelect( ( select ) => { const { getCurrentTheme, getCurrentUser } = select( coreStore ); const currentTheme = getCurrentTheme(); @@ -77,8 +125,15 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { currentUser: getCurrentUser(), }; }, [] ); + const blockNames = useMemo( () => { + const blockTypes = getBlockTypes(); + return blockTypes.reduce( ( accumulator, { name, title } ) => { + accumulator[ name ] = title; + return accumulator; + }, {} ); + }, [] ); const dateNowInMs = getDate().getTime(); - const { date: dateFormat, datetimeAbbreviated } = getSettings().formats; + const { datetimeAbbreviated } = getSettings().formats; return ( <ol @@ -87,27 +142,29 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { role="group" > { userRevisions.map( ( revision, index ) => { - const { id, isLatest, author, modified } = revision; + const { id, author, modified } = revision; const isUnsaved = 'unsaved' === id; // Unsaved changes are created by the current user. const revisionAuthor = isUnsaved ? currentUser : author; const authorDisplayName = revisionAuthor?.name || __( 'User' ); const authorAvatar = revisionAuthor?.avatar_urls?.[ '48' ]; + const isFirstItem = index === 0; const isSelected = selectedRevisionId ? selectedRevisionId === id - : index === 0; + : isFirstItem; + const areStylesEqual = ! canApplyRevision && isSelected; const isReset = 'parent' === id; const modifiedDate = getDate( modified ); const displayDate = modified && dateNowInMs - modifiedDate.getTime() > DAY_IN_MILLISECONDS - ? dateI18n( dateFormat, modifiedDate ) + ? dateI18n( datetimeAbbreviated, modifiedDate ) : humanTimeDiff( modified ); const revisionLabel = getRevisionLabel( id, - isLatest, authorDisplayName, - dateI18n( datetimeAbbreviated, modifiedDate ) + dateI18n( datetimeAbbreviated, modifiedDate ), + areStylesEqual ); return ( @@ -116,6 +173,7 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { 'edit-site-global-styles-screen-revisions__revision-item', { 'is-selected': isSelected, + 'is-active': areStylesEqual, 'is-reset': isReset, } ) } @@ -127,7 +185,7 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { onClick={ () => { onChange( revision ); } } - label={ revisionLabel } + aria-label={ revisionLabel } > { isReset ? ( <span className="edit-site-global-styles-screen-revisions__description"> @@ -150,6 +208,17 @@ function RevisionsButtons( { userRevisions, selectedRevisionId, onChange } ) { { displayDate } </time> ) } + { isSelected && ( + <ChangesSummary + blockNames={ blockNames } + revision={ revision } + previousRevision={ + index < userRevisions.length + ? userRevisions[ index + 1 ] + : {} + } + /> + ) } <span className="edit-site-global-styles-screen-revisions__meta"> <img alt={ authorDisplayName } diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/style.scss b/packages/edit-site/src/components/global-styles/screen-revisions/style.scss index 6598fcb5ce1c7..d1325f84772a6 100644 --- a/packages/edit-site/src/components/global-styles/screen-revisions/style.scss +++ b/packages/edit-site/src/components/global-styles/screen-revisions/style.scss @@ -66,7 +66,7 @@ width: 100%; height: auto; display: block; - padding: $grid-unit-15 $grid-unit-15 $grid-unit-15 $grid-unit-30; + padding: $grid-unit-15 $grid-unit-15 $grid-unit-10 $grid-unit-30; &:focus, &:active { outline: 0; @@ -103,6 +103,7 @@ } } +.edit-site-global-styles-screen-revisions__changes, .edit-site-global-styles-screen-revisions__meta { color: $gray-600; display: flex; @@ -110,7 +111,6 @@ width: 100%; align-items: center; font-size: 12px; - img { width: $grid-unit-20; height: $grid-unit-20; @@ -122,3 +122,11 @@ .edit-site-global-styles-screen-revisions__loading { margin: $grid-unit-30 auto !important; } + +.edit-site-global-styles-screen-revisions__changes { + margin-bottom: $grid-unit-05; + text-align: left; + color: $gray-900; + line-height: $default-line-height; +} + diff --git a/packages/edit-site/src/components/global-styles/screen-revisions/test/get-revision-changes.js b/packages/edit-site/src/components/global-styles/screen-revisions/test/get-revision-changes.js new file mode 100644 index 0000000000000..be9a26b97f688 --- /dev/null +++ b/packages/edit-site/src/components/global-styles/screen-revisions/test/get-revision-changes.js @@ -0,0 +1,191 @@ +/** + * Internal dependencies + */ +import getRevisionChanges from '../get-revision-changes'; + +describe( 'getRevisionChanges', () => { + const revision = { + id: 10, + styles: { + typography: { + fontSize: 'var(--wp--preset--font-size--potato)', + fontStyle: 'normal', + fontWeight: '600', + lineHeight: '1.85', + fontFamily: 'var(--wp--preset--font-family--asparagus)', + }, + spacing: { + padding: { + top: '36px', + right: '89px', + bottom: '133px', + left: 'var(--wp--preset--spacing--20)', + }, + blockGap: '114px', + }, + elements: { + heading: { + typography: { + letterSpacing: '37px', + }, + }, + caption: { + color: { + text: 'var(--wp--preset--color--pineapple)', + }, + }, + }, + color: { + text: 'var(--wp--preset--color--tomato)', + }, + blocks: { + 'core/paragraph': { + color: { + text: '#000000', + }, + }, + }, + }, + settings: { + color: { + palette: { + theme: [ + { + slug: 'one', + color: 'pink', + }, + ], + }, + }, + }, + }; + const previousRevision = { + id: 9, + styles: { + typography: { + fontSize: 'var(--wp--preset--font-size--fungus)', + fontStyle: 'normal', + fontWeight: '600', + lineHeight: '1.85', + fontFamily: 'var(--wp--preset--font-family--grapes)', + }, + spacing: { + padding: { + top: '36px', + right: '89px', + bottom: '133px', + left: 'var(--wp--preset--spacing--20)', + }, + blockGap: '114px', + }, + elements: { + heading: { + typography: { + letterSpacing: '37px', + }, + }, + caption: { + typography: { + fontSize: '1.11rem', + fontStyle: 'normal', + fontWeight: '600', + }, + }, + link: { + typography: { + lineHeight: 2, + textDecoration: 'line-through', + }, + color: { + text: 'var(--wp--preset--color--egg)', + }, + }, + }, + color: { + text: 'var(--wp--preset--color--tomato)', + background: 'var(--wp--preset--color--pumpkin)', + }, + blocks: { + 'core/paragraph': { + color: { + text: '#fff', + }, + }, + }, + }, + settings: { + color: { + palette: { + theme: [ + { + slug: 'one', + color: 'blue', + }, + ], + }, + }, + }, + }; + const blockNames = { + 'core/paragraph': 'Paragraph', + }; + it( 'returns a list of changes and caches them', () => { + const resultA = getRevisionChanges( + revision, + previousRevision, + blockNames + ); + expect( resultA ).toEqual( [ + 'Colors', + 'Typography', + 'Paragraph block', + 'Caption element', + 'Link element', + 'Color settings', + ] ); + + const resultB = getRevisionChanges( + revision, + previousRevision, + blockNames + ); + + expect( resultA ).toBe( resultB ); + } ); + + it( 'skips unknown and unchanged keys', () => { + const result = getRevisionChanges( + { + styles: { + frogs: { + legs: 'green', + }, + typography: { + fontSize: '1rem', + }, + settings: { + '': { + '': 'foo', + }, + }, + }, + }, + { + styles: { + frogs: { + legs: 'yellow', + }, + typography: { + fontSize: '1rem', + }, + settings: { + '': { + '': 'bar', + }, + }, + }, + } + ); + expect( result ).toEqual( [] ); + } ); +} ); diff --git a/packages/edit-site/src/components/header-edit-mode/document-actions/index.js b/packages/edit-site/src/components/header-edit-mode/document-actions/index.js deleted file mode 100644 index 132dbcb249ffb..0000000000000 --- a/packages/edit-site/src/components/header-edit-mode/document-actions/index.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; - -/** - * WordPress dependencies - */ -import { __, isRTL } from '@wordpress/i18n'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { - Button, - VisuallyHidden, - __experimentalText as Text, - __experimentalHStack as HStack, -} from '@wordpress/components'; -import { BlockIcon } from '@wordpress/block-editor'; -import { store as commandsStore } from '@wordpress/commands'; -import { - chevronLeftSmall, - chevronRightSmall, - page as pageIcon, - navigation as navigationIcon, - symbol, -} from '@wordpress/icons'; -import { displayShortcut } from '@wordpress/keycodes'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as editorStore } from '@wordpress/editor'; -import { useRef, useState, useEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import useEditedEntityRecord from '../../use-edited-entity-record'; -import { store as editSiteStore } from '../../../store'; -import { - TEMPLATE_POST_TYPE, - NAVIGATION_POST_TYPE, - TEMPLATE_PART_POST_TYPE, - PATTERN_TYPES, - PATTERN_SYNC_TYPES, -} from '../../../utils/constants'; - -const typeLabels = { - [ PATTERN_TYPES.user ]: __( 'Editing pattern:' ), - [ NAVIGATION_POST_TYPE ]: __( 'Editing navigation menu:' ), - [ TEMPLATE_POST_TYPE ]: __( 'Editing template:' ), - [ TEMPLATE_PART_POST_TYPE ]: __( 'Editing template part:' ), -}; - -export default function DocumentActions() { - const isPage = useSelect( - ( select ) => select( editSiteStore ).isPage(), - [] - ); - return isPage ? <PageDocumentActions /> : <TemplateDocumentActions />; -} - -function PageDocumentActions() { - const { isEditingPage, hasResolved, isFound, title } = useSelect( - ( select ) => { - const { getEditedPostContext } = select( editSiteStore ); - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); - const { getRenderingMode } = select( editorStore ); - const context = getEditedPostContext(); - const queryArgs = [ 'postType', context.postType, context.postId ]; - const page = getEditedEntityRecord( ...queryArgs ); - return { - isEditingPage: - !! context.postId && getRenderingMode() !== 'template-only', - hasResolved: hasFinishedResolution( - 'getEditedEntityRecord', - queryArgs - ), - isFound: !! page, - title: page?.title, - }; - }, - [] - ); - - const { setRenderingMode } = useDispatch( editorStore ); - const [ isAnimated, setIsAnimated ] = useState( false ); - const isLoading = useRef( true ); - - useEffect( () => { - if ( ! isLoading.current ) { - setIsAnimated( true ); - } - isLoading.current = false; - }, [ isEditingPage ] ); - - if ( ! hasResolved ) { - return null; - } - - if ( ! isFound ) { - return ( - <div className="edit-site-document-actions"> - { __( 'Document not found' ) } - </div> - ); - } - - return isEditingPage ? ( - <BaseDocumentActions - className={ classnames( 'is-page', { - 'is-animated': isAnimated, - } ) } - icon={ pageIcon } - > - { title } - </BaseDocumentActions> - ) : ( - <TemplateDocumentActions - className={ classnames( { - 'is-animated': isAnimated, - } ) } - onBack={ () => setRenderingMode( 'template-locked' ) } - /> - ); -} - -function TemplateDocumentActions( { className, onBack } ) { - const { isLoaded, record, getTitle, icon } = useEditedEntityRecord(); - - if ( ! isLoaded ) { - return null; - } - - if ( ! record ) { - return ( - <div className="edit-site-document-actions"> - { __( 'Document not found' ) } - </div> - ); - } - - let typeIcon = icon; - if ( record.type === NAVIGATION_POST_TYPE ) { - typeIcon = navigationIcon; - } else if ( record.type === PATTERN_TYPES.user ) { - typeIcon = symbol; - } - - return ( - <BaseDocumentActions - className={ classnames( className, { - 'is-synced-entity': - record.wp_pattern_sync_status !== - PATTERN_SYNC_TYPES.unsynced, - } ) } - icon={ typeIcon } - onBack={ onBack } - > - <VisuallyHidden as="span"> - { typeLabels[ record.type ] ?? - typeLabels[ TEMPLATE_POST_TYPE ] } - </VisuallyHidden> - { getTitle() } - </BaseDocumentActions> - ); -} - -function BaseDocumentActions( { className, icon, children, onBack } ) { - const { open: openCommandCenter } = useDispatch( commandsStore ); - return ( - <div - className={ classnames( 'edit-site-document-actions', className ) } - > - { onBack && ( - <Button - className="edit-site-document-actions__back" - icon={ isRTL() ? chevronRightSmall : chevronLeftSmall } - onClick={ ( event ) => { - event.stopPropagation(); - onBack(); - } } - > - { __( 'Back' ) } - </Button> - ) } - <Button - className="edit-site-document-actions__command" - onClick={ () => openCommandCenter() } - > - <HStack - className="edit-site-document-actions__title" - spacing={ 1 } - justify="center" - > - <BlockIcon icon={ icon } /> - <Text size="body" as="h1"> - { children } - </Text> - </HStack> - <span className="edit-site-document-actions__shortcut"> - { displayShortcut.primary( 'k' ) } - </span> - </Button> - </div> - ); -} diff --git a/packages/edit-site/src/components/header-edit-mode/document-tools/index.js b/packages/edit-site/src/components/header-edit-mode/document-tools/index.js index dda761c983d31..53d7de6fba18f 100644 --- a/packages/edit-site/src/components/header-edit-mode/document-tools/index.js +++ b/packages/edit-site/src/components/header-edit-mode/document-tools/index.js @@ -14,6 +14,7 @@ import { _x, __ } from '@wordpress/i18n'; import { listView, plus, chevronUpDown } from '@wordpress/icons'; import { Button, ToolbarItem } from '@wordpress/components'; import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -39,18 +40,13 @@ export default function DocumentTools( { const inserterButton = useRef(); const { isInserterOpen, isListViewOpen, listViewShortcut, isVisualMode } = useSelect( ( select ) => { - const { - __experimentalGetPreviewDeviceType, - isInserterOpened, - isListViewOpened, - getEditorMode, - } = select( editSiteStore ); + const { isInserterOpened, isListViewOpened, getEditorMode } = + select( editSiteStore ); const { getShortcutRepresentation } = select( keyboardShortcutsStore ); return { - deviceType: __experimentalGetPreviewDeviceType(), isInserterOpen: isInserterOpened(), isListViewOpen: isListViewOpened(), listViewShortcut: getShortcutRepresentation( @@ -60,12 +56,10 @@ export default function DocumentTools( { }; }, [] ); - const { - __experimentalSetPreviewDeviceType: setPreviewDeviceType, - setIsInserterOpened, - setIsListViewOpened, - } = useDispatch( editSiteStore ); + const { setIsInserterOpened, setIsListViewOpened } = + useDispatch( editSiteStore ); const { __unstableSetEditorMode } = useDispatch( blockEditorStore ); + const { setDeviceType } = useDispatch( editorStore ); const isLargeViewport = useViewportMatch( 'medium' ); @@ -130,6 +124,7 @@ export default function DocumentTools( { label={ showIconLabels ? shortLabel : longLabel } showTooltip={ ! showIconLabels } aria-expanded={ isInserterOpen } + size="compact" /> ) } { isLargeViewport && ( @@ -142,17 +137,20 @@ export default function DocumentTools( { showIconLabels ? 'tertiary' : undefined } disabled={ ! isVisualMode } + size="compact" /> ) } <ToolbarItem as={ UndoButton } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + size="compact" /> <ToolbarItem as={ RedoButton } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + size="compact" /> { ! isDistractionFree && ( <ToolbarItem @@ -171,6 +169,7 @@ export default function DocumentTools( { showIconLabels ? 'tertiary' : undefined } aria-expanded={ isListViewOpen } + size="compact" /> ) } { isZoomedOutViewExperimentEnabled && @@ -184,13 +183,14 @@ export default function DocumentTools( { /* translators: button label text should, if possible, be under 16 characters. */ label={ __( 'Zoom-out View' ) } onClick={ () => { - setPreviewDeviceType( 'Desktop' ); + setDeviceType( 'Desktop' ); __unstableSetEditorMode( isZoomedOutView ? 'edit' : 'zoom-out' ); } } + size="compact" /> ) } </> diff --git a/packages/edit-site/src/components/header-edit-mode/index.js b/packages/edit-site/src/components/header-edit-mode/index.js index ecb501f669ee9..a18c7e3a3eaad 100644 --- a/packages/edit-site/src/components/header-edit-mode/index.js +++ b/packages/edit-site/src/components/header-edit-mode/index.js @@ -7,33 +7,32 @@ import classnames from 'classnames'; * WordPress dependencies */ import { useViewportMatch, useReducedMotion } from '@wordpress/compose'; -import { store as coreStore } from '@wordpress/core-data'; import { - __experimentalPreviewOptions as PreviewOptions, - privateApis as blockEditorPrivateApis, + BlockToolbar, store as blockEditorStore, } from '@wordpress/block-editor'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { useEffect, useRef, useState } from '@wordpress/element'; import { PinnedItems } from '@wordpress/interface'; import { __ } from '@wordpress/i18n'; -import { external, next, previous } from '@wordpress/icons'; +import { next, previous } from '@wordpress/icons'; import { Button, __unstableMotion as motion, - MenuGroup, - MenuItem, Popover, - VisuallyHidden, } from '@wordpress/components'; import { store as preferencesStore } from '@wordpress/preferences'; +import { + DocumentBar, + store as editorStore, + privateApis as editorPrivateApis, +} from '@wordpress/editor'; /** * Internal dependencies */ import MoreMenu from './more-menu'; import SaveButton from '../save-button'; -import DocumentActions from './document-actions'; import DocumentTools from './document-tools'; import { store as editSiteStore } from '../../store'; import { @@ -43,40 +42,30 @@ import { import { unlock } from '../../lock-unlock'; import { FOCUSABLE_ENTITIES } from '../../utils/constants'; -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); +const { PreviewDropdown } = unlock( editorPrivateApis ); export default function HeaderEditMode( { setListViewToggleElement } ) { const { - deviceType, templateType, isDistractionFree, blockEditorMode, blockSelectionStart, - homeUrl, showIconLabels, editorCanvasView, hasFixedToolbar, isZoomOutMode, } = useSelect( ( select ) => { - const { __experimentalGetPreviewDeviceType, getEditedPostType } = - select( editSiteStore ); + const { getEditedPostType } = select( editSiteStore ); const { getBlockSelectionStart, __unstableGetEditorMode } = select( blockEditorStore ); - - const postType = getEditedPostType(); - - const { - getUnstableBase, // Site index. - } = select( coreStore ); - const { get: getPreference } = select( preferencesStore ); + const { getDeviceType } = select( editorStore ); return { - deviceType: __experimentalGetPreviewDeviceType(), - templateType: postType, + deviceType: getDeviceType(), + templateType: getEditedPostType(), blockEditorMode: __unstableGetEditorMode(), blockSelectionStart: getBlockSelectionStart(), - homeUrl: getUnstableBase()?.home, showIconLabels: getPreference( editSiteStore.name, 'showIconLabels' @@ -99,9 +88,6 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { const isLargeViewport = useViewportMatch( 'medium' ); const isTopToolbar = ! isZoomOutMode && hasFixedToolbar && isLargeViewport; const blockToolbarRef = useRef(); - - const { __experimentalSetPreviewDeviceType: setPreviewDeviceType } = - useDispatch( editSiteStore ); const disableMotion = useReducedMotion(); const hasDefaultEditorCanvasView = ! useHasEditorCanvasContainer(); @@ -163,7 +149,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { } ) } > - <BlockContextualToolbar isFixed /> + <BlockToolbar hideDragHandle /> </div> <Popover.Slot ref={ blockToolbarRef } @@ -205,7 +191,7 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { { ! hasDefaultEditorCanvasView ? ( getEditorCanvasContainerTitle( editorCanvasView ) ) : ( - <DocumentActions /> + <DocumentBar /> ) } </div> ) } @@ -216,41 +202,21 @@ export default function HeaderEditMode( { setListViewToggleElement } ) { variants={ toolbarVariants } transition={ toolbarTransition } > - <div - className={ classnames( - 'edit-site-header-edit-mode__preview-options', - { 'is-zoomed-out': isZoomedOutView } - ) } - > - <PreviewOptions - deviceType={ deviceType } - setDeviceType={ setPreviewDeviceType } - label={ __( 'View' ) } - isEnabled={ - ! isFocusMode && hasDefaultEditorCanvasView - } - showIconLabels={ showIconLabels } - > - { ( { onClose } ) => ( - <MenuGroup> - <MenuItem - href={ homeUrl } - target="_blank" - icon={ external } - onClick={ onClose } - > - { __( 'View site' ) } - <VisuallyHidden as="span"> - { - /* translators: accessibility text */ - __( '(opens in a new tab)' ) - } - </VisuallyHidden> - </MenuItem> - </MenuGroup> + { isLargeViewport && ( + <div + className={ classnames( + 'edit-site-header-edit-mode__preview-options', + { 'is-zoomed-out': isZoomedOutView } ) } - </PreviewOptions> - </div> + > + <PreviewDropdown + showIconLabels={ showIconLabels } + disabled={ + isFocusMode || ! hasDefaultEditorCanvasView + } + /> + </div> + ) } <SaveButton /> { ! isDistractionFree && ( <PinnedItems.Slot scope="core/edit-site" /> diff --git a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js index 2185258ad338a..f6c47c1eb93bd 100644 --- a/packages/edit-site/src/components/header-edit-mode/more-menu/index.js +++ b/packages/edit-site/src/components/header-edit-mode/more-menu/index.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; -import { useSelect, useDispatch, useRegistry } from '@wordpress/data'; +import { useDispatch, useRegistry } from '@wordpress/data'; import { displayShortcut } from '@wordpress/keycodes'; import { external } from '@wordpress/icons'; import { MenuGroup, MenuItem, VisuallyHidden } from '@wordpress/components'; @@ -36,14 +36,6 @@ import { store as siteEditorStore } from '../../../store'; export default function MoreMenu( { showIconLabels } ) { const registry = useRegistry(); - const isDistractionFree = useSelect( - ( select ) => - select( preferencesStore ).get( - 'core/edit-site', - 'distractionFree' - ), - [] - ); const { setIsInserterOpened, setIsListViewOpened, closeGeneralSidebar } = useDispatch( siteEditorStore ); @@ -59,6 +51,10 @@ export default function MoreMenu( { showIconLabels } ) { } ); }; + const turnOffDistractionFree = () => { + setPreference( 'core/edit-site', 'distractionFree', false ); + }; + return ( <> <MoreMenuDropdown @@ -73,7 +69,7 @@ export default function MoreMenu( { showIconLabels } ) { <PreferenceToggleMenuItem scope="core/edit-site" name="fixedToolbar" - disabled={ isDistractionFree } + onToggle={ turnOffDistractionFree } label={ __( 'Top toolbar' ) } info={ __( 'Access all block and document tools in a single place' @@ -85,18 +81,6 @@ export default function MoreMenu( { showIconLabels } ) { 'Top toolbar deactivated' ) } /> - <PreferenceToggleMenuItem - scope="core/edit-site" - name="focusMode" - label={ __( 'Spotlight mode' ) } - info={ __( 'Focus on one block at a time' ) } - messageActivated={ __( - 'Spotlight mode activated' - ) } - messageDeactivated={ __( - 'Spotlight mode deactivated' - ) } - /> <PreferenceToggleMenuItem scope="core/edit-site" name="distractionFree" @@ -113,6 +97,18 @@ export default function MoreMenu( { showIconLabels } ) { '\\' ) } /> + <PreferenceToggleMenuItem + scope="core/edit-site" + name="focusMode" + label={ __( 'Spotlight mode' ) } + info={ __( 'Focus on one block at a time' ) } + messageActivated={ __( + 'Spotlight mode activated' + ) } + messageDeactivated={ __( + 'Spotlight mode deactivated' + ) } + /> </MenuGroup> <ModeSwitcher /> <ActionItem.Slot diff --git a/packages/edit-site/src/components/header-edit-mode/style.scss b/packages/edit-site/src/components/header-edit-mode/style.scss index 78a988b2716ae..cbd0a7422b536 100644 --- a/packages/edit-site/src/components/header-edit-mode/style.scss +++ b/packages/edit-site/src/components/header-edit-mode/style.scss @@ -25,7 +25,9 @@ $header-toolbar-min-width: 335px; // is visible on toolbar buttons. height: 100%; // Allow focus ring to be fully visible on furthest right button. - padding-right: 2px; + @include break-medium() { + padding-right: 2px; + } } .edit-site-header-edit-mode__end { @@ -38,7 +40,7 @@ $header-toolbar-min-width: 335px; align-items: center; height: 100%; flex-grow: 1; - margin: 0 $grid-unit-10; + margin: 0 $grid-unit-20; justify-content: center; // Flex items will, by default, refuse to shrink below a minimum // intrinsic width. In order to shrink this flexbox item, and @@ -47,18 +49,16 @@ $header-toolbar-min-width: 335px; min-width: 0; } - .block-editor-block-contextual-toolbar.is-fixed { - border: none; - } } .edit-site-header-edit-mode__toolbar { display: flex; align-items: center; - padding-left: $grid-unit-10; + padding-left: $grid-unit-20; + gap: $grid-unit-10; - @include break-small() { - padding-left: $grid-unit-30; + @include break-medium() { + padding-left: $grid-unit-50 * 0.5; } @include break-wide() { @@ -66,12 +66,6 @@ $header-toolbar-min-width: 335px; } .edit-site-header-edit-mode__inserter-toggle { - margin-right: $grid-unit-10; - min-width: $grid-unit-40; - width: $grid-unit-40; - height: $grid-unit-40; - padding: 0; - svg { transition: transform cubic-bezier(0.165, 0.84, 0.44, 1) 0.2s; @include reduce-motion("transition"); @@ -92,17 +86,8 @@ $header-toolbar-min-width: 335px; .edit-site-header-edit-mode__actions { display: inline-flex; align-items: center; - padding-right: $grid-unit-05; - - @include break-small () { - padding-right: $grid-unit-20 - ($grid-unit-15 * 0.5); - } - - gap: $grid-unit-05; - - @include break-small() { - gap: $grid-unit-10; - } + padding-right: $grid-unit-10; + gap: $grid-unit-10; } .edit-site-header-edit-mode__preview-options { @@ -122,9 +107,12 @@ $header-toolbar-min-width: 335px; // here to the original button styles .edit-site-header-edit-mode__toolbar > .components-button.has-icon, .edit-site-header-edit-mode__toolbar > .components-dropdown > .components-button.has-icon { - height: $button-size; - min-width: $button-size; - padding: 6px; + // @todo: override toolbar group inherited paddings from components/block-tools/style.scss. + // This is best fixed by making the mover control area a proper single toolbar group. + // It needs specificity due to style inherited from .components-accessible-toolbar .components-button.has-icon.has-icon. + height: $button-size-compact; + min-width: $button-size-compact; + padding: 4px; &.is-pressed { background: $gray-900; @@ -132,7 +120,7 @@ $header-toolbar-min-width: 335px; &:focus:not(:disabled) { box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color), inset 0 0 0 $border-width $white; - outline: 1px solid transparent; + outline: $border-width solid transparent; } &::before { @@ -141,11 +129,10 @@ $header-toolbar-min-width: 335px; } .edit-site-header-edit-mode__toolbar > .edit-site-header-edit-mode__inserter-toggle.has-icon { - margin-right: $grid-unit-10; // Special dimensions for this button. - min-width: 32px; - width: 32px; - height: 32px; + min-width: $button-size-compact; + width: $button-size-compact; + height: $button-size-compact; padding: 0; } @@ -201,12 +188,50 @@ $header-toolbar-min-width: 335px; .edit-site-header-edit-mode__document-tools .edit-site-header-edit-mode__toolbar > * + * { margin-left: $grid-unit-10; } + + .block-editor-block-mover { + border-left: none; + + &::before { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + } } .has-fixed-toolbar { .selected-block-tools-wrapper { overflow-x: scroll; + .block-editor-block-contextual-toolbar { + border-bottom: 0; + } + + // Modified group borders + .components-toolbar-group, + .components-toolbar { + border-right: none; + + &::after { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + + & .components-toolbar-group.components-toolbar-group { + &::after { + display: none; + } + } + } + &.is-collapsed { display: none; } diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js index 9e4153938d40a..71d99b9a4bcbb 100644 --- a/packages/edit-site/src/components/layout/index.js +++ b/packages/edit-site/src/components/layout/index.js @@ -278,26 +278,27 @@ export default function Layout() { ariaLabel={ __( 'Navigation' ) } className="edit-site-layout__sidebar-region" > - <motion.div - // The sidebar is needed for routing on mobile - // (https://github.com/WordPress/gutenberg/pull/51558/files#r1231763003), - // so we can't remove the element entirely. Using `inert` will make - // it inaccessible to screen readers and keyboard navigation. - inert={ showSidebar ? undefined : 'true' } - animate={ { opacity: showSidebar ? 1 : 0 } } - transition={ { - type: 'tween', - duration: - // Disable transition in mobile to emulate a full page transition. - disableMotion || isMobileViewport - ? 0 - : ANIMATION_DURATION, - ease: 'easeOut', - } } - className="edit-site-layout__sidebar" - > - <Sidebar /> - </motion.div> + <AnimatePresence> + { showSidebar && ( + <motion.div + initial={ { opacity: 0 } } + animate={ { opacity: 1 } } + exit={ { opacity: 0 } } + transition={ { + type: 'tween', + duration: + // Disable transition in mobile to emulate a full page transition. + disableMotion || isMobileViewport + ? 0 + : ANIMATION_DURATION, + ease: 'easeOut', + } } + className="edit-site-layout__sidebar" + > + <Sidebar /> + </motion.div> + ) } + </AnimatePresence> </NavigableRegion> <SavePanel /> diff --git a/packages/edit-site/src/components/list/style.scss b/packages/edit-site/src/components/list/style.scss index cfc65252c8e68..4739243b7dc29 100644 --- a/packages/edit-site/src/components/list/style.scss +++ b/packages/edit-site/src/components/list/style.scss @@ -186,3 +186,8 @@ display: block; color: $gray-700; } + +.edit-site-list-title__customized-info { + font-size: 1.3em; + font-weight: 600; +} diff --git a/packages/edit-site/src/components/page-content-focus-notifications/back-to-page-notification.js b/packages/edit-site/src/components/page-content-focus-notifications/back-to-page-notification.js deleted file mode 100644 index 7cf963246bed8..0000000000000 --- a/packages/edit-site/src/components/page-content-focus-notifications/back-to-page-notification.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { useEffect, useRef } from '@wordpress/element'; -import { store as noticesStore } from '@wordpress/notices'; -import { __ } from '@wordpress/i18n'; -import { store as editorStore } from '@wordpress/editor'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../store'; - -/** - * Component that displays a 'You are editing a template' notification when the - * user switches from focusing on editing page content to editing a template. - */ -export default function BackToPageNotification() { - useBackToPageNotification(); - return null; -} - -/** - * Hook that displays a 'You are editing a template' notification when the user - * switches from focusing on editing page content to editing a template. - */ -export function useBackToPageNotification() { - const renderingMode = useSelect( - ( select ) => select( editorStore ).getRenderingMode(), - [] - ); - const { isPage } = useSelect( editSiteStore ); - const { setRenderingMode } = useDispatch( editorStore ); - const { createInfoNotice } = useDispatch( noticesStore ); - - const alreadySeen = useRef( false ); - - useEffect( () => { - if ( - isPage() && - ! alreadySeen.current && - renderingMode === 'template-only' - ) { - createInfoNotice( __( 'You are editing a template.' ), { - isDismissible: true, - type: 'snackbar', - actions: [ - { - label: __( 'Back to page' ), - onClick: () => setRenderingMode( 'template-locked' ), - }, - ], - } ); - alreadySeen.current = true; - } - }, [ isPage, renderingMode, createInfoNotice, setRenderingMode ] ); -} diff --git a/packages/edit-site/src/components/page-content-focus-notifications/index.js b/packages/edit-site/src/components/page-content-focus-notifications/index.js deleted file mode 100644 index 3f76c91eeadee..0000000000000 --- a/packages/edit-site/src/components/page-content-focus-notifications/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Internal dependencies - */ -import EditTemplateNotification from './edit-template-notification'; -import BackToPageNotification from './back-to-page-notification'; - -export default function PageContentFocusNotifications( { contentRef } ) { - return ( - <> - <EditTemplateNotification contentRef={ contentRef } /> - <BackToPageNotification /> - </> - ); -} diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 2d3a4c659f504..c92ce35ebe46d 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -12,14 +12,23 @@ import { useState, useMemo, useCallback, useEffect } from '@wordpress/element'; import { dateI18n, getDate, getSettings } from '@wordpress/date'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { useSelect, useDispatch } from '@wordpress/data'; +import { DataViews } from '@wordpress/dataviews'; /** * Internal dependencies */ import Page from '../page'; import Link from '../routes/link'; -import { DataViews, viewTypeSupportsMap } from '../dataviews'; import { default as DEFAULT_VIEWS } from '../sidebar-dataviews/default-views'; +import { + ENUMERATION_TYPE, + LAYOUT_GRID, + LAYOUT_TABLE, + LAYOUT_LIST, + OPERATOR_IN, + OPERATOR_NOT_IN, +} from '../../utils/constants'; + import { trashPostAction, usePermanentlyDeletePostAction, @@ -31,16 +40,19 @@ import { import SideEditor from './side-editor'; import Media from '../media'; import { unlock } from '../../lock-unlock'; -import { ENUMERATION_TYPE, OPERATOR_IN } from '../dataviews/constants'; const { useLocation } = unlock( routerPrivateApis ); const EMPTY_ARRAY = []; const defaultConfigPerViewType = { - list: {}, - grid: { + [ LAYOUT_TABLE ]: {}, + [ LAYOUT_GRID ]: { mediaField: 'featured-image', primaryField: 'title', }, + [ LAYOUT_LIST ]: { + primaryField: 'title', + mediaField: 'featured-image', + }, }; function useView( type ) { @@ -117,7 +129,10 @@ const DEFAULT_STATUSES = 'draft,future,pending,private,publish'; // All but 'tra export default function PagePages() { const postType = 'page'; const [ view, setView ] = useView( postType ); - const [ selection, setSelection ] = useState( [] ); + const [ pageId, setPageId ] = useState( null ); + + const onSelectionChange = ( items ) => + setPageId( items?.length === 1 ? items[ 0 ].id : null ); const queryArgs = useMemo( () => { const filters = {}; @@ -133,6 +148,11 @@ export default function PagePages() { filter.operator === OPERATOR_IN ) { filters.author = filter.value; + } else if ( + filter.field === 'author' && + filter.operator === OPERATOR_NOT_IN + ) { + filters.author_exclude = filter.value; } } ); // We want to provide a different default item for the status filter @@ -175,15 +195,15 @@ export default function PagePages() { id: 'featured-image', header: __( 'Featured Image' ), getValue: ( { item } ) => item.featured_media, - render: ( { item, view: currentView } ) => + render: ( { item } ) => !! item.featured_media ? ( <Media className="edit-site-page-pages__featured-image" id={ item.featured_media } size={ - currentView.type === 'list' - ? [ 'thumbnail', 'medium', 'large', 'full' ] - : [ 'large', 'full', 'medium', 'thumbnail' ] + view.type === LAYOUT_GRID + ? [ 'large', 'full', 'medium', 'thumbnail' ] + : [ 'thumbnail', 'medium', 'large', 'full' ] } /> ) : null, @@ -193,29 +213,29 @@ export default function PagePages() { header: __( 'Title' ), id: 'title', getValue: ( { item } ) => item.title?.rendered || item.slug, - render: ( { item, view: { type } } ) => { + render: ( { item } ) => { return ( <VStack spacing={ 1 }> - <Heading as="h3" level={ 5 }> - <Link - params={ { - postId: item.id, - postType: item.type, - canvas: 'edit', - } } - onClick={ ( event ) => { - if ( - viewTypeSupportsMap[ type ].preview - ) { - event.preventDefault(); - setSelection( [ item.id ] ); - } - } } - > - { decodeEntities( + <Heading as="h3" level={ 5 } weight={ 500 }> + { [ LAYOUT_TABLE, LAYOUT_GRID ].includes( + view.type + ) ? ( + <Link + params={ { + postId: item.id, + postType: item.type, + canvas: 'edit', + } } + > + { decodeEntities( + item.title?.rendered || item.slug + ) || __( '(no title)' ) } + </Link> + ) : ( + decodeEntities( item.title?.rendered || item.slug - ) || __( '(no title)' ) } - </Link> + ) || __( '(no title)' ) + ) } </Heading> </VStack> ); @@ -243,6 +263,9 @@ export default function PagePages() { type: ENUMERATION_TYPE, elements: STATUSES, enableSorting: false, + filterBy: { + operators: [ OPERATOR_IN ], + }, }, { header: __( 'Date' ), @@ -257,7 +280,7 @@ export default function PagePages() { }, }, ], - [ authors ] + [ authors, view ] ); const permanentlyDeletePostAction = usePermanentlyDeletePostAction(); @@ -307,18 +330,19 @@ export default function PagePages() { isLoading={ isLoadingPages || isLoadingAuthors } view={ view } onChangeView={ onChangeView } + onSelectionChange={ onSelectionChange } + deferredRendering={ false } /> </Page> - { viewTypeSupportsMap[ view.type ].preview && ( + { view.type === LAYOUT_LIST && ( <Page> <div className="edit-site-page-pages-preview"> - { selection.length === 1 && ( + { pageId !== null ? ( <SideEditor - postId={ selection[ 0 ] } + postId={ pageId } postType={ postType } /> - ) } - { selection.length !== 1 && ( + ) : ( <div style={ { display: 'flex', diff --git a/packages/edit-site/src/components/page-pages/style.scss b/packages/edit-site/src/components/page-pages/style.scss index fde960ca1a72c..933fdadb8d070 100644 --- a/packages/edit-site/src/components/page-pages/style.scss +++ b/packages/edit-site/src/components/page-pages/style.scss @@ -1,3 +1,5 @@ .edit-site-page-pages__featured-image { - border-radius: $radius-block-ui; + border-radius: $grid-unit-05; + width: $grid-unit-40; + height: $grid-unit-40; } diff --git a/packages/edit-site/src/components/page-patterns/patterns-list.js b/packages/edit-site/src/components/page-patterns/patterns-list.js index eb56fdded9060..6015fbbf4caf3 100644 --- a/packages/edit-site/src/components/page-patterns/patterns-list.js +++ b/packages/edit-site/src/components/page-patterns/patterns-list.js @@ -15,7 +15,11 @@ import { import { __, _x, isRTL } from '@wordpress/i18n'; import { chevronLeft, chevronRight } from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; -import { useAsyncList, useViewportMatch } from '@wordpress/compose'; +import { + useAsyncList, + useViewportMatch, + useDebouncedInput, +} from '@wordpress/compose'; /** * Internal dependencies @@ -25,7 +29,6 @@ import Grid from './grid'; import NoPatterns from './no-patterns'; import usePatterns from './use-patterns'; import SidebarButton from '../sidebar-button'; -import useDebouncedInput from '../../utils/use-debounced-input'; import { unlock } from '../../lock-unlock'; import { PATTERN_SYNC_TYPES, PATTERN_TYPES } from '../../utils/constants'; import Pagination from './pagination'; diff --git a/packages/edit-site/src/components/page-patterns/rename-menu-item.js b/packages/edit-site/src/components/page-patterns/rename-menu-item.js index 8b1d6771c2e90..c2b3b960fb667 100644 --- a/packages/edit-site/src/components/page-patterns/rename-menu-item.js +++ b/packages/edit-site/src/components/page-patterns/rename-menu-item.js @@ -96,6 +96,7 @@ export default function RenameMenuItem( { item, onClose } ) { <VStack spacing="5"> <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Name' ) } value={ title } onChange={ setTitle } @@ -104,6 +105,7 @@ export default function RenameMenuItem( { item, onClose } ) { <HStack justify="right"> <Button + __next40pxDefaultSize variant="tertiary" onClick={ () => { setIsModalOpen( false ); @@ -113,7 +115,11 @@ export default function RenameMenuItem( { item, onClose } ) { { __( 'Cancel' ) } </Button> - <Button variant="primary" type="submit"> + <Button + __next40pxDefaultSize + variant="primary" + type="submit" + > { __( 'Save' ) } </Button> </HStack> diff --git a/packages/edit-site/src/components/page-templates/dataviews-templates.js b/packages/edit-site/src/components/page-templates/dataviews-templates.js index 7dab7192779c8..07de0cb73ff44 100644 --- a/packages/edit-site/src/components/page-templates/dataviews-templates.js +++ b/packages/edit-site/src/components/page-templates/dataviews-templates.js @@ -8,7 +8,7 @@ import removeAccents from 'remove-accents'; */ import { Icon, - __experimentalHeading as Heading, + __experimentalView as View, __experimentalText as Text, __experimentalHStack as HStack, __experimentalVStack as VStack, @@ -23,6 +23,7 @@ import { BlockPreview, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; +import { DataViews } from '@wordpress/dataviews'; /** * Internal dependencies @@ -30,9 +31,14 @@ import { import Page from '../page'; import Link from '../routes/link'; import { useAddedBy, AvatarImage } from '../list/added-by'; -import { TEMPLATE_POST_TYPE } from '../../utils/constants'; -import { DataViews } from '../dataviews'; -import { ENUMERATION_TYPE, OPERATOR_IN } from '../dataviews/constants'; +import { + TEMPLATE_POST_TYPE, + ENUMERATION_TYPE, + OPERATOR_IN, + OPERATOR_NOT_IN, + LAYOUT_GRID, + LAYOUT_TABLE, +} from '../../utils/constants'; import { useResetTemplateAction, deleteTemplateAction, @@ -48,15 +54,15 @@ const { ExperimentalBlockEditorProvider, useGlobalStyle } = unlock( const EMPTY_ARRAY = []; const defaultConfigPerViewType = { - list: {}, - grid: { + [ LAYOUT_TABLE ]: {}, + [ LAYOUT_GRID ]: { mediaField: 'preview', primaryField: 'title', }, }; const DEFAULT_VIEW = { - type: 'list', + type: LAYOUT_TABLE, search: '', page: 1, perPage: 20, @@ -77,7 +83,7 @@ function TemplateTitle( { item } ) { const { isCustomized } = useAddedBy( item.type, item.id ); return ( <VStack spacing={ 1 }> - <Heading as="h3" level={ 5 }> + <View as="span" className="edit-site-list-title__customized-info"> <Link params={ { postId: item.id, @@ -88,7 +94,7 @@ function TemplateTitle( { item } ) { { decodeEntities( item.title?.rendered || item.slug ) || __( '(no title)' ) } </Link> - </Heading> + </View> { isCustomized && ( <span className="edit-site-list-added-by__customized-info"> { item.type === TEMPLATE_POST_TYPE @@ -170,11 +176,11 @@ export default function DataviewsTemplates() { { header: __( 'Preview' ), id: 'preview', - render: ( { item, view: { type: viewType } } ) => { + render: ( { item } ) => { return ( <TemplatePreview content={ item.content.raw } - viewType={ viewType } + viewType={ view.type } /> ); }, @@ -223,7 +229,7 @@ export default function DataviewsTemplates() { elements: authors, }, ], - [ authors ] + [ authors, view ] ); const { shownTemplates, paginationInfo } = useMemo( () => { @@ -261,6 +267,14 @@ export default function DataviewsTemplates() { filteredTemplates = filteredTemplates.filter( ( item ) => { return item.author_text === filter.value; } ); + } else if ( + filter.field === 'author' && + filter.operator === OPERATOR_NOT_IN && + !! filter.value + ) { + filteredTemplates = filteredTemplates.filter( ( item ) => { + return item.author_text !== filter.value; + } ); } } ); } @@ -338,7 +352,8 @@ export default function DataviewsTemplates() { isLoading={ isLoadingData } view={ view } onChangeView={ onChangeView } - supportedLayouts={ [ 'list', 'grid' ] } + supportedLayouts={ [ LAYOUT_TABLE, LAYOUT_GRID ] } + deferredRendering={ ! view.hiddenFields?.includes( 'preview' ) } /> </Page> ); diff --git a/packages/edit-site/src/components/page/header.js b/packages/edit-site/src/components/page/header.js index 06de80c25685b..274fd395a16f1 100644 --- a/packages/edit-site/src/components/page/header.js +++ b/packages/edit-site/src/components/page/header.js @@ -19,7 +19,8 @@ export default function Header( { title, subTitle, actions } ) { <FlexBlock className="edit-site-page-header__page-title"> <Heading as="h2" - level={ 4 } + level={ 3 } + weight={ 500 } className="edit-site-page-header__title" > { title } diff --git a/packages/edit-site/src/components/page/style.scss b/packages/edit-site/src/components/page/style.scss index 8da7df8e0385b..72ecbb4e2b7d7 100644 --- a/packages/edit-site/src/components/page/style.scss +++ b/packages/edit-site/src/components/page/style.scss @@ -12,8 +12,8 @@ } .edit-site-page-header { - padding: 0 $grid-unit-40; - min-height: $header-height; + padding: $grid-unit-20 $grid-unit-40; + min-height: $grid-unit * 9; border-bottom: 1px solid $gray-100; background: $white; position: sticky; diff --git a/packages/edit-site/src/components/preferences-modal/index.js b/packages/edit-site/src/components/preferences-modal/index.js index 8439661a7910b..b0d065c0cfa5f 100644 --- a/packages/edit-site/src/components/preferences-modal/index.js +++ b/packages/edit-site/src/components/preferences-modal/index.js @@ -41,37 +41,16 @@ export default function EditSitePreferencesModal() { } ); }; + const turnOffDistractionFree = () => { + setPreference( 'core/edit-site', 'distractionFree', false ); + }; + const sections = useMemo( () => [ { name: 'general', tabLabel: __( 'General' ), content: ( - <PreferencesModalSection - title={ __( 'Appearance' ) } - description={ __( - 'Customize options related to the block editor interface and editing flow.' - ) } - > - <EnableFeature - featureName="distractionFree" - onToggle={ toggleDistractionFree } - help={ __( - 'Reduce visual distractions by hiding the toolbar and other elements to focus on writing.' - ) } - label={ __( 'Distraction free' ) } - /> - <EnableFeature - featureName="focusMode" - help={ __( - 'Highlights the current block and fades other content.' - ) } - label={ __( 'Spotlight mode' ) } - /> - <EnableFeature - featureName="showIconLabels" - label={ __( 'Show button text labels' ) } - help={ __( 'Show text instead of icons on buttons.' ) } - /> + <PreferencesModalSection title={ __( 'Interface' ) }> <EnableFeature featureName="showListViewByDefault" help={ __( @@ -90,25 +69,72 @@ export default function EditSitePreferencesModal() { ), }, { - name: 'blocks', - tabLabel: __( 'Blocks' ), + name: 'appearance', + tabLabel: __( 'Appearance' ), content: ( <PreferencesModalSection - title={ __( 'Block interactions' ) } + title={ __( 'Appearance' ) } description={ __( - 'Customize how you interact with blocks in the block library and editing canvas.' + 'Customize the editor interface to suit your needs.' ) } > <EnableFeature - featureName="keepCaretInsideBlock" + featureName="fixedToolbar" + onToggle={ turnOffDistractionFree } + help={ __( + 'Access all block and document tools in a single place.' + ) } + label={ __( 'Top toolbar' ) } + /> + <EnableFeature + featureName="distractionFree" + onToggle={ toggleDistractionFree } + help={ __( + 'Reduce visual distractions by hiding the toolbar and other elements to focus on writing.' + ) } + label={ __( 'Distraction free' ) } + /> + <EnableFeature + featureName="focusMode" help={ __( - 'Aids screen readers by stopping text caret from leaving blocks.' + 'Highlights the current block and fades other content.' ) } - label={ __( 'Contain text cursor inside block' ) } + label={ __( 'Spotlight mode' ) } /> </PreferencesModalSection> ), }, + { + name: 'accessibility', + tabLabel: __( 'Accessibility' ), + content: ( + <> + <PreferencesModalSection + title={ __( 'Navigation' ) } + description={ __( + 'Optimize the editing experience for enhanced control.' + ) } + > + <EnableFeature + featureName="keepCaretInsideBlock" + help={ __( + 'Keeps the text cursor within the block boundaries, aiding users with screen readers by preventing unintentional cursor movement outside the block.' + ) } + label={ __( 'Contain text cursor inside block' ) } + /> + </PreferencesModalSection> + <PreferencesModalSection title={ __( 'Interface' ) }> + <EnableFeature + featureName="showIconLabels" + label={ __( 'Show button text labels' ) } + help={ __( + 'Show text instead of icons on buttons across the interface.' + ) } + /> + </PreferencesModalSection> + </> + ), + }, ] ); if ( ! isModalActive ) { return null; diff --git a/packages/edit-site/src/components/routes/use-title.js b/packages/edit-site/src/components/routes/use-title.js index 6d06c593dd253..775f365977874 100644 --- a/packages/edit-site/src/components/routes/use-title.js +++ b/packages/edit-site/src/components/routes/use-title.js @@ -38,8 +38,8 @@ export default function useTitle( title ) { if ( title && siteTitle ) { // @see https://github.com/WordPress/wordpress-develop/blob/94849898192d271d533e09756007e176feb80697/src/wp-admin/admin-header.php#L67-L68 const formattedTitle = sprintf( - /* translators: Admin screen title. 1: Admin screen name, 2: Network or site name. */ - __( '%1$s ‹ %2$s — WordPress' ), + /* translators: Admin document title. 1: Admin screen name, 2: Network or site name. */ + __( '%1$s ‹ %2$s ‹ Editor — WordPress' ), decodeEntities( title ), decodeEntities( siteTitle ) ); @@ -47,14 +47,7 @@ export default function useTitle( title ) { document.title = formattedTitle; // Announce title on route change for screen readers. - speak( - sprintf( - /* translators: The page title that is currently displaying. */ - __( 'Now displaying: %s' ), - document.title - ), - 'assertive' - ); + speak( title, 'assertive' ); } }, [ title, siteTitle, location ] ); } diff --git a/packages/edit-site/src/components/save-button/index.js b/packages/edit-site/src/components/save-button/index.js index c2a213cc3e364..d4c2969920c52 100644 --- a/packages/edit-site/src/components/save-button/index.js +++ b/packages/edit-site/src/components/save-button/index.js @@ -103,6 +103,7 @@ export default function SaveButton( { showTooltip={ showTooltip } icon={ icon } __next40pxDefaultSize={ __next40pxDefaultSize } + size="compact" > { label } </Button> diff --git a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js index c6d7bbe4a231b..cbcb4f2f8ed59 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js +++ b/packages/edit-site/src/components/sidebar-dataviews/dataview-item.js @@ -6,9 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { page, columns, pullRight } from '@wordpress/icons'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { __experimentalHStack as HStack } from '@wordpress/components'; +import { VIEW_LAYOUTS } from '@wordpress/dataviews'; /** * Internal dependencies @@ -18,11 +18,6 @@ import SidebarNavigationItem from '../sidebar-navigation-item'; import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); -function getDataViewIcon( type ) { - const icons = { list: page, grid: columns, 'side-by-side': pullRight }; - return icons[ type ]; -} - export default function DataViewItem( { title, slug, @@ -37,7 +32,8 @@ export default function DataViewItem( { params: { path }, } = useLocation(); - const iconToUse = icon || getDataViewIcon( type ); + const iconToUse = + icon || VIEW_LAYOUTS.find( ( v ) => v.type === type ).icon; const linkInfo = useLink( { path, diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index d853b6cde112f..11652286e62d8 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -7,10 +7,10 @@ import { trash } from '@wordpress/icons'; /** * Internal dependencies */ -import { OPERATOR_IN } from '../dataviews/constants'; +import { LAYOUT_TABLE, OPERATOR_IN } from '../../utils/constants'; const DEFAULT_PAGE_BASE = { - type: 'list', + type: LAYOUT_TABLE, search: '', filters: [], page: 1, diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js deleted file mode 100644 index a11d119e1cecb..0000000000000 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * WordPress dependencies - */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { decodeEntities } from '@wordpress/html-entities'; -import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { store as coreStore } from '@wordpress/core-data'; -import { check } from '@wordpress/icons'; -import { - privateApis as editorPrivateApis, - store as editorStore, -} from '@wordpress/editor'; - -/** - * Internal dependencies - */ -import { store as editSiteStore } from '../../../store'; -import SwapTemplateButton from './swap-template-button'; -import ResetDefaultTemplate from './reset-default-template'; -import { unlock } from '../../../lock-unlock'; - -const { PostPanelRow } = unlock( editorPrivateApis ); - -const POPOVER_PROPS = { - className: 'edit-site-page-panels-edit-template__dropdown', - placement: 'bottom-start', -}; - -export default function EditTemplate() { - const { hasResolved, template, isTemplateHidden } = useSelect( - ( select ) => { - const { getEditedPostContext, getEditedPostType, getEditedPostId } = - select( editSiteStore ); - const { getRenderingMode } = unlock( select( editorStore ) ); - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); - const _context = getEditedPostContext(); - const _postType = getEditedPostType(); - const queryArgs = [ 'postType', _postType, getEditedPostId() ]; - return { - context: _context, - hasResolved: hasFinishedResolution( - 'getEditedEntityRecord', - queryArgs - ), - template: getEditedEntityRecord( ...queryArgs ), - isTemplateHidden: getRenderingMode() === 'post-only', - postType: _postType, - }; - }, - [] - ); - - const { setRenderingMode } = useDispatch( editorStore ); - - if ( ! hasResolved ) { - return null; - } - - return ( - <PostPanelRow label={ __( 'Template' ) }> - <DropdownMenu - popoverProps={ POPOVER_PROPS } - focusOnMount - toggleProps={ { - variant: 'tertiary', - className: 'edit-site-summary-field__trigger', - } } - label={ __( 'Template options' ) } - text={ decodeEntities( template.title ) } - icon={ null } - > - { ( { onClose } ) => ( - <> - <MenuGroup> - <MenuItem - onClick={ () => { - setRenderingMode( 'template-only' ); - onClose(); - } } - > - { __( 'Edit template' ) } - </MenuItem> - <SwapTemplateButton onClick={ onClose } /> - </MenuGroup> - <ResetDefaultTemplate onClick={ onClose } /> - <MenuGroup> - <MenuItem - icon={ ! isTemplateHidden ? check : undefined } - isPressed={ ! isTemplateHidden } - onClick={ () => { - setRenderingMode( - isTemplateHidden - ? 'template-locked' - : 'post-only' - ); - } } - > - { __( 'Template preview' ) } - </MenuItem> - </MenuGroup> - </> - ) } - </DropdownMenu> - </PostPanelRow> - ); -} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js index df59dffe66be6..bbf4b55c05287 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js @@ -12,6 +12,7 @@ import { humanTimeDiff } from '@wordpress/date'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies @@ -22,28 +23,39 @@ import PageContent from './page-content'; import PageSummary from './page-summary'; export default function PagePanels() { - const { id, type, hasResolved, status, date, password, title, modified } = - useSelect( ( select ) => { - const { getEditedPostContext } = select( editSiteStore ); - const { getEditedEntityRecord, hasFinishedResolution } = - select( coreStore ); - const context = getEditedPostContext(); - const queryArgs = [ 'postType', context.postType, context.postId ]; - const page = getEditedEntityRecord( ...queryArgs ); - return { - hasResolved: hasFinishedResolution( - 'getEditedEntityRecord', - queryArgs - ), - title: page?.title, - id: page?.id, - type: page?.type, - status: page?.status, - date: page?.date, - password: page?.password, - modified: page?.modified, - }; - }, [] ); + const { + id, + type, + hasResolved, + status, + date, + password, + title, + modified, + renderingMode, + } = useSelect( ( select ) => { + const { getEditedPostContext } = select( editSiteStore ); + const { getEditedEntityRecord, hasFinishedResolution } = + select( coreStore ); + const { getRenderingMode } = select( editorStore ); + const context = getEditedPostContext(); + const queryArgs = [ 'postType', context.postType, context.postId ]; + const page = getEditedEntityRecord( ...queryArgs ); + return { + hasResolved: hasFinishedResolution( + 'getEditedEntityRecord', + queryArgs + ), + title: page?.title, + id: page?.id, + type: page?.type, + status: page?.status, + date: page?.date, + password: page?.password, + modified: page?.modified, + renderingMode: getRenderingMode(), + }; + }, [] ); if ( ! hasResolved ) { return null; @@ -77,9 +89,11 @@ export default function PagePanels() { postType={ type } /> </PanelBody> - <PanelBody title={ __( 'Content' ) }> - <PageContent /> - </PanelBody> + { renderingMode !== 'post-only' && ( + <PanelBody title={ __( 'Content' ) }> + <PageContent /> + </PanelBody> + ) } </> ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js index 0219b568e57c5..fd10946fd989d 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js @@ -6,13 +6,13 @@ import { PostAuthorPanel, PostURLPanel, PostSchedulePanel, + PostTemplatePanel, } from '@wordpress/editor'; /** * Internal dependencies */ import PageStatus from './page-status'; -import EditTemplate from './edit-template'; export default function PageSummary( { status, @@ -31,7 +31,7 @@ export default function PageSummary( { postType={ postType } /> <PostSchedulePanel /> - <EditTemplate /> + <PostTemplatePanel /> <PostURLPanel /> <PostAuthorPanel /> </VStack> diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss index f3da54c244bd1..f05a3e6fe1deb 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss @@ -1,39 +1,9 @@ -.edit-site-swap-template-modal { - z-index: z-index(".edit-site-swap-template-modal"); -} + .edit-site-page-panels__swap-template__confirm-modal__actions { margin-top: $grid-unit-30; } -.edit-site-page-panels__swap-template__modal-content .block-editor-block-patterns-list { - column-count: 2; - column-gap: $grid-unit-30; - - // Small top padding required to avoid cutting off the visible outline when hovering items - padding-top: $border-width-focus-fallback; - - @include break-medium() { - column-count: 3; - } - - @include break-wide() { - column-count: 4; - } - - .block-editor-block-patterns-list__list-item { - break-inside: avoid-column; - } - - .block-editor-block-patterns-list__item { - // Avoid to override the BlockPatternList component - // default hover and focus styles. - &:not(:focus):not(:hover) .block-editor-block-preview__container { - box-shadow: 0 0 0 1px $gray-300; - } - } -} - .edit-site-change-status__content { .components-popover__content { min-width: 320px; @@ -69,14 +39,3 @@ overflow: hidden; text-overflow: ellipsis; } - -.edit-site-page-panels-edit-template__dropdown { - .components-popover__content { - min-width: 240px; - } - .components-button.is-pressed, - .components-button.is-pressed:hover { - background: inherit; - color: inherit; - } -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js index 668179755ec35..a2281804bcb72 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-navigation-menu/rename-modal.js @@ -27,16 +27,22 @@ export default function RenameModal( { menuTitle, onClose, onSave } ) { <VStack spacing="3"> <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize value={ editedMenuTitle } placeholder={ __( 'Navigation title' ) } onChange={ setEditedMenuTitle } /> <HStack justify="right"> - <Button variant="tertiary" onClick={ onClose }> + <Button + __next40pxDefaultSize + variant="tertiary" + onClick={ onClose } + > { __( 'Cancel' ) } </Button> <Button + __next40pxDefaultSize disabled={ ! isEditedMenuTitleValid } variant="primary" type="submit" diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js index 4dee1bae3f314..4d80d838ae90d 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-patterns/use-pattern-categories.js @@ -95,7 +95,7 @@ export default function usePatternCategories() { sortedCategories.unshift( { name: PATTERN_DEFAULT_CATEGORY, label: __( 'All patterns' ), - description: __( 'A list of all patterns from all sources' ), + description: __( 'A list of all patterns from all sources.' ), count: themePatterns.length + userPatterns.length, } ); diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js index c798067aca9c5..57a1337761753 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/home-template-details.js @@ -9,12 +9,8 @@ import { CheckboxControl, __experimentalInputControl as InputControl, __experimentalNumberControl as NumberControl, - __experimentalTruncate as Truncate, - __experimentalItemGroup as ItemGroup, } from '@wordpress/components'; -import { header, footer, layout } from '@wordpress/icons'; -import { useMemo, useState, useEffect } from '@wordpress/element'; -import { decodeEntities } from '@wordpress/html-entities'; +import { useState, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -23,58 +19,19 @@ import { SidebarNavigationScreenDetailsPanel, SidebarNavigationScreenDetailsPanelRow, } from '../sidebar-navigation-screen-details-panel'; -import { unlock } from '../../lock-unlock'; -import { store as editSiteStore } from '../../store'; -import { useLink } from '../routes/link'; -import SidebarNavigationItem from '../sidebar-navigation-item'; -import { TEMPLATE_PART_POST_TYPE } from '../../utils/constants'; const EMPTY_OBJECT = {}; -function TemplateAreaButton( { postId, icon, title } ) { - const icons = { - header, - footer, - }; - const linkInfo = useLink( { - postType: TEMPLATE_PART_POST_TYPE, - postId, - } ); - - return ( - <SidebarNavigationItem - className="edit-site-sidebar-navigation-screen-template__template-area-button" - { ...linkInfo } - icon={ icons[ icon ] ?? layout } - withChevron - > - <Truncate - limit={ 20 } - ellipsizeMode="tail" - numberOfLines={ 1 } - className="edit-site-sidebar-navigation-screen-template__template-area-label-text" - > - { decodeEntities( title ) } - </Truncate> - </SidebarNavigationItem> - ); -} - export default function HomeTemplateDetails() { const { editEntityRecord } = useDispatch( coreStore ); const { allowCommentsOnNewPosts, - templatePartAreas, postsPerPage, postsPageTitle, postsPageId, - currentTemplateParts, } = useSelect( ( select ) => { const { getEntityRecord } = select( coreStore ); - const { getSettings, getCurrentTemplateTemplateParts } = unlock( - select( editSiteStore ) - ); const siteSettings = getEntityRecord( 'root', 'site' ); const _postsPageRecord = siteSettings?.page_for_posts ? getEntityRecord( @@ -90,8 +47,6 @@ export default function HomeTemplateDetails() { postsPageTitle: _postsPageRecord?.title?.rendered, postsPageId: _postsPageRecord?.id, postsPerPage: siteSettings?.posts_per_page, - templatePartAreas: getSettings()?.defaultTemplatePartAreas, - currentTemplateParts: getCurrentTemplateTemplateParts(), }; }, [] ); @@ -111,36 +66,6 @@ export default function HomeTemplateDetails() { setPostsCountValue( postsPerPage ); }, [ postsPageTitle, allowCommentsOnNewPosts, postsPerPage ] ); - /* - * Merge data in currentTemplateParts with templatePartAreas, - * which contains the template icon and fallback labels - */ - const templateAreas = useMemo( () => { - // Keep track of template part IDs that have already been added to the array. - const templatePartIds = new Set(); - const filterOutDuplicateTemplateParts = ( currentTemplatePart ) => { - // If the template part has already been added to the array, skip it. - if ( templatePartIds.has( currentTemplatePart.templatePart.id ) ) { - return; - } - // Add to the array of template part IDs. - templatePartIds.add( currentTemplatePart.templatePart.id ); - return currentTemplatePart; - }; - - return currentTemplateParts.length && templatePartAreas - ? currentTemplateParts - .filter( filterOutDuplicateTemplateParts ) - .map( ( { templatePart, block } ) => ( { - ...templatePartAreas?.find( - ( { area } ) => area === templatePart?.area - ), - ...templatePart, - clientId: block.clientId, - } ) ) - : []; - }, [ currentTemplateParts, templatePartAreas ] ); - const setAllowCommentsOnNewPosts = ( newValue ) => { setCommentsOnNewPostsValue( newValue ); editEntityRecord( 'root', 'site', undefined, { @@ -214,26 +139,6 @@ export default function HomeTemplateDetails() { /> </SidebarNavigationScreenDetailsPanelRow> </SidebarNavigationScreenDetailsPanel> - <SidebarNavigationScreenDetailsPanel - title={ __( 'Areas' ) } - spacing={ 3 } - > - <ItemGroup> - { templateAreas.map( - ( { clientId, label, icon, theme, slug, title } ) => ( - <SidebarNavigationScreenDetailsPanelRow - key={ clientId } - > - <TemplateAreaButton - postId={ `${ theme }//${ slug }` } - title={ title?.rendered || label } - icon={ icon } - /> - </SidebarNavigationScreenDetailsPanelRow> - ) - ) } - </ItemGroup> - </SidebarNavigationScreenDetailsPanel> </> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js index fd60d0a509ace..e6ead651fbbfc 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/index.js @@ -12,6 +12,7 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ +import TemplateAreas from './template-areas'; import SidebarNavigationScreen from '../sidebar-navigation-screen'; import useEditedEntityRecord from '../use-edited-entity-record'; import { unlock } from '../../lock-unlock'; @@ -45,8 +46,13 @@ function useTemplateDetails( postType, postId ) { const content = record?.slug === 'home' || record?.slug === 'index' ? ( - <HomeTemplateDetails /> - ) : null; + <> + <HomeTemplateDetails /> + <TemplateAreas /> + </> + ) : ( + <TemplateAreas /> + ); const footer = record?.modified ? ( <SidebarNavigationScreenDetailsFooter record={ record } /> diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template/template-areas.js b/packages/edit-site/src/components/sidebar-navigation-screen-template/template-areas.js new file mode 100644 index 0000000000000..db59f6886124b --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-template/template-areas.js @@ -0,0 +1,135 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { + __experimentalTruncate as Truncate, + __experimentalItemGroup as ItemGroup, +} from '@wordpress/components'; +import { store as editorStore } from '@wordpress/editor'; +import { useMemo } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { + SidebarNavigationScreenDetailsPanel, + SidebarNavigationScreenDetailsPanelRow, +} from '../sidebar-navigation-screen-details-panel'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import { useLink } from '../routes/link'; +import SidebarNavigationItem from '../sidebar-navigation-item'; +import { TEMPLATE_PART_POST_TYPE } from '../../utils/constants'; + +function TemplateAreaButton( { postId, area, title } ) { + const templatePartArea = useSelect( + ( select ) => { + const defaultAreas = + select( + editorStore + ).__experimentalGetDefaultTemplatePartAreas(); + + return defaultAreas.find( + ( defaultArea ) => defaultArea.area === area + ); + }, + [ area ] + ); + const linkInfo = useLink( { + postType: TEMPLATE_PART_POST_TYPE, + postId, + } ); + + return ( + <SidebarNavigationItem + className="edit-site-sidebar-navigation-screen-template__template-area-button" + { ...linkInfo } + icon={ templatePartArea?.icon } + withChevron + > + <Truncate + limit={ 20 } + ellipsizeMode="tail" + numberOfLines={ 1 } + className="edit-site-sidebar-navigation-screen-template__template-area-label-text" + > + { decodeEntities( title ) } + </Truncate> + </SidebarNavigationItem> + ); +} + +export default function TemplateAreas() { + const { templatePartAreas, currentTemplateParts } = useSelect( + ( select ) => { + const { getSettings, getCurrentTemplateTemplateParts } = unlock( + select( editSiteStore ) + ); + return { + templatePartAreas: getSettings()?.defaultTemplatePartAreas, + currentTemplateParts: getCurrentTemplateTemplateParts(), + }; + }, + [] + ); + + /* + * Merge data in currentTemplateParts with templatePartAreas, + * which contains the template icon and fallback labels + */ + const templateAreas = useMemo( () => { + // Keep track of template part IDs that have already been added to the array. + const templatePartIds = new Set(); + const filterOutDuplicateTemplateParts = ( currentTemplatePart ) => { + // If the template part has already been added to the array, skip it. + if ( templatePartIds.has( currentTemplatePart.templatePart.id ) ) { + return; + } + // Add to the array of template part IDs. + templatePartIds.add( currentTemplatePart.templatePart.id ); + return currentTemplatePart; + }; + + return currentTemplateParts.length && templatePartAreas + ? currentTemplateParts + .filter( filterOutDuplicateTemplateParts ) + .map( ( { templatePart, block } ) => ( { + ...templatePartAreas?.find( + ( { area } ) => area === templatePart?.area + ), + ...templatePart, + clientId: block.clientId, + } ) ) + : []; + }, [ currentTemplateParts, templatePartAreas ] ); + + if ( ! templateAreas.length ) { + return null; + } + + return ( + <SidebarNavigationScreenDetailsPanel + title={ __( 'Areas' ) } + spacing={ 3 } + > + <ItemGroup> + { templateAreas.map( + ( { clientId, label, area, theme, slug, title } ) => ( + <SidebarNavigationScreenDetailsPanelRow + key={ clientId } + > + <TemplateAreaButton + postId={ `${ theme }//${ slug }` } + title={ title?.rendered || label } + area={ area } + /> + </SidebarNavigationScreenDetailsPanelRow> + ) + ) } + </ItemGroup> + </SidebarNavigationScreenDetailsPanel> + ); +} diff --git a/packages/edit-site/src/components/sidebar/index.js b/packages/edit-site/src/components/sidebar/index.js index b66bf4390a6bc..3fa1280d59f42 100644 --- a/packages/edit-site/src/components/sidebar/index.js +++ b/packages/edit-site/src/components/sidebar/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + /** * WordPress dependencies */ @@ -33,53 +38,65 @@ import DataViewsSidebarContent from '../sidebar-dataviews'; const { useLocation } = unlock( routerPrivateApis ); +function SidebarScreenWrapper( { className, ...props } ) { + return ( + <NavigatorScreen + className={ classNames( + 'edit-site-sidebar__screen-wrapper', + className + ) } + { ...props } + /> + ); +} + function SidebarScreens() { useSyncPathWithURL(); return ( <> - <NavigatorScreen path="/"> + <SidebarScreenWrapper path="/"> <SidebarNavigationScreenMain /> - </NavigatorScreen> - <NavigatorScreen path="/navigation"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/navigation"> <SidebarNavigationScreenNavigationMenus /> - </NavigatorScreen> - <NavigatorScreen path="/navigation/:postType/:postId"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/navigation/:postType/:postId"> <SidebarNavigationScreenNavigationMenu /> - </NavigatorScreen> - <NavigatorScreen path="/wp_global_styles"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/wp_global_styles"> <SidebarNavigationScreenGlobalStyles /> - </NavigatorScreen> - <NavigatorScreen path="/page"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/page"> <SidebarNavigationScreenPages /> - </NavigatorScreen> - <NavigatorScreen path="/page/:postId"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/page/:postId"> <SidebarNavigationScreenPage /> - </NavigatorScreen> + </SidebarScreenWrapper> { window?.__experimentalAdminViews && ( - <NavigatorScreen path="/pages"> + <SidebarScreenWrapper path="/pages"> <SidebarNavigationScreen title={ __( 'Pages' ) } backPath="/page" content={ <DataViewsSidebarContent /> } /> - </NavigatorScreen> + </SidebarScreenWrapper> ) } - <NavigatorScreen path="/:postType(wp_template)"> + <SidebarScreenWrapper path="/:postType(wp_template)"> <SidebarNavigationScreenTemplates /> - </NavigatorScreen> - <NavigatorScreen path="/patterns"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/patterns"> <SidebarNavigationScreenPatterns /> - </NavigatorScreen> - <NavigatorScreen path="/:postType(wp_template|wp_template_part)/all"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/:postType(wp_template|wp_template_part)/all"> <SidebarNavigationScreenTemplatesBrowse /> - </NavigatorScreen> - <NavigatorScreen path="/:postType(wp_template_part|wp_block)/:postId"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/:postType(wp_template_part|wp_block)/:postId"> <SidebarNavigationScreenPattern /> - </NavigatorScreen> - <NavigatorScreen path="/:postType(wp_template)/:postId"> + </SidebarScreenWrapper> + <SidebarScreenWrapper path="/:postType(wp_template)/:postId"> <SidebarNavigationScreenTemplate /> - </NavigatorScreen> + </SidebarScreenWrapper> </> ); } diff --git a/packages/edit-site/src/components/sidebar/style.scss b/packages/edit-site/src/components/sidebar/style.scss index 9a3644cc830d5..ef24b0d4b8cf6 100644 --- a/packages/edit-site/src/components/sidebar/style.scss +++ b/packages/edit-site/src/components/sidebar/style.scss @@ -1,14 +1,17 @@ .edit-site-sidebar__content { flex-grow: 1; overflow-y: auto; +} + +.edit-site-sidebar__screen-wrapper { + @include custom-scrollbars-on-hover(transparent, $gray-700); + scrollbar-gutter: stable; + display: flex; + flex-direction: column; + height: 100%; - .components-navigator-screen { - @include custom-scrollbars-on-hover(transparent, $gray-700); - scrollbar-gutter: stable; - display: flex; - flex-direction: column; - height: 100%; - } + // This matches the logo padding + padding: 0 $grid-unit-15; } .edit-site-sidebar__footer { @@ -17,8 +20,3 @@ margin: 0 $canvas-padding; padding: $canvas-padding 0; } - -.edit-site-sidebar__content > div { - // This matches the logo padding - padding: 0 $grid-unit-15; -} diff --git a/packages/edit-site/src/components/site-hub/index.js b/packages/edit-site/src/components/site-hub/index.js index 9d63001c185c3..7af0d53090c57 100644 --- a/packages/edit-site/src/components/site-hub/index.js +++ b/packages/edit-site/src/components/site-hub/index.js @@ -17,6 +17,7 @@ import { useReducedMotion } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; import { decodeEntities } from '@wordpress/html-entities'; import { memo } from '@wordpress/element'; import { search, external } from '@wordpress/icons'; @@ -57,11 +58,9 @@ const SiteHub = memo( ( { isTransparent, className } ) => { const { open: openCommandCenter } = useDispatch( commandsStore ); const disableMotion = useReducedMotion(); - const { - setCanvasMode, - __experimentalSetPreviewDeviceType: setPreviewDeviceType, - } = unlock( useDispatch( editSiteStore ) ); + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); const { clearSelectedBlock } = useDispatch( blockEditorStore ); + const { setDeviceType } = useDispatch( editorStore ); const isBackToDashboardButton = canvasMode === 'view'; const siteIconButtonProps = isBackToDashboardButton ? { @@ -76,7 +75,7 @@ const SiteHub = memo( ( { isTransparent, className } ) => { event.preventDefault(); if ( canvasMode === 'edit' ) { clearSelectedBlock(); - setPreviewDeviceType( 'Desktop' ); + setDeviceType( 'Desktop' ); setCanvasMode( 'view' ); } }, diff --git a/packages/edit-site/src/components/template-actions/rename-menu-item.js b/packages/edit-site/src/components/template-actions/rename-menu-item.js index d098ea13fa58f..730bdba803ab5 100644 --- a/packages/edit-site/src/components/template-actions/rename-menu-item.js +++ b/packages/edit-site/src/components/template-actions/rename-menu-item.js @@ -107,6 +107,7 @@ export default function RenameMenuItem( { template, onClose } ) { <VStack spacing="5"> <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Name' ) } value={ editedTitle } onChange={ setEditedTitle } @@ -115,6 +116,7 @@ export default function RenameMenuItem( { template, onClose } ) { <HStack justify="right"> <Button + __next40pxDefaultSize variant="tertiary" onClick={ () => { setIsModalOpen( false ); @@ -123,7 +125,11 @@ export default function RenameMenuItem( { template, onClose } ) { { __( 'Cancel' ) } </Button> - <Button variant="primary" type="submit"> + <Button + __next40pxDefaultSize + variant="primary" + type="submit" + > { __( 'Save' ) } </Button> </HStack> diff --git a/packages/edit-site/src/components/welcome-guide/styles.js b/packages/edit-site/src/components/welcome-guide/styles.js index 03a3014b1b9fa..f0e6f65a9e434 100644 --- a/packages/edit-site/src/components/welcome-guide/styles.js +++ b/packages/edit-site/src/components/welcome-guide/styles.js @@ -118,7 +118,7 @@ export default function WelcomeGuideStyles() { <p className="edit-site-welcome-guide__text"> { __( 'New to block themes and styling your site?' - ) } + ) }{ ' ' } <ExternalLink href={ __( 'https://wordpress.org/documentation/article/styles-overview/' diff --git a/packages/edit-site/src/hooks/commands/use-common-commands.js b/packages/edit-site/src/hooks/commands/use-common-commands.js index f6da4829bf38d..fdea50d8afbdd 100644 --- a/packages/edit-site/src/hooks/commands/use-common-commands.js +++ b/packages/edit-site/src/hooks/commands/use-common-commands.js @@ -244,11 +244,16 @@ function useGlobalStylesOpenRevisionsCommands() { const isMobileViewport = useViewportMatch( 'medium', '<' ); const isEditorPage = ! getIsListPage( params, isMobileViewport ); const history = useHistory(); - const hasRevisions = useSelect( - ( select ) => - select( coreStore ).getCurrentThemeGlobalStylesRevisions()?.length, - [] - ); + const hasRevisions = useSelect( ( select ) => { + const { getEntityRecord, __experimentalGetCurrentGlobalStylesId } = + select( coreStore ); + const globalStylesId = __experimentalGetCurrentGlobalStylesId(); + const globalStyles = globalStylesId + ? getEntityRecord( 'root', 'globalStyles', globalStylesId ) + : undefined; + return !! globalStyles?._links?.[ 'version-history' ]?.[ 0 ]?.count; + }, [] ); + const commands = useMemo( () => { if ( ! hasRevisions ) { return []; diff --git a/packages/edit-site/src/hooks/navigation-menu-edit.js b/packages/edit-site/src/hooks/navigation-menu-edit.js index 4c04b1b253402..8b502f075430b 100644 --- a/packages/edit-site/src/hooks/navigation-menu-edit.js +++ b/packages/edit-site/src/hooks/navigation-menu-edit.js @@ -43,7 +43,7 @@ function NavigationMenuEdit( { attributes } ) { }, { // this applies to Navigation Menus as well. - fromTemplateId: params.postId, + fromTemplateId: params.postId || navigationMenu?.id, } ); diff --git a/packages/edit-site/src/hooks/template-part-edit.js b/packages/edit-site/src/hooks/template-part-edit.js index 0b14bbbbd7712..e60b7945448e7 100644 --- a/packages/edit-site/src/hooks/template-part-edit.js +++ b/packages/edit-site/src/hooks/template-part-edit.js @@ -43,7 +43,7 @@ function EditTemplatePartMenuItem( { attributes } ) { canvas: 'edit', }, { - fromTemplateId: params.postId, + fromTemplateId: params.postId || templatePart?.id, } ); diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index 2dd7aacd38401..6397a31af120b 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -10,6 +10,7 @@ import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; import { speak } from '@wordpress/a11y'; import { store as preferencesStore } from '@wordpress/preferences'; import { decodeEntities } from '@wordpress/html-entities'; @@ -49,16 +50,25 @@ export function toggleFeature( featureName ) { /** * Action that changes the width of the editing canvas. * + * @deprecated + * * @param {string} deviceType * * @return {Object} Action object. */ -export function __experimentalSetPreviewDeviceType( deviceType ) { - return { - type: 'SET_PREVIEW_DEVICE_TYPE', - deviceType, +export const __experimentalSetPreviewDeviceType = + ( deviceType ) => + ( { registry } ) => { + deprecated( + "dispatch( 'core/edit-site' ).__experimentalSetPreviewDeviceType", + { + since: '6.5', + version: '6.7', + hint: 'registry.dispatch( editorStore ).setDeviceType', + } + ); + registry.dispatch( editorStore ).setDeviceType( deviceType ); }; -} /** * Action that sets a template, optionally fetching it from REST API. diff --git a/packages/edit-site/src/store/reducer.js b/packages/edit-site/src/store/reducer.js index a46d215f90507..b55acbffd626e 100644 --- a/packages/edit-site/src/store/reducer.js +++ b/packages/edit-site/src/store/reducer.js @@ -3,23 +3,6 @@ */ import { combineReducers } from '@wordpress/data'; -/** - * Reducer returning the editing canvas device type. - * - * @param {Object} state Current state. - * @param {Object} action Dispatched action. - * - * @return {Object} Updated state. - */ -export function deviceType( state = 'Desktop', action ) { - switch ( action.type ) { - case 'SET_PREVIEW_DEVICE_TYPE': - return action.deviceType; - } - - return state; -} - /** * Reducer returning the settings. * @@ -158,7 +141,6 @@ function editorCanvasContainerView( state = undefined, action ) { } export default combineReducers( { - deviceType, settings, editedPost, blockInserterPanel, diff --git a/packages/edit-site/src/store/selectors.js b/packages/edit-site/src/store/selectors.js index 9d00e141270c4..ebaee12dfdc5e 100644 --- a/packages/edit-site/src/store/selectors.js +++ b/packages/edit-site/src/store/selectors.js @@ -44,13 +44,25 @@ export const isFeatureActive = createRegistrySelector( /** * Returns the current editing canvas device type. * + * @deprecated + * * @param {Object} state Global application state. * * @return {string} Device type. */ -export function __experimentalGetPreviewDeviceType( state ) { - return state.deviceType; -} +export const __experimentalGetPreviewDeviceType = createRegistrySelector( + ( select ) => () => { + deprecated( + `select( 'core/edit-site' ).__experimentalGetPreviewDeviceType`, + { + since: '6.5', + version: '6.7', + alternative: `select( 'core/editor' ).getDeviceType`, + } + ); + return select( editorStore ).getDeviceType(); + } +); /** * Returns whether the current user can create media or not. diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 30fbec3a94cc1..9e9cdbc6684b8 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -1,14 +1,13 @@ @import "../../interface/src/style.scss"; +@import "../../dataviews/src/style.scss"; @import "./components/add-new-template/style.scss"; @import "./components/block-editor/style.scss"; @import "./components/canvas-loader/style.scss"; @import "./components/code-editor/style.scss"; -@import "./components/dataviews/style.scss"; @import "./components/global-styles/style.scss"; @import "./components/global-styles/screen-revisions/style.scss"; @import "./components/header-edit-mode/style.scss"; -@import "./components/header-edit-mode/document-actions/style.scss"; @import "./components/list/style.scss"; @import "./components/page/style.scss"; @import "./components/page-pages/style.scss"; diff --git a/packages/edit-site/src/utils/constants.js b/packages/edit-site/src/utils/constants.js index 0aae3e681a16e..f5ca89b9fb62c 100644 --- a/packages/edit-site/src/utils/constants.js +++ b/packages/edit-site/src/utils/constants.js @@ -44,3 +44,11 @@ export const POST_TYPE_LABELS = { [ PATTERN_TYPES.user ]: __( 'Pattern' ), [ NAVIGATION_POST_TYPE ]: __( 'Navigation' ), }; + +// DataViews constants +export const LAYOUT_GRID = 'grid'; +export const LAYOUT_TABLE = 'table'; +export const LAYOUT_LIST = 'list'; +export const ENUMERATION_TYPE = 'enumeration'; +export const OPERATOR_IN = 'in'; +export const OPERATOR_NOT_IN = 'notIn'; diff --git a/packages/edit-widgets/src/components/header/index.js b/packages/edit-widgets/src/components/header/index.js index 9251f528ca5ee..9d4cb4cb60103 100644 --- a/packages/edit-widgets/src/components/header/index.js +++ b/packages/edit-widgets/src/components/header/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { BlockToolbar } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; import { useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -16,9 +16,6 @@ import { store as preferencesStore } from '@wordpress/preferences'; import DocumentTools from './document-tools'; import SaveButton from '../save-button'; import MoreMenu from '../more-menu'; -import { unlock } from '../../lock-unlock'; - -const { BlockContextualToolbar } = unlock( blockEditorPrivateApis ); function Header( { setListViewToggleElement } ) { const isLargeViewport = useViewportMatch( 'medium' ); @@ -56,7 +53,7 @@ function Header( { setListViewToggleElement } ) { { hasFixedToolbar && isLargeViewport && ( <> <div className="selected-block-tools-wrapper"> - <BlockContextualToolbar isFixed /> + <BlockToolbar hideDragHandle /> </div> <Popover.Slot ref={ blockToolbarRef } diff --git a/packages/edit-widgets/src/components/header/style.scss b/packages/edit-widgets/src/components/header/style.scss index e279b0f79b458..2dd4b88eebddf 100644 --- a/packages/edit-widgets/src/components/header/style.scss +++ b/packages/edit-widgets/src/components/header/style.scss @@ -12,10 +12,31 @@ .selected-block-tools-wrapper { overflow-x: hidden; - } - .block-editor-block-contextual-toolbar.is-fixed { - border: none; + .block-editor-block-contextual-toolbar { + border-bottom: 0; + } + + // Modified group borders + .components-toolbar-group, + .components-toolbar { + border-right: none; + + &::after { + content: ""; + width: $border-width; + margin-top: $grid-unit + $grid-unit-05; + margin-bottom: $grid-unit + $grid-unit-05; + background-color: $gray-300; + margin-left: $grid-unit; + } + + & .components-toolbar-group.components-toolbar-group { + &::after { + display: none; + } + } + } } } diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js index 12a70e2e4da27..a9024da1f0074 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js @@ -3,11 +3,13 @@ */ import { BlockList, + BlockToolbar, BlockTools, BlockSelectionClearer, WritingFlow, __unstableEditorStyles as EditorStyles, } from '@wordpress/block-editor'; +import { useViewportMatch } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { useMemo } from '@wordpress/element'; import { store as preferencesStore } from '@wordpress/preferences'; @@ -29,6 +31,7 @@ export default function WidgetAreasBlockEditorContent( { ), [] ); + const isLargeViewport = useViewportMatch( 'medium' ); const styles = useMemo( () => { return hasThemeStyles ? blockEditorSettings.styles : []; @@ -37,6 +40,7 @@ export default function WidgetAreasBlockEditorContent( { return ( <div className="edit-widgets-block-editor"> <Notices /> + { ! isLargeViewport && <BlockToolbar hideDragHandle /> } <BlockTools> <KeyboardShortcuts /> <EditorStyles diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js index 1fbf51d05f7b7..4abc420434cc4 100644 --- a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js +++ b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { SlotFillProvider } from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; import { uploadMedia } from '@wordpress/media-utils'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -32,6 +33,7 @@ export default function WidgetAreasBlockEditorProvider( { ...props } ) { const mediaPermissions = useResourcePermissions( 'media' ); + const isLargeViewport = useViewportMatch( 'medium' ); const { reusableBlocks, isFixedToolbarActive, @@ -78,7 +80,7 @@ export default function WidgetAreasBlockEditorProvider( { return { ...blockEditorSettings, __experimentalReusableBlocks: reusableBlocks, - hasFixedToolbar: isFixedToolbarActive, + hasFixedToolbar: isFixedToolbarActive || ! isLargeViewport, keepCaretInsideBlock, mediaUpload: mediaUploadBlockEditor, templateLock: 'all', @@ -89,6 +91,7 @@ export default function WidgetAreasBlockEditorProvider( { }, [ blockEditorSettings, isFixedToolbarActive, + isLargeViewport, keepCaretInsideBlock, mediaPermissions.canCreate, reusableBlocks, diff --git a/packages/editor/package.json b/packages/editor/package.json index a4e2e4832333a..70344e9dc3e72 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -36,6 +36,7 @@ "@wordpress/blob": "file:../blob", "@wordpress/block-editor": "file:../block-editor", "@wordpress/blocks": "file:../blocks", + "@wordpress/commands": "file:../commands", "@wordpress/components": "file:../components", "@wordpress/compose": "file:../compose", "@wordpress/core-data": "file:../core-data", diff --git a/packages/editor/src/components/document-bar/index.js b/packages/editor/src/components/document-bar/index.js new file mode 100644 index 0000000000000..da43533bfa5bc --- /dev/null +++ b/packages/editor/src/components/document-bar/index.js @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, isRTL, sprintf } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, +} from '@wordpress/components'; +import { BlockIcon } from '@wordpress/block-editor'; +import { + chevronLeftSmall, + chevronRightSmall, + page as pageIcon, + navigation as navigationIcon, + symbol, +} from '@wordpress/icons'; +import { displayShortcut } from '@wordpress/keycodes'; +import { useEntityRecord } from '@wordpress/core-data'; +import { store as commandsStore } from '@wordpress/commands'; +import { useState, useEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; + +const typeLabels = { + // translators: 1: Pattern title. + wp_pattern: __( 'Editing pattern: %s' ), + // translators: 1: Navigation menu title. + wp_navigation: __( 'Editing navigation menu: %s' ), + // translators: 1: Template title. + wp_template: __( 'Editing template: %s' ), + // translators: 1: Template part title. + wp_template_part: __( 'Editing template part: %s' ), +}; + +const icons = { + wp_block: symbol, + wp_navigation: navigationIcon, +}; + +export default function DocumentBar() { + const { isEditingTemplate, templateId, postType, postId } = useSelect( + ( select ) => { + const { + getRenderingMode, + getCurrentTemplateId, + getCurrentPostId, + getCurrentPostType, + } = select( editorStore ); + const _templateId = getCurrentTemplateId(); + return { + isEditingTemplate: + !! _templateId && getRenderingMode() === 'template-only', + templateId: _templateId, + postType: getCurrentPostType(), + postId: getCurrentPostId(), + }; + }, + [] + ); + const { getEditorSettings } = useSelect( editorStore ); + const { setRenderingMode } = useDispatch( editorStore ); + + return ( + <BaseDocumentActions + postType={ isEditingTemplate ? 'wp_template' : postType } + postId={ isEditingTemplate ? templateId : postId } + onBack={ + isEditingTemplate + ? () => + setRenderingMode( + getEditorSettings().defaultRenderingMode + ) + : undefined + } + /> + ); +} + +function BaseDocumentActions( { postType, postId, onBack } ) { + const { open: openCommandCenter } = useDispatch( commandsStore ); + const { editedRecord: doc, isResolving } = useEntityRecord( + 'postType', + postType, + postId + ); + const { templateIcon, templateTitle } = useSelect( ( select ) => { + const { __experimentalGetTemplateInfo: getTemplateInfo } = + select( editorStore ); + const templateInfo = getTemplateInfo( doc ); + return { + templateIcon: templateInfo.icon, + templateTitle: templateInfo.title, + }; + } ); + const isNotFound = ! doc && ! isResolving; + const icon = icons[ postType ] ?? pageIcon; + const [ isAnimated, setIsAnimated ] = useState( false ); + const isMounting = useRef( true ); + const isTemplate = [ 'wp_template', 'wp_template_part' ].includes( + postType + ); + const isGlobalEntity = [ + 'wp_template', + 'wp_navigation', + 'wp_template_part', + 'wp_block', + ].includes( postType ); + + useEffect( () => { + if ( ! isMounting.current ) { + setIsAnimated( true ); + } + isMounting.current = false; + }, [ postType, postId ] ); + + const title = isTemplate ? templateTitle : doc.title; + + return ( + <div + className={ classnames( 'editor-document-bar', { + 'has-back-button': !! onBack, + 'is-animated': isAnimated, + 'is-global': isGlobalEntity, + } ) } + > + { onBack && ( + <Button + className="editor-document-bar__back" + icon={ isRTL() ? chevronRightSmall : chevronLeftSmall } + onClick={ ( event ) => { + event.stopPropagation(); + onBack(); + } } + size="compact" + > + { __( 'Back' ) } + </Button> + ) } + { isNotFound && <Text>{ __( 'Document not found' ) }</Text> } + { ! isNotFound && ( + <Button + className="editor-document-bar__command" + onClick={ () => openCommandCenter() } + size="compact" + > + <HStack + className="editor-document-bar__title" + spacing={ 1 } + justify="center" + > + <BlockIcon icon={ isTemplate ? templateIcon : icon } /> + <Text + size="body" + as="h1" + aria-label={ + typeLabels[ postType ] + ? // eslint-disable-next-line @wordpress/valid-sprintf + sprintf( typeLabels[ postType ], title ) + : undefined + } + > + { title } + </Text> + </HStack> + <span className="editor-document-bar__shortcut"> + { displayShortcut.primary( 'k' ) } + </span> + </Button> + ) } + </div> + ); +} diff --git a/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss b/packages/editor/src/components/document-bar/style.scss similarity index 62% rename from packages/edit-site/src/components/header-edit-mode/document-actions/style.scss rename to packages/editor/src/components/document-bar/style.scss index be34a9696a3fd..0cd7e0689c7d3 100644 --- a/packages/edit-site/src/components/header-edit-mode/document-actions/style.scss +++ b/packages/editor/src/components/document-bar/style.scss @@ -1,7 +1,7 @@ -.edit-site-document-actions { +.editor-document-bar { display: flex; align-items: center; - height: $button-size; + height: $button-size-compact; justify-content: space-between; // Flex items will, by default, refuse to shrink below a minimum // intrinsic width. In order to shrink this flexbox item, and @@ -12,11 +12,6 @@ border-radius: $grid-unit-05; width: min(100%, 450px); - // Make the document title shorter in top-toolbar mode, as it has to be covered. - .has-fixed-toolbar & { - width: min(100%, 380px); - } - &:hover { background-color: $gray-200; } @@ -33,26 +28,18 @@ @include break-large() { width: min(100%, 450px); } - - &.is-synced-entity { - .edit-site-document-actions__title { - color: var(--wp-block-synced-color); - h1 { - color: var(--wp-block-synced-color); - } - } - } } -.edit-site-document-actions__command { +.editor-document-bar__command { flex-grow: 1; color: var(--wp-block-synced-color); overflow: hidden; } -.edit-site-document-actions__title { +.editor-document-bar__title { flex-grow: 1; overflow: hidden; + color: $gray-800; // Offset the layout based on the width of the ⌘K label. This ensures the title is centrally aligned. @include break-small() { @@ -63,6 +50,10 @@ color: var(--wp-block-synced-color); } + .editor-document-bar.is-global & { + color: var(--wp-block-synced-color); + } + .block-editor-block-icon { min-width: $grid-unit-30; flex-shrink: 0; @@ -73,37 +64,31 @@ overflow: hidden; text-overflow: ellipsis; max-width: 50%; + color: currentColor; } - .edit-site-document-actions.is-page & { - color: $gray-800; - - h1 { - color: $gray-800; - } - } - - .edit-site-document-actions.is-animated & { - animation: edit-site-document-actions__slide-in-left 0.3s; + .editor-document-bar.is-animated.has-back-button & { + animation: editor-document-bar__slide-in-left 0.3s; @include reduce-motion("animation"); } - .edit-site-document-actions.is-animated.is-page & { - animation: edit-site-document-actions__slide-in-right 0.3s; + .editor-document-bar.is-animated & { + animation: editor-document-bar__slide-in-right 0.3s; @include reduce-motion("animation"); } } -.edit-site-document-actions__shortcut { +.editor-document-bar__shortcut { color: $gray-800; min-width: $grid-unit-40; display: none; - @include break-small() { + + @include break-medium() { display: initial; } } -.edit-site-document-actions__back.components-button.has-icon.has-text { +.editor-document-bar__back.components-button.has-icon.has-text { min-width: $button-size; flex-shrink: 0; color: $gray-700; @@ -112,17 +97,17 @@ position: absolute; &:hover { - color: currentColor; + color: var(--wp-block-synced-color); background-color: transparent; } - .edit-site-document-actions.is-animated & { - animation: edit-site-document-actions__slide-in-left 0.3s; + .editor-document-bar.is-animated & { + animation: editor-document-bar__slide-in-left 0.3s; @include reduce-motion("animation"); } } -@keyframes edit-site-document-actions__slide-in-right { +@keyframes editor-document-bar__slide-in-right { from { transform: translateX(-15%); opacity: 0; @@ -133,7 +118,7 @@ } } -@keyframes edit-site-document-actions__slide-in-left { +@keyframes editor-document-bar__slide-in-left { from { transform: translateX(15%); opacity: 0; diff --git a/packages/edit-site/src/components/page-content-focus-notifications/edit-template-notification.js b/packages/editor/src/components/editor-canvas/edit-template-blocks-notification.js similarity index 91% rename from packages/edit-site/src/components/page-content-focus-notifications/edit-template-notification.js rename to packages/editor/src/components/editor-canvas/edit-template-blocks-notification.js index 8799eb4d66128..566311e20cadc 100644 --- a/packages/edit-site/src/components/page-content-focus-notifications/edit-template-notification.js +++ b/packages/editor/src/components/editor-canvas/edit-template-blocks-notification.js @@ -6,7 +6,11 @@ import { useEffect, useState, useRef } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { __ } from '@wordpress/i18n'; import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components'; -import { store as editorStore } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; /** * Component that: @@ -22,7 +26,7 @@ import { store as editorStore } from '@wordpress/editor'; * @param {import('react').RefObject<HTMLElement>} props.contentRef Ref to the block * editor iframe canvas. */ -export default function EditTemplateNotification( { contentRef } ) { +export default function EditTemplateBlocksNotification( { contentRef } ) { const renderingMode = useSelect( ( select ) => select( editorStore ).getRenderingMode(), [] @@ -38,7 +42,7 @@ export default function EditTemplateNotification( { contentRef } ) { useEffect( () => { const handleClick = async ( event ) => { - if ( renderingMode === 'template-only' ) { + if ( renderingMode !== 'template-locked' ) { return; } if ( ! event.target.classList.contains( 'is-root-container' ) ) { @@ -67,7 +71,7 @@ export default function EditTemplateNotification( { contentRef } ) { }; const handleDblClick = ( event ) => { - if ( renderingMode === 'template-only' ) { + if ( renderingMode !== 'template-locked' ) { return; } if ( ! event.target.classList.contains( 'is-root-container' ) ) { diff --git a/packages/editor/src/components/editor-canvas/index.js b/packages/editor/src/components/editor-canvas/index.js new file mode 100644 index 0000000000000..cd87db0d4bf5e --- /dev/null +++ b/packages/editor/src/components/editor-canvas/index.js @@ -0,0 +1,381 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { + BlockList, + store as blockEditorStore, + __unstableUseTypewriter as useTypewriter, + __unstableUseTypingObserver as useTypingObserver, + useSettings, + __experimentalRecursionProvider as RecursionProvider, + privateApis as blockEditorPrivateApis, + __experimentalUseResizeCanvas as useResizeCanvas, +} from '@wordpress/block-editor'; +import { useEffect, useRef, useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { parse } from '@wordpress/blocks'; +import { store as coreStore } from '@wordpress/core-data'; +import { useMergeRefs } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import PostTitle from '../post-title'; +import { store as editorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; +import EditTemplateBlocksNotification from './edit-template-blocks-notification'; + +const { + LayoutStyle, + useLayoutClasses, + useLayoutStyles, + ExperimentalBlockCanvas: BlockCanvas, +} = unlock( blockEditorPrivateApis ); + +/** + * Given an array of nested blocks, find the first Post Content + * block inside it, recursing through any nesting levels, + * and return its attributes. + * + * @param {Array} blocks A list of blocks. + * + * @return {Object | undefined} The Post Content block. + */ +function getPostContentAttributes( blocks ) { + for ( let i = 0; i < blocks.length; i++ ) { + if ( blocks[ i ].name === 'core/post-content' ) { + return blocks[ i ].attributes; + } + if ( blocks[ i ].innerBlocks.length ) { + const nestedPostContent = getPostContentAttributes( + blocks[ i ].innerBlocks + ); + + if ( nestedPostContent ) { + return nestedPostContent; + } + } + } +} + +function checkForPostContentAtRootLevel( blocks ) { + for ( let i = 0; i < blocks.length; i++ ) { + if ( blocks[ i ].name === 'core/post-content' ) { + return true; + } + } + return false; +} + +function EditorCanvas( { + // Ideally as we unify post and site editors, we won't need these props. + autoFocus, + className, + renderAppender, + styles, + disableIframe = false, + iframeProps, + children, +} ) { + const { + renderingMode, + postContentAttributes, + editedPostTemplate = {}, + wrapperBlockName, + wrapperUniqueId, + deviceType, + } = useSelect( ( select ) => { + const { + getCurrentPostId, + getCurrentPostType, + getCurrentTemplateId, + getEditorSettings, + getRenderingMode, + getDeviceType, + } = select( editorStore ); + const { getPostType, canUser, getEditedEntityRecord } = + select( coreStore ); + const postTypeSlug = getCurrentPostType(); + const _renderingMode = getRenderingMode(); + let _wrapperBlockName; + + if ( postTypeSlug === 'wp_block' ) { + _wrapperBlockName = 'core/block'; + } else if ( ! _renderingMode === 'post-only' ) { + _wrapperBlockName = 'core/post-content'; + } + + const editorSettings = getEditorSettings(); + const supportsTemplateMode = editorSettings.supportsTemplateMode; + const postType = getPostType( postTypeSlug ); + const canEditTemplate = canUser( 'create', 'templates' ); + const currentTemplateId = getCurrentTemplateId(); + const template = currentTemplateId + ? getEditedEntityRecord( + 'postType', + 'wp_template', + currentTemplateId + ) + : undefined; + + return { + renderingMode: _renderingMode, + postContentAttributes: getEditorSettings().postContentAttributes, + // Post template fetch returns a 404 on classic themes, which + // messes with e2e tests, so check it's a block theme first. + editedPostTemplate: + postType?.viewable && supportsTemplateMode && canEditTemplate + ? template + : undefined, + wrapperBlockName: _wrapperBlockName, + wrapperUniqueId: getCurrentPostId(), + deviceType: getDeviceType(), + }; + }, [] ); + const { isCleanNewPost } = useSelect( editorStore ); + const { + hasRootPaddingAwareAlignments, + themeHasDisabledLayoutStyles, + themeSupportsLayout, + } = useSelect( ( select ) => { + const _settings = select( blockEditorStore ).getSettings(); + return { + themeHasDisabledLayoutStyles: _settings.disableLayoutStyles, + themeSupportsLayout: _settings.supportsLayout, + hasRootPaddingAwareAlignments: + _settings.__experimentalFeatures?.useRootPaddingAwareAlignments, + }; + }, [] ); + + const deviceStyles = useResizeCanvas( deviceType ); + const [ globalLayoutSettings ] = useSettings( 'layout' ); + + // fallbackLayout is used if there is no Post Content, + // and for Post Title. + const fallbackLayout = useMemo( () => { + if ( renderingMode !== 'post-only' ) { + return { type: 'default' }; + } + + if ( themeSupportsLayout ) { + // We need to ensure support for wide and full alignments, + // so we add the constrained type. + return { ...globalLayoutSettings, type: 'constrained' }; + } + // Set default layout for classic themes so all alignments are supported. + return { type: 'default' }; + }, [ renderingMode, themeSupportsLayout, globalLayoutSettings ] ); + + const newestPostContentAttributes = useMemo( () => { + if ( + ! editedPostTemplate?.content && + ! editedPostTemplate?.blocks && + postContentAttributes + ) { + return postContentAttributes; + } + // When in template editing mode, we can access the blocks directly. + if ( editedPostTemplate?.blocks ) { + return getPostContentAttributes( editedPostTemplate?.blocks ); + } + // If there are no blocks, we have to parse the content string. + // Best double-check it's a string otherwise the parse function gets unhappy. + const parseableContent = + typeof editedPostTemplate?.content === 'string' + ? editedPostTemplate?.content + : ''; + + return getPostContentAttributes( parse( parseableContent ) ) || {}; + }, [ + editedPostTemplate?.content, + editedPostTemplate?.blocks, + postContentAttributes, + ] ); + + const hasPostContentAtRootLevel = useMemo( () => { + if ( ! editedPostTemplate?.content && ! editedPostTemplate?.blocks ) { + return false; + } + // When in template editing mode, we can access the blocks directly. + if ( editedPostTemplate?.blocks ) { + return checkForPostContentAtRootLevel( editedPostTemplate?.blocks ); + } + // If there are no blocks, we have to parse the content string. + // Best double-check it's a string otherwise the parse function gets unhappy. + const parseableContent = + typeof editedPostTemplate?.content === 'string' + ? editedPostTemplate?.content + : ''; + + return ( + checkForPostContentAtRootLevel( parse( parseableContent ) ) || false + ); + }, [ editedPostTemplate?.content, editedPostTemplate?.blocks ] ); + + const { layout = {}, align = '' } = newestPostContentAttributes || {}; + + const postContentLayoutClasses = useLayoutClasses( + newestPostContentAttributes, + 'core/post-content' + ); + + const blockListLayoutClass = classnames( + { + 'is-layout-flow': ! themeSupportsLayout, + }, + themeSupportsLayout && postContentLayoutClasses, + align && `align${ align }` + ); + + const postContentLayoutStyles = useLayoutStyles( + newestPostContentAttributes, + 'core/post-content', + '.block-editor-block-list__layout.is-root-container' + ); + + // Update type for blocks using legacy layouts. + const postContentLayout = useMemo( () => { + return layout && + ( layout?.type === 'constrained' || + layout?.inherit || + layout?.contentSize || + layout?.wideSize ) + ? { ...globalLayoutSettings, ...layout, type: 'constrained' } + : { ...globalLayoutSettings, ...layout, type: 'default' }; + }, [ + layout?.type, + layout?.inherit, + layout?.contentSize, + layout?.wideSize, + globalLayoutSettings, + ] ); + + // If there is a Post Content block we use its layout for the block list; + // if not, this must be a classic theme, in which case we use the fallback layout. + const blockListLayout = postContentAttributes + ? postContentLayout + : fallbackLayout; + + const postEditorLayout = + blockListLayout?.type === 'default' && ! hasPostContentAtRootLevel + ? fallbackLayout + : blockListLayout; + + const observeTypingRef = useTypingObserver(); + const titleRef = useRef(); + useEffect( () => { + if ( ! autoFocus || ! isCleanNewPost() ) { + return; + } + titleRef?.current?.focus(); + }, [ autoFocus, isCleanNewPost ] ); + + // Add some styles for alignwide/alignfull Post Content and its children. + const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--global--wide-size); margin-left: auto; margin-right: auto;} + .is-root-container.alignwide:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: var(--wp--style--global--wide-size);} + .is-root-container.alignfull { max-width: none; margin-left: auto; margin-right: auto;} + .is-root-container.alignfull:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: none;}`; + + const localRef = useRef(); + const typewriterRef = useTypewriter(); + const contentRef = useMergeRefs( + [ + localRef, + renderingMode === 'post-only' ? typewriterRef : undefined, + ].filter( ( r ) => !! r ) + ); + + return ( + <BlockCanvas + shouldIframe={ + ! disableIframe || [ 'Tablet', 'Mobile' ].includes( deviceType ) + } + contentRef={ contentRef } + styles={ styles } + height="100%" + iframeProps={ { + ...iframeProps, + style: { + ...iframeProps?.style, + ...deviceStyles, + }, + } } + > + { themeSupportsLayout && + ! themeHasDisabledLayoutStyles && + renderingMode === 'post-only' && ( + <> + <LayoutStyle + selector=".editor-editor-canvas__post-title-wrapper" + layout={ fallbackLayout } + /> + <LayoutStyle + selector=".block-editor-block-list__layout.is-root-container" + layout={ postEditorLayout } + /> + { align && <LayoutStyle css={ alignCSS } /> } + { postContentLayoutStyles && ( + <LayoutStyle + layout={ postContentLayout } + css={ postContentLayoutStyles } + /> + ) } + </> + ) } + { renderingMode === 'post-only' && ( + <div + className={ classnames( + 'editor-editor-canvas__post-title-wrapper', + // The following class is only here for backward comapatibility + // some themes might be using it to style the post title. + 'edit-post-visual-editor__post-title-wrapper', + { + 'has-global-padding': hasRootPaddingAwareAlignments, + } + ) } + contentEditable={ false } + ref={ observeTypingRef } + style={ { + // This is using inline styles + // so it's applied for both iframed and non iframed editors. + marginTop: '4rem', + } } + > + <PostTitle ref={ titleRef } /> + </div> + ) } + <RecursionProvider + blockName={ wrapperBlockName } + uniqueId={ wrapperUniqueId } + > + <BlockList + className={ classnames( + className, + 'is-' + deviceType.toLowerCase() + '-preview', + renderingMode !== 'post-only' + ? 'wp-site-blocks' + : `${ blockListLayoutClass } wp-block-post-content` // Ensure root level blocks receive default/flow blockGap styling rules. + ) } + layout={ blockListLayout } + dropZoneElement={ + // When iframed, pass in the html element of the iframe to + // ensure the drop zone extends to the edges of the iframe. + disableIframe + ? localRef.current + : localRef.current?.parentNode + } + renderAppender={ renderAppender } + /> + <EditTemplateBlocksNotification contentRef={ localRef } /> + </RecursionProvider> + { children } + </BlockCanvas> + ); +} + +export default EditorCanvas; diff --git a/packages/editor/src/components/index.js b/packages/editor/src/components/index.js index 5fefc5506a02f..7efa33dc243b5 100644 --- a/packages/editor/src/components/index.js +++ b/packages/editor/src/components/index.js @@ -8,6 +8,7 @@ export * from './autocompleters'; // Post Related Components. export { default as AutosaveMonitor } from './autosave-monitor'; +export { default as DocumentBar } from './document-bar'; export { default as DocumentOutline } from './document-outline'; export { default as DocumentOutlineCheck } from './document-outline/check'; export { EditorKeyboardShortcuts }; @@ -23,7 +24,8 @@ export { default as LocalAutosaveMonitor } from './local-autosave-monitor'; export { default as PageAttributesCheck } from './page-attributes/check'; export { default as PageAttributesOrder } from './page-attributes/order'; export { default as PageAttributesParent } from './page-attributes/parent'; -export { default as PageTemplate } from './post-template'; +export { default as PageTemplate } from './post-template/classic-theme'; +export { default as PostTemplatePanel } from './post-template/panel'; export { default as PostAuthor } from './post-author'; export { default as PostAuthorCheck } from './post-author/check'; export { default as PostAuthorPanel } from './post-author/panel'; diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js index b9140b733c9d3..a81709607e193 100644 --- a/packages/editor/src/components/post-publish-button/index.js +++ b/packages/editor/src/components/post-publish-button/index.js @@ -176,6 +176,7 @@ export class PostPublishButton extends Component { className: 'editor-post-publish-panel__toggle', isBusy: isSaving && isPublished, variant: 'primary', + size: 'compact', onClick: this.createOnClick( onClickToggle ), }; diff --git a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js index 0097b3f0ea741..f432b0da16c35 100644 --- a/packages/editor/src/components/post-publish-panel/maybe-upload-media.js +++ b/packages/editor/src/components/post-publish-panel/maybe-upload-media.js @@ -10,7 +10,6 @@ import { } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import { upload } from '@wordpress/icons'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { useState } from '@wordpress/element'; import { isBlobURL } from '@wordpress/blob'; @@ -135,7 +134,7 @@ export default function PostFormatPanel() { <PanelBody initialOpen={ true } title={ panelBodyTitle }> <p> { __( - 'There are some external images in the post which can be uploaded to the media library. Images coming from different domains may not always display correctly, load slowly for visitors, or be removed unexpectedly.' + 'Upload external images to the Media Library. Images from different domains may load slowly, display incorrectly, or be removed unexpectedly.' ) } </p> <div @@ -153,12 +152,8 @@ export default function PostFormatPanel() { { isUploading ? ( <Spinner /> ) : ( - <Button - icon={ upload } - variant="primary" - onClick={ uploadImages } - > - { __( 'Upload all' ) } + <Button variant="primary" onClick={ uploadImages }> + { __( 'Upload' ) } </Button> ) } </div> diff --git a/packages/editor/src/components/post-saved-state/index.js b/packages/editor/src/components/post-saved-state/index.js index 7b2c19d6eabe5..9cccb4fcb120e 100644 --- a/packages/editor/src/components/post-saved-state/index.js +++ b/packages/editor/src/components/post-saved-state/index.js @@ -168,6 +168,7 @@ export default function PostSavedState( { } onClick={ isDisabled ? undefined : () => savePost() } variant="tertiary" + size="compact" icon={ isLargeViewport ? undefined : cloudUpload } // Make sure the aria-label has always a value, as the default `text` is undefined on small screens. aria-label={ buttonAccessibleLabel } diff --git a/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap index 16fdd70e06573..95ea3db54b511 100644 --- a/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/post-saved-state/test/__snapshots__/index.js.snap @@ -4,7 +4,7 @@ exports[`PostSavedState returns a disabled button if the post is not saveable 1` <button aria-disabled="true" aria-label="Save draft" - class="components-button is-tertiary has-icon" + class="components-button is-compact is-tertiary has-icon" type="button" > <svg @@ -26,7 +26,7 @@ exports[`PostSavedState should return Save button if edits to be saved 1`] = ` <button aria-disabled="false" aria-label="Save draft" - class="components-button editor-post-save-draft is-tertiary" + class="components-button editor-post-save-draft is-compact is-tertiary" type="button" > Save draft diff --git a/packages/editor/src/components/post-schedule/panel.js b/packages/editor/src/components/post-schedule/panel.js index 2e725a06bc9fd..899ecd9efaee7 100644 --- a/packages/editor/src/components/post-schedule/panel.js +++ b/packages/editor/src/components/post-schedule/panel.js @@ -49,7 +49,7 @@ export default function PostSchedulePanel() { label ) } label={ fullLabel } - showTooltip + showTooltip={ label !== fullLabel } aria-expanded={ isOpen } > { label } diff --git a/packages/editor/src/components/post-template/block-theme.js b/packages/editor/src/components/post-template/block-theme.js new file mode 100644 index 0000000000000..a8b07cdacb554 --- /dev/null +++ b/packages/editor/src/components/post-template/block-theme.js @@ -0,0 +1,109 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecord } from '@wordpress/core-data'; +import { check } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import SwapTemplateButton from './swap-template-button'; +import ResetDefaultTemplate from './reset-default-template'; +import { unlock } from '../../lock-unlock'; +import CreateNewTemplate from './create-new-template'; + +const POPOVER_PROPS = { + className: 'editor-post-template__dropdown', + placement: 'bottom-start', +}; + +export default function BlockThemeControl( { id } ) { + const { isTemplateHidden } = useSelect( ( select ) => { + const { getRenderingMode } = unlock( select( editorStore ) ); + return { + isTemplateHidden: getRenderingMode() === 'post-only', + }; + }, [] ); + const { editedRecord: template, hasResolved } = useEntityRecord( + 'postType', + 'wp_template', + id + ); + const { getEditorSettings } = useSelect( editorStore ); + const { createSuccessNotice } = useDispatch( noticesStore ); + const { setRenderingMode } = useDispatch( editorStore ); + + if ( ! hasResolved ) { + return null; + } + + return ( + <DropdownMenu + popoverProps={ POPOVER_PROPS } + focusOnMount + toggleProps={ { + variant: 'tertiary', + } } + label={ __( 'Template options' ) } + text={ decodeEntities( template.title ) } + icon={ null } + > + { ( { onClose } ) => ( + <> + <MenuGroup> + <MenuItem + onClick={ () => { + setRenderingMode( 'template-only' ); + onClose(); + createSuccessNotice( + __( + 'Editing template. Changes made here affect all posts and pages that use the template.' + ), + { + type: 'snackbar', + actions: [ + { + label: __( 'Go back' ), + onClick: () => + setRenderingMode( + getEditorSettings() + .defaultRenderingMode + ), + }, + ], + } + ); + } } + > + { __( 'Edit template' ) } + </MenuItem> + <SwapTemplateButton onClick={ onClose } /> + <ResetDefaultTemplate onClick={ onClose } /> + <CreateNewTemplate onClick={ onClose } /> + </MenuGroup> + <MenuGroup> + <MenuItem + icon={ ! isTemplateHidden ? check : undefined } + isPressed={ ! isTemplateHidden } + onClick={ () => { + setRenderingMode( + isTemplateHidden + ? 'template-locked' + : 'post-only' + ); + } } + > + { __( 'Template preview' ) } + </MenuItem> + </MenuGroup> + </> + ) } + </DropdownMenu> + ); +} diff --git a/packages/editor/src/components/post-template/classic-theme.js b/packages/editor/src/components/post-template/classic-theme.js new file mode 100644 index 0000000000000..2aac8f90a0218 --- /dev/null +++ b/packages/editor/src/components/post-template/classic-theme.js @@ -0,0 +1,213 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { SelectControl, Dropdown, Button, Notice } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { __experimentalInspectorPopoverHeader as InspectorPopoverHeader } from '@wordpress/block-editor'; +import { useState, useMemo } from '@wordpress/element'; +import { addTemplate } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import CreateNewTemplateModal from './create-new-template-modal'; +import { useAllowSwitchingTemplates } from './hooks'; + +const POPOVER_PROPS = { + className: 'editor-post-template__dropdown', + placement: 'bottom-start', +}; + +function PostTemplateToggle( { isOpen, onClick } ) { + const templateTitle = useSelect( ( select ) => { + const templateSlug = + select( editorStore ).getEditedPostAttribute( 'template' ); + + const { supportsTemplateMode, availableTemplates } = + select( editorStore ).getEditorSettings(); + if ( ! supportsTemplateMode && availableTemplates[ templateSlug ] ) { + return availableTemplates[ templateSlug ]; + } + const template = + select( coreStore ).canUser( 'create', 'templates' ) && + select( editorStore ).getCurrentTemplateId(); + return ( + template?.title || + template?.slug || + availableTemplates?.[ templateSlug ] + ); + }, [] ); + + return ( + <Button + className="edit-post-post-template__toggle" + variant="tertiary" + aria-expanded={ isOpen } + aria-label={ __( 'Template options' ) } + onClick={ onClick } + > + { templateTitle ?? __( 'Default template' ) } + </Button> + ); +} + +function PostTemplateDropdownContent( { onClose } ) { + const allowSwitchingTemplate = useAllowSwitchingTemplates(); + const { + availableTemplates, + fetchedTemplates, + selectedTemplateSlug, + canCreate, + canEdit, + } = useSelect( + ( select ) => { + const { canUser, getEntityRecords } = select( coreStore ); + const editorSettings = select( editorStore ).getEditorSettings(); + const canCreateTemplates = canUser( 'create', 'templates' ); + + return { + availableTemplates: editorSettings.availableTemplates, + fetchedTemplates: canCreateTemplates + ? getEntityRecords( 'postType', 'wp_template', { + post_type: + select( editorStore ).getCurrentPostType(), + per_page: -1, + } ) + : undefined, + selectedTemplateSlug: + select( editorStore ).getEditedPostAttribute( 'template' ), + canCreate: + allowSwitchingTemplate && + canCreateTemplates && + editorSettings.supportsTemplateMode, + canEdit: + allowSwitchingTemplate && + canCreateTemplates && + editorSettings.supportsTemplateMode && + !! select( editorStore ).getCurrentTemplateId(), + }; + }, + [ allowSwitchingTemplate ] + ); + + const options = useMemo( + () => + Object.entries( { + ...availableTemplates, + ...Object.fromEntries( + ( fetchedTemplates ?? [] ).map( ( { slug, title } ) => [ + slug, + title.rendered, + ] ) + ), + } ).map( ( [ slug, title ] ) => ( { value: slug, label: title } ) ), + [ availableTemplates, fetchedTemplates ] + ); + + const selectedOption = + options.find( ( option ) => option.value === selectedTemplateSlug ) ?? + options.find( ( option ) => ! option.value ); // The default option has '' value. + + const { editPost } = useDispatch( editorStore ); + const { getEditorSettings } = useSelect( editorStore ); + const { createSuccessNotice } = useDispatch( noticesStore ); + const { setRenderingMode } = useDispatch( editorStore ); + const [ isCreateModalOpen, setIsCreateModalOpen ] = useState( false ); + + return ( + <div className="editor-post-template__classic-theme-dropdown"> + <InspectorPopoverHeader + title={ __( 'Template' ) } + help={ __( + 'Templates define the way content is displayed when viewing your site.' + ) } + actions={ + canCreate + ? [ + { + icon: addTemplate, + label: __( 'Add template' ), + onClick: () => setIsCreateModalOpen( true ), + }, + ] + : [] + } + onClose={ onClose } + /> + { ! allowSwitchingTemplate ? ( + <Notice status="warning" isDismissible={ false }> + { __( 'The posts page template cannot be changed.' ) } + </Notice> + ) : ( + <SelectControl + __next40pxDefaultSize + __nextHasNoMarginBottom + hideLabelFromVision + label={ __( 'Template' ) } + value={ selectedOption?.value ?? '' } + options={ options } + onChange={ ( slug ) => + editPost( { template: slug || '' } ) + } + /> + ) } + { canEdit && ( + <p> + <Button + variant="link" + onClick={ () => { + setRenderingMode( 'template-only' ); + onClose(); + createSuccessNotice( + __( + 'Editing template. Changes made here affect all posts and pages that use the template.' + ), + { + type: 'snackbar', + actions: [ + { + label: __( 'Go back' ), + onClick: () => + setRenderingMode( + getEditorSettings() + .defaultRenderingMode + ), + }, + ], + } + ); + } } + > + { __( 'Edit template' ) } + </Button> + </p> + ) } + { isCreateModalOpen && ( + <CreateNewTemplateModal + onClose={ () => setIsCreateModalOpen( false ) } + /> + ) } + </div> + ); +} + +function ClassicThemeControl() { + return ( + <Dropdown + popoverProps={ POPOVER_PROPS } + focusOnMount + renderToggle={ ( { isOpen, onToggle } ) => ( + <PostTemplateToggle isOpen={ isOpen } onClick={ onToggle } /> + ) } + renderContent={ ( { onClose } ) => ( + <PostTemplateDropdownContent onClose={ onClose } /> + ) } + /> + ); +} + +export default ClassicThemeControl; diff --git a/packages/edit-post/src/components/sidebar/post-template/create-modal.js b/packages/editor/src/components/post-template/create-new-template-modal.js similarity index 84% rename from packages/edit-post/src/components/sidebar/post-template/create-modal.js rename to packages/editor/src/components/post-template/create-new-template-modal.js index a25cfa5c25cb3..61b1b165aca27 100644 --- a/packages/edit-post/src/components/sidebar/post-template/create-modal.js +++ b/packages/editor/src/components/post-template/create-new-template-modal.js @@ -2,7 +2,6 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; import { useState } from '@wordpress/element'; import { serialize, createBlock } from '@wordpress/blocks'; import { @@ -18,19 +17,21 @@ import { cleanForSlug } from '@wordpress/url'; /** * Internal dependencies */ -import { store as editPostStore } from '../../../store'; +import { unlock } from '../../lock-unlock'; +import { store as editorStore } from '../../store'; const DEFAULT_TITLE = __( 'Custom Template' ); -export default function PostTemplateCreateModal( { onClose } ) { +export default function CreateNewTemplateModal( { onClose } ) { const defaultBlockTemplate = useSelect( ( select ) => select( editorStore ).getEditorSettings().defaultBlockTemplate, [] ); - const { __unstableCreateTemplate, __unstableSwitchToTemplateMode } = - useDispatch( editPostStore ); + const { createTemplate, setRenderingMode } = unlock( + useDispatch( editorStore ) + ); const [ title, setTitle ] = useState( '' ); @@ -85,7 +86,7 @@ export default function PostTemplateCreateModal( { onClose } ) { ), ] ); - await __unstableCreateTemplate( { + await createTemplate( { slug: cleanForSlug( title || DEFAULT_TITLE ), content: newTemplateContent, title: title || DEFAULT_TITLE, @@ -93,18 +94,16 @@ export default function PostTemplateCreateModal( { onClose } ) { setIsBusy( false ); cancel(); - - __unstableSwitchToTemplateMode( true ); + setRenderingMode( 'template-only' ); }; return ( <Modal title={ __( 'Create custom template' ) } onRequestClose={ cancel } - className="edit-post-post-template__create-modal" > <form - className="edit-post-post-template__create-form" + className="editor-post-template__create-form" onSubmit={ submit } > <VStack spacing="3"> diff --git a/packages/editor/src/components/post-template/create-new-template.js b/packages/editor/src/components/post-template/create-new-template.js new file mode 100644 index 0000000000000..04a7ab8febdff --- /dev/null +++ b/packages/editor/src/components/post-template/create-new-template.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import CreateNewTemplateModal from './create-new-template-modal'; +import { useAllowSwitchingTemplates } from './hooks'; + +export default function CreateNewTemplate( { onClick } ) { + const { canCreateTemplates } = useSelect( ( select ) => { + const { canUser } = select( coreStore ); + return { + canCreateTemplates: canUser( 'create', 'templates' ), + }; + }, [] ); + const [ isCreateModalOpen, setIsCreateModalOpen ] = useState( false ); + const allowSwitchingTemplate = useAllowSwitchingTemplates(); + + // The default template in a post is indicated by an empty string. + if ( ! canCreateTemplates || ! allowSwitchingTemplate ) { + return null; + } + return ( + <> + <MenuItem + onClick={ () => { + setIsCreateModalOpen( true ); + } } + > + { __( 'Create new template' ) } + </MenuItem> + + { isCreateModalOpen && ( + <CreateNewTemplateModal + onClose={ () => { + setIsCreateModalOpen( false ); + onClick(); + } } + /> + ) } + </> + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js b/packages/editor/src/components/post-template/hooks.js similarity index 78% rename from packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js rename to packages/editor/src/components/post-template/hooks.js index 1071479b2f9f2..e676bf66cf2fb 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js +++ b/packages/editor/src/components/post-template/hooks.js @@ -8,50 +8,46 @@ import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies */ -import { store as editSiteStore } from '../../../store'; -import { TEMPLATE_POST_TYPE } from '../../../utils/constants'; +import { store as editorStore } from '../../store'; export function useEditedPostContext() { - return useSelect( - ( select ) => select( editSiteStore ).getEditedPostContext(), - [] - ); + return useSelect( ( select ) => { + const { getCurrentPostId, getCurrentPostType } = select( editorStore ); + return { + postId: getCurrentPostId(), + postType: getCurrentPostType(), + }; + }, [] ); } - export function useAllowSwitchingTemplates() { - const { postId } = useEditedPostContext(); + const { postType, postId } = useEditedPostContext(); return useSelect( ( select ) => { const { getEntityRecord, getEntityRecords } = select( coreStore ); const siteSettings = getEntityRecord( 'root', 'site' ); - const templates = getEntityRecords( - 'postType', - TEMPLATE_POST_TYPE, - { per_page: -1 } - ); + const templates = getEntityRecords( 'postType', 'wp_template', { + per_page: -1, + } ); const isPostsPage = +postId === siteSettings?.page_for_posts; // If current page is set front page or posts page, we also need // to check if the current theme has a template for it. If not const isFrontPage = + postType === 'page' && +postId === siteSettings?.page_on_front && templates?.some( ( { slug } ) => slug === 'front-page' ); return ! isPostsPage && ! isFrontPage; }, - [ postId ] + [ postId, postType ] ); } function useTemplates() { return useSelect( ( select ) => - select( coreStore ).getEntityRecords( - 'postType', - TEMPLATE_POST_TYPE, - { - per_page: -1, - post_type: 'page', - } - ), + select( coreStore ).getEntityRecords( 'postType', 'wp_template', { + per_page: -1, + post_type: 'page', + } ), [] ); } diff --git a/packages/editor/src/components/post-template/index.js b/packages/editor/src/components/post-template/index.js deleted file mode 100644 index 91efcd77e8742..0000000000000 --- a/packages/editor/src/components/post-template/index.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { SelectControl } from '@wordpress/components'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { store as editorStore } from '../../store'; - -export function PostTemplate() { - const { availableTemplates, selectedTemplate, isViewable } = useSelect( - ( select ) => { - const { - getEditedPostAttribute, - getEditorSettings, - getCurrentPostType, - } = select( editorStore ); - const { getPostType } = select( coreStore ); - - return { - selectedTemplate: getEditedPostAttribute( 'template' ), - availableTemplates: getEditorSettings().availableTemplates, - isViewable: - getPostType( getCurrentPostType() )?.viewable ?? false, - }; - }, - [] - ); - - const { editPost } = useDispatch( editorStore ); - - if ( - ! isViewable || - ! availableTemplates || - ! Object.keys( availableTemplates ).length - ) { - return null; - } - - return ( - <SelectControl - __nextHasNoMarginBottom - label={ __( 'Template:' ) } - value={ selectedTemplate } - onChange={ ( templateSlug ) => { - editPost( { - template: templateSlug || '', - } ); - } } - options={ Object.entries( availableTemplates ?? {} ).map( - ( [ templateSlug, templateName ] ) => ( { - value: templateSlug, - label: templateName, - } ) - ) } - /> - ); -} - -export default PostTemplate; diff --git a/packages/editor/src/components/post-template/panel.js b/packages/editor/src/components/post-template/panel.js new file mode 100644 index 0000000000000..8fcaeec8f3a3b --- /dev/null +++ b/packages/editor/src/components/post-template/panel.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import ClassicThemeControl from './classic-theme'; +import BlockThemeControl from './block-theme'; +import PostPanelRow from '../post-panel-row'; + +export default function PostTemplatePanel() { + const { templateId, isBlockTheme } = useSelect( ( select ) => { + const { getCurrentTemplateId, getEditorSettings } = + select( editorStore ); + return { + templateId: getCurrentTemplateId(), + isBlockTheme: getEditorSettings().__unstableIsBlockBasedTheme, + }; + }, [] ); + + const isVisible = true; + useSelect( ( select ) => { + const postTypeSlug = select( editorStore ).getCurrentPostType(); + const postType = select( coreStore ).getPostType( postTypeSlug ); + if ( ! postType?.viewable ) { + return false; + } + + const settings = select( editorStore ).getEditorSettings(); + const hasTemplates = + !! settings.availableTemplates && + Object.keys( settings.availableTemplates ).length > 0; + if ( hasTemplates ) { + return true; + } + + if ( ! settings.supportsTemplateMode ) { + return false; + } + + const canCreateTemplates = + select( coreStore ).canUser( 'create', 'templates' ) ?? false; + return canCreateTemplates; + }, [] ); + + if ( ! isBlockTheme && isVisible ) { + return ( + <PostPanelRow label={ __( 'Template' ) }> + <ClassicThemeControl /> + </PostPanelRow> + ); + } + + if ( isBlockTheme && !! templateId ) { + return ( + <PostPanelRow label={ __( 'Template' ) }> + <BlockThemeControl id={ templateId } /> + </PostPanelRow> + ); + } + return null; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js b/packages/editor/src/components/post-template/reset-default-template.js similarity index 69% rename from packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js rename to packages/editor/src/components/post-template/reset-default-template.js index 795477cc8fc7c..c730f6b06dee8 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js +++ b/packages/editor/src/components/post-template/reset-default-template.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { MenuGroup, MenuItem } from '@wordpress/components'; +import { MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; @@ -25,21 +25,19 @@ export default function ResetDefaultTemplate( { onClick } ) { return null; } return ( - <MenuGroup> - <MenuItem - onClick={ async () => { - editEntityRecord( - 'postType', - postType, - postId, - { template: '' }, - { undoIgnore: true } - ); - onClick(); - } } - > - { __( 'Use default template' ) } - </MenuItem> - </MenuGroup> + <MenuItem + onClick={ () => { + editEntityRecord( + 'postType', + postType, + postId, + { template: '' }, + { undoIgnore: true } + ); + onClick(); + } } + > + { __( 'Use default template' ) } + </MenuItem> ); } diff --git a/packages/editor/src/components/post-template/style.scss b/packages/editor/src/components/post-template/style.scss new file mode 100644 index 0000000000000..c969654a53265 --- /dev/null +++ b/packages/editor/src/components/post-template/style.scss @@ -0,0 +1,52 @@ +.editor-post-template__swap-template-modal { + z-index: z-index(".editor-post-template__swap-template-modal"); +} + +.editor-post-template__swap-template-modal-content .block-editor-block-patterns-list { + column-count: 2; + column-gap: $grid-unit-30; + + // Small top padding required to avoid cutting off the visible outline when hovering items + padding-top: $border-width-focus-fallback; + + @include break-medium() { + column-count: 3; + } + + @include break-wide() { + column-count: 4; + } + + .block-editor-block-patterns-list__list-item { + break-inside: avoid-column; + } + + .block-editor-block-patterns-list__item { + // Avoid to override the BlockPatternList component + // default hover and focus styles. + &:not(:focus):not(:hover) .block-editor-block-preview__container { + box-shadow: 0 0 0 1px $gray-300; + } + } +} + +.editor-post-template__dropdown { + .components-popover__content { + min-width: 240px; + } + .components-button.is-pressed, + .components-button.is-pressed:hover { + background: inherit; + color: inherit; + } +} + +.editor-post-template__create-form { + @include break-medium() { + width: $grid-unit * 40; + } +} + +.editor-post-template__classic-theme-dropdown { + padding: $grid-unit-10; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js b/packages/editor/src/components/post-template/swap-template-button.js similarity index 94% rename from packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js rename to packages/editor/src/components/post-template/swap-template-button.js index 40eb1c5c4bd62..240dee42214d5 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js +++ b/packages/editor/src/components/post-template/swap-template-button.js @@ -47,10 +47,10 @@ export default function SwapTemplateButton( { onClick } ) { <Modal title={ __( 'Choose a template' ) } onRequestClose={ onClose } - overlayClassName="edit-site-swap-template-modal" + overlayClassName="editor-post-template__swap-template-modal" isFullScreen > - <div className="edit-site-page-panels__swap-template__modal-content"> + <div className="editor-post-template__swap-template-modal-content"> <TemplatesList onSelect={ onTemplateSelect } /> </div> </Modal> diff --git a/packages/editor/src/components/post-title/index.native.js b/packages/editor/src/components/post-title/index.native.js index 1ec0dd3ade3bf..6d905e743581e 100644 --- a/packages/editor/src/components/post-title/index.native.js +++ b/packages/editor/src/components/post-title/index.native.js @@ -155,7 +155,6 @@ class PostTitle extends Component { tagsToEliminate={ [ 'strong' ] } unstableOnFocus={ this.props.onSelect } onBlur={ this.props.onBlur } // Always assign onBlur as a props. - multiline={ false } style={ titleStyles } styles={ styles } fontSize={ 24 } diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js new file mode 100644 index 0000000000000..b7d64f2eeebc6 --- /dev/null +++ b/packages/editor/src/components/preview-dropdown/index.js @@ -0,0 +1,136 @@ +/** + * WordPress dependencies + */ +import { useViewportMatch } from '@wordpress/compose'; +import { + DropdownMenu, + MenuGroup, + MenuItem, + VisuallyHidden, + Icon, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { check, desktop, mobile, tablet, external } from '@wordpress/icons'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../../store'; +import PostPreviewButton from '../post-preview-button'; + +export default function PreviewDropdown( { + showIconLabels, + forceIsAutosaveable, + disabled, +} ) { + const { deviceType, homeUrl, isTemplate, isViewable } = useSelect( + ( select ) => { + const { getDeviceType, getCurrentPostType } = select( editorStore ); + const { getUnstableBase, getPostType } = select( coreStore ); + const _currentPostType = getCurrentPostType(); + return { + deviceType: getDeviceType(), + homeUrl: getUnstableBase()?.home, + isTemplate: _currentPostType === 'wp_template', + isViewable: getPostType( _currentPostType )?.viewable ?? false, + }; + }, + [] + ); + const { setDeviceType } = useDispatch( editorStore ); + const isMobile = useViewportMatch( 'medium', '<' ); + if ( isMobile ) return null; + + const popoverProps = { + placement: 'bottom-end', + }; + const toggleProps = { + className: 'editor-preview-dropdown__toggle', + size: 'compact', + showTooltip: ! showIconLabels, + disabled, + __experimentalIsFocusable: disabled, + }; + const menuProps = { + 'aria-label': __( 'View options' ), + }; + + const deviceIcons = { + mobile, + tablet, + desktop, + }; + + return ( + <DropdownMenu + className="editor-preview-dropdown" + popoverProps={ popoverProps } + toggleProps={ toggleProps } + menuProps={ menuProps } + icon={ deviceIcons[ deviceType.toLowerCase() ] } + label={ __( 'View' ) } + disableOpenOnArrowDown={ disabled } + > + { ( { onClose } ) => ( + <> + <MenuGroup> + <MenuItem + onClick={ () => setDeviceType( 'Desktop' ) } + icon={ deviceType === 'Desktop' && check } + > + { __( 'Desktop' ) } + </MenuItem> + <MenuItem + onClick={ () => setDeviceType( 'Tablet' ) } + icon={ deviceType === 'Tablet' && check } + > + { __( 'Tablet' ) } + </MenuItem> + <MenuItem + onClick={ () => setDeviceType( 'Mobile' ) } + icon={ deviceType === 'Mobile' && check } + > + { __( 'Mobile' ) } + </MenuItem> + </MenuGroup> + { isTemplate && ( + <MenuGroup> + <MenuItem + href={ homeUrl } + target="_blank" + icon={ external } + onClick={ onClose } + > + { __( 'View site' ) } + <VisuallyHidden as="span"> + { + /* translators: accessibility text */ + __( '(opens in a new tab)' ) + } + </VisuallyHidden> + </MenuItem> + </MenuGroup> + ) } + { isViewable && ( + <MenuGroup> + <PostPreviewButton + className="editor-preview-dropdown__button-external" + role="menuitem" + forceIsAutosaveable={ forceIsAutosaveable } + textContent={ + <> + { __( 'Preview in new tab' ) } + <Icon icon={ external } /> + </> + } + onPreview={ onClose } + /> + </MenuGroup> + ) } + </> + ) } + </DropdownMenu> + ); +} diff --git a/packages/editor/src/components/preview-dropdown/style.scss b/packages/editor/src/components/preview-dropdown/style.scss new file mode 100644 index 0000000000000..43fa7cdd8ecd9 --- /dev/null +++ b/packages/editor/src/components/preview-dropdown/style.scss @@ -0,0 +1,5 @@ +.editor-preview-dropdown__button-external { + width: 100%; + display: flex; + justify-content: space-between; +} diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index dda536aec4f73..fc49339c2c13f 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -9,7 +9,6 @@ import { BlockEditorProvider, BlockContextProvider, privateApis as blockEditorPrivateApis, - store as blockEditorStore, } from '@wordpress/block-editor'; import { store as noticesStore } from '@wordpress/notices'; import { privateApis as editPatternsPrivateApis } from '@wordpress/patterns'; @@ -23,73 +22,13 @@ import { store as editorStore } from '../../store'; import useBlockEditorSettings from './use-block-editor-settings'; import { unlock } from '../../lock-unlock'; import DisableNonPageContentBlocks from './disable-non-page-content-blocks'; -import { PAGE_CONTENT_BLOCK_TYPES } from './constants'; +import NavigationBlockEditingMode from './navigation-block-editing-mode'; const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); const { PatternsMenuItems } = unlock( editPatternsPrivateApis ); const noop = () => {}; -/** - * For the Navigation block editor, we need to force the block editor to contentOnly for that block. - * - * Set block editing mode to contentOnly when entering Navigation focus mode. - * this ensures that non-content controls on the block will be hidden and thus - * the user can focus on editing the Navigation Menu content only. - * - * @param {string} navigationBlockClientId ClientId. - */ -function useForceFocusModeForNavigation( navigationBlockClientId ) { - const { setBlockEditingMode, unsetBlockEditingMode } = - useDispatch( blockEditorStore ); - - useEffect( () => { - if ( ! navigationBlockClientId ) { - return; - } - - setBlockEditingMode( navigationBlockClientId, 'contentOnly' ); - - return () => { - unsetBlockEditingMode( navigationBlockClientId ); - }; - }, [ - navigationBlockClientId, - unsetBlockEditingMode, - setBlockEditingMode, - ] ); -} - -/** - * Helper method to extract the post content block types from a template. - * - * @param {Array} blocks Template blocks. - * - * @return {Array} Flattened object. - */ -function extractPageContentBlockTypesFromTemplateBlocks( blocks ) { - const result = []; - for ( let i = 0; i < blocks.length; i++ ) { - // Since the Query Block could contain PAGE_CONTENT_BLOCK_TYPES block types, - // we skip it because we only want to render stand-alone page content blocks in the block list. - if ( blocks[ i ].name === 'core/query' ) { - continue; - } - if ( PAGE_CONTENT_BLOCK_TYPES.includes( blocks[ i ].name ) ) { - result.push( createBlock( blocks[ i ].name ) ); - } - if ( blocks[ i ].innerBlocks.length ) { - result.push( - ...extractPageContentBlockTypesFromTemplateBlocks( - blocks[ i ].innerBlocks - ) - ); - } - } - - return result; -} - /** * Depending on the post, template and template mode, * returns the appropriate blocks and change handlers for the block editor provider. @@ -125,36 +64,6 @@ function useBlockEditorProps( post, template, mode ) { } }, [ post.type, post.id ] ); - const maybePostOnlyBlocks = useMemo( () => { - if ( mode === 'post-only' ) { - const postContentBlocks = - extractPageContentBlockTypesFromTemplateBlocks( - templateBlocks - ); - return [ - createBlock( - 'core/group', - { - layout: { type: 'constrained' }, - style: { - spacing: { - margin: { - top: '4em', // Mimics the post editor. - }, - }, - }, - }, - postContentBlocks.length - ? postContentBlocks - : [ - createBlock( 'core/post-title' ), - createBlock( 'core/post-content' ), - ] - ), - ]; - } - }, [ templateBlocks, mode ] ); - // It is important that we don't create a new instance of blocks on every change // We should only create a new instance if the blocks them selves change, not a dependency of them. const blocks = useMemo( () => { @@ -162,33 +71,19 @@ function useBlockEditorProps( post, template, mode ) { return maybeNavigationBlocks; } - if ( maybePostOnlyBlocks ) { - return maybePostOnlyBlocks; - } - if ( rootLevelPost === 'template' ) { return templateBlocks; } return postBlocks; - }, [ - maybeNavigationBlocks, - maybePostOnlyBlocks, - rootLevelPost, - templateBlocks, - postBlocks, - ] ); + }, [ maybeNavigationBlocks, rootLevelPost, templateBlocks, postBlocks ] ); // Handle fallback to postBlocks outside of the above useMemo, to ensure // that constructed block templates that call `createBlock` are not generated // too frequently. This ensures that clientIds are stable. const disableRootLevelChanges = ( !! template && mode === 'template-locked' ) || - post.type === 'wp_navigation' || - mode === 'post-only'; - const navigationBlockClientId = - post.type === 'wp_navigation' && blocks && blocks[ 0 ]?.clientId; - useForceFocusModeForNavigation( navigationBlockClientId ); + post.type === 'wp_navigation'; if ( disableRootLevelChanges ) { return [ blocks, noop, noop ]; } @@ -269,11 +164,12 @@ export const ExperimentalEditorProvider = withRegistryProvider( updatePostLock, setupEditor, updateEditorSettings, - __experimentalTearDownEditor, - } = useDispatch( editorStore ); + setCurrentTemplateId, + setEditedPost, + setRenderingMode, + } = unlock( useDispatch( editorStore ) ); const { createWarningNotice } = useDispatch( noticesStore ); - // Initialize and tear down the editor. // Ideally this should be synced on each change and not just something you do once. useLayoutEffect( () => { // Assume that we don't need to initialize in the case of an error recovery. @@ -299,17 +195,28 @@ export const ExperimentalEditorProvider = withRegistryProvider( } ); } - - return () => { - __experimentalTearDownEditor(); - }; }, [] ); + // Synchronizes the active post with the state + useEffect( () => { + setEditedPost( post.type, post.id ); + }, [ post.type, post.id ] ); + // Synchronize the editor settings as they change. useEffect( () => { updateEditorSettings( settings ); }, [ settings, updateEditorSettings ] ); + // Synchronizes the active template with the state. + useEffect( () => { + setCurrentTemplateId( template?.id ); + }, [ template?.id, setCurrentTemplateId ] ); + + // Sets the right rendering mode when loading the editor. + useEffect( () => { + setRenderingMode( settings.defaultRenderingMode ?? 'post-only' ); + }, [ settings.defaultRenderingMode, setRenderingMode ] ); + if ( ! isReady ) { return null; } @@ -332,9 +239,12 @@ export const ExperimentalEditorProvider = withRegistryProvider( > { children } <PatternsMenuItems /> - { [ 'post-only', 'template-locked' ].includes( - mode - ) && <DisableNonPageContentBlocks /> } + { mode === 'template-locked' && ( + <DisableNonPageContentBlocks /> + ) } + { type === 'wp_navigation' && ( + <NavigationBlockEditingMode /> + ) } </BlockEditorProviderComponent> </BlockContextProvider> </EntityProvider> diff --git a/packages/editor/src/components/provider/index.native.js b/packages/editor/src/components/provider/index.native.js index 5fd6a4cdbb888..bbd710f031c84 100644 --- a/packages/editor/src/components/provider/index.native.js +++ b/packages/editor/src/components/provider/index.native.js @@ -27,6 +27,7 @@ import { parse, serialize, getUnregisteredTypeHandlerName, + getBlockType, createBlock, } from '@wordpress/blocks'; import { withDispatch, withSelect } from '@wordpress/data'; @@ -35,6 +36,7 @@ import { applyFilters } from '@wordpress/hooks'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { getGlobalStyles, getColorsAndGradients } from '@wordpress/components'; import { NEW_BLOCK_TYPES } from '@wordpress/block-library'; +import { __ } from '@wordpress/i18n'; const postTypeEntities = [ { name: 'post', baseURL: '/wp/v2/posts' }, @@ -94,6 +96,7 @@ class NativeEditorProvider extends Component { componentDidMount() { const { capabilities, + createErrorNotice, locale, hostAppNamespace, updateEditorSettings, @@ -136,17 +139,26 @@ class NativeEditorProvider extends Component { this.subscriptionParentMediaAppend = subscribeMediaAppend( ( payload ) => { const blockName = 'core/' + payload.mediaType; - const newBlock = createBlock( blockName, { - id: payload.mediaId, - [ payload.mediaType === 'image' ? 'url' : 'src' ]: - payload.mediaUrl, - } ); - - const indexAfterSelected = this.props.selectedBlockIndex + 1; - const insertionIndex = - indexAfterSelected || this.props.blockCount; - - this.props.insertBlock( newBlock, insertionIndex ); + const blockType = getBlockType( blockName ); + + if ( blockType && blockType?.name ) { + const newBlock = createBlock( blockType.name, { + id: payload.mediaId, + [ payload.mediaType === 'image' ? 'url' : 'src' ]: + payload.mediaUrl, + } ); + + const indexAfterSelected = + this.props.selectedBlockIndex + 1; + const insertionIndex = + indexAfterSelected || this.props.blockCount; + + this.props.insertBlock( newBlock, insertionIndex ); + } else { + createErrorNotice( + __( 'File type not supported as a media file.' ) + ); + } } ); @@ -389,7 +401,8 @@ const ComposedNativeProvider = compose( [ dispatch( blockEditorStore ); const { switchEditorMode } = dispatch( editPostStore ); const { addEntities, receiveEntityRecords } = dispatch( coreStore ); - const { createSuccessNotice } = dispatch( noticesStore ); + const { createSuccessNotice, createErrorNotice } = + dispatch( noticesStore ); return { updateBlockEditorSettings: updateSettings, @@ -397,6 +410,7 @@ const ComposedNativeProvider = compose( [ addEntities, insertBlock, createSuccessNotice, + createErrorNotice, editTitle( title ) { editPost( { title } ); }, diff --git a/packages/editor/src/components/provider/navigation-block-editing-mode.js b/packages/editor/src/components/provider/navigation-block-editing-mode.js new file mode 100644 index 0000000000000..f45de7c149d82 --- /dev/null +++ b/packages/editor/src/components/provider/navigation-block-editing-mode.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { useEffect } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * For the Navigation block editor, we need to force the block editor to contentOnly for that block. + * + * Set block editing mode to contentOnly when entering Navigation focus mode. + * this ensures that non-content controls on the block will be hidden and thus + * the user can focus on editing the Navigation Menu content only. + */ + +export default function NavigationBlockEditingMode() { + // In the navigation block editor, + // the navigation block is the only root block. + const blockClientId = useSelect( + ( select ) => select( blockEditorStore ).getBlockOrder()?.[ 0 ], + [] + ); + const { setBlockEditingMode, unsetBlockEditingMode } = + useDispatch( blockEditorStore ); + + useEffect( () => { + if ( ! blockClientId ) { + return; + } + + setBlockEditingMode( blockClientId, 'contentOnly' ); + + return () => { + unsetBlockEditingMode( blockClientId ); + }; + }, [ blockClientId, unsetBlockEditingMode, setBlockEditingMode ] ); +} diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index e2cbba7e6a757..de5d9cf43437d 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -96,6 +96,8 @@ function useBlockEditorSettings( settings, postType, postId ) { pageOnFront, pageForPosts, userPatternCategories, + restBlockPatterns, + restBlockPatternCategories, } = useSelect( ( select ) => { const isWeb = Platform.OS === 'web'; @@ -105,6 +107,8 @@ function useBlockEditorSettings( settings, postType, postId ) { getEntityRecord, getUserPatternCategories, getEntityRecords, + getBlockPatterns, + getBlockPatternCategories, } = select( coreStore ); const siteSettings = canUser( 'read', 'settings' ) @@ -127,6 +131,8 @@ function useBlockEditorSettings( settings, postType, postId ) { pageOnFront: siteSettings?.page_on_front, pageForPosts: siteSettings?.page_for_posts, userPatternCategories: getUserPatternCategories(), + restBlockPatterns: getBlockPatterns(), + restBlockPatternCategories: getBlockPatternCategories(), }; }, [ postType, postId ] @@ -139,15 +145,6 @@ function useBlockEditorSettings( settings, postType, postId ) { settings.__experimentalAdditionalBlockPatternCategories ?? // WP 6.0 settings.__experimentalBlockPatternCategories; // WP 5.9 - const { restBlockPatterns, restBlockPatternCategories } = useSelect( - ( select ) => ( { - restBlockPatterns: select( coreStore ).getBlockPatterns(), - restBlockPatternCategories: - select( coreStore ).getBlockPatternCategories(), - } ), - [] - ); - const blockPatterns = useMemo( () => [ diff --git a/packages/editor/src/hooks/index.js b/packages/editor/src/hooks/index.js index 6e0934d63c0cf..5a48ec1bf4956 100644 --- a/packages/editor/src/hooks/index.js +++ b/packages/editor/src/hooks/index.js @@ -3,3 +3,4 @@ */ import './custom-sources-backwards-compatibility'; import './default-autocompleters'; +import './pattern-partial-syncing'; diff --git a/packages/editor/src/hooks/pattern-partial-syncing.js b/packages/editor/src/hooks/pattern-partial-syncing.js new file mode 100644 index 0000000000000..40bd1e16dfc00 --- /dev/null +++ b/packages/editor/src/hooks/pattern-partial-syncing.js @@ -0,0 +1,73 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useBlockEditingMode } from '@wordpress/block-editor'; +import { hasBlockSupport } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as editorStore } from '../store'; +import { unlock } from '../lock-unlock'; + +const { + PartialSyncingControls, + PATTERN_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, +} = unlock( patternsPrivateApis ); + +/** + * Override the default edit UI to include a new block inspector control for + * assigning a partial syncing controls to supported blocks in the pattern editor. + * Currently, only the `core/paragraph` block is supported. + * + * @param {Component} BlockEdit Original component. + * + * @return {Component} Wrapped component. + */ +const withPartialSyncingControls = createHigherOrderComponent( + ( BlockEdit ) => ( props ) => { + const blockEditingMode = useBlockEditingMode(); + const hasCustomFieldsSupport = hasBlockSupport( + props.name, + '__experimentalConnections', + false + ); + const isEditingPattern = useSelect( + ( select ) => + select( editorStore ).getCurrentPostType() === + PATTERN_TYPES.user, + [] + ); + + const shouldShowPartialSyncingControls = + hasCustomFieldsSupport && + props.isSelected && + isEditingPattern && + blockEditingMode === 'default' && + Object.keys( PARTIAL_SYNCING_SUPPORTED_BLOCKS ).includes( + props.name + ); + + return ( + <> + <BlockEdit { ...props } /> + { shouldShowPartialSyncingControls && ( + <PartialSyncingControls { ...props } /> + ) } + </> + ); + } +); + +if ( window.__experimentalPatternPartialSyncing ) { + addFilter( + 'editor.BlockEdit', + 'core/editor/with-partial-syncing-controls', + withPartialSyncingControls + ); +} diff --git a/packages/editor/src/private-apis.js b/packages/editor/src/private-apis.js index a44720eb93ac8..ac5bd4324946e 100644 --- a/packages/editor/src/private-apis.js +++ b/packages/editor/src/private-apis.js @@ -1,17 +1,21 @@ /** * Internal dependencies */ +import EditorCanvas from './components/editor-canvas'; import { ExperimentalEditorProvider } from './components/provider'; import { lock } from './lock-unlock'; import { EntitiesSavedStatesExtensible } from './components/entities-saved-states'; import useBlockEditorSettings from './components/provider/use-block-editor-settings'; import PostPanelRow from './components/post-panel-row'; +import PreviewDropdown from './components/preview-dropdown'; export const privateApis = {}; lock( privateApis, { + EditorCanvas, ExperimentalEditorProvider, EntitiesSavedStatesExtensible, PostPanelRow, + PreviewDropdown, // This is a temporary private API while we're updating the site editor to use EditorProvider. useBlockEditorSettings, diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 0c946d4124f49..8fe0822e6a016 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -36,7 +36,7 @@ import { export const setupEditor = ( post, edits, template ) => ( { dispatch } ) => { - dispatch.setupEditorState( post ); + dispatch.setEditedPost( post.type, post.id ); // Apply a template for new posts only, if exists. const isNewPost = post.status === 'auto-draft'; if ( isNewPost && template ) { @@ -70,10 +70,18 @@ export const setupEditor = * Returns an action object signalling that the editor is being destroyed and * that any necessary state or side-effect cleanup should occur. * + * @deprecated + * * @return {Object} Action object. */ export function __experimentalTearDownEditor() { - return { type: 'TEAR_DOWN_EDITOR' }; + deprecated( + "wp.data.dispatch( 'core/editor' ).__experimentalTearDownEditor", + { + since: '6.5', + } + ); + return { type: 'DO_NOTHING' }; } /** @@ -109,17 +117,33 @@ export function updatePost() { } /** - * Returns an action object used to setup the editor state when first opening - * an editor. + * Setup the editor state. + * + * @deprecated * * @param {Object} post Post object. + */ +export function setupEditorState( post ) { + deprecated( "wp.data.dispatch( 'core/editor' ).setupEditorState", { + since: '6.5', + alternative: "wp.data.dispatch( 'core/editor' ).setEditedPost", + } ); + return setEditedPost( post.type, post.id ); +} + +/** + * Returns an action that sets the current post Type and post ID. + * + * @param {string} postType Post Type. + * @param {string} postId Post ID. * * @return {Object} Action object. */ -export function setupEditorState( post ) { +export function setEditedPost( postType, postId ) { return { - type: 'SETUP_EDITOR_STATE', - post, + type: 'SET_EDITED_POST', + postType, + postId, }; } @@ -560,8 +584,12 @@ export function updateEditorSettings( settings ) { */ export const setRenderingMode = ( mode ) => - ( { dispatch, registry } ) => { - registry.dispatch( blockEditorStore ).clearSelectedBlock(); + ( { dispatch, registry, select } ) => { + if ( select.__unstableIsEditorReady() ) { + // We clear the block selection but we also need to clear the selection from the core store. + registry.dispatch( blockEditorStore ).clearSelectedBlock(); + dispatch.editPost( { selection: undefined }, { undoIgnore: true } ); + } dispatch( { type: 'SET_RENDERING_MODE', @@ -569,6 +597,20 @@ export const setRenderingMode = } ); }; +/** + * Action that changes the width of the editing canvas. + * + * @param {string} deviceType + * + * @return {Object} Action object. + */ +export function setDeviceType( deviceType ) { + return { + type: 'SET_DEVICE_TYPE', + deviceType, + }; +} + /** * Backward compatibility */ diff --git a/packages/editor/src/store/defaults.js b/packages/editor/src/store/defaults.js index 38b79ad9a84cc..686888f91de3d 100644 --- a/packages/editor/src/store/defaults.js +++ b/packages/editor/src/store/defaults.js @@ -27,4 +27,5 @@ export const EDITOR_SETTINGS_DEFAULTS = { richEditingEnabled: true, codeEditingEnabled: true, enableCustomFields: undefined, + defaultRenderingMode: 'post-only', }; diff --git a/packages/editor/src/store/index.js b/packages/editor/src/store/index.js index baee4d9197d0c..ebd41354308e7 100644 --- a/packages/editor/src/store/index.js +++ b/packages/editor/src/store/index.js @@ -9,7 +9,9 @@ import { createReduxStore, register } from '@wordpress/data'; import reducer from './reducer'; import * as selectors from './selectors'; import * as actions from './actions'; +import * as privateActions from './private-actions'; import { STORE_NAME } from './constants'; +import { unlock } from '../lock-unlock'; /** * Post editor data store configuration. @@ -36,3 +38,4 @@ export const store = createReduxStore( STORE_NAME, { } ); register( store ); +unlock( store ).registerPrivateActions( privateActions ); diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js new file mode 100644 index 0000000000000..7ddeab5f35734 --- /dev/null +++ b/packages/editor/src/store/private-actions.js @@ -0,0 +1,61 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Returns an action object used to set which template is currently being used/edited. + * + * @param {string} id Template Id. + * + * @return {Object} Action object. + */ +export function setCurrentTemplateId( id ) { + return { + type: 'SET_CURRENT_TEMPLATE_ID', + id, + }; +} + +/** + * Create a block based template. + * + * @param {Object?} template Template to create and assign. + */ +export const createTemplate = + ( template ) => + async ( { select, dispatch, registry } ) => { + const savedTemplate = await registry + .dispatch( coreStore ) + .saveEntityRecord( 'postType', 'wp_template', template ); + registry + .dispatch( coreStore ) + .editEntityRecord( + 'postType', + select.getCurrentPostType(), + select.getCurrentPostId(), + { + template: savedTemplate.slug, + } + ); + registry + .dispatch( noticesStore ) + .createSuccessNotice( + __( "Custom template created. You're in template mode now." ), + { + type: 'snackbar', + actions: [ + { + label: __( 'Go back' ), + onClick: () => + dispatch.setRenderingMode( + select.getEditorSettings() + .defaultRenderingMode + ), + }, + ], + } + ); + }; diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 48356fd8e99e3..a4323b5967956 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -83,8 +83,17 @@ export function shouldOverwriteState( action, previousAction ) { export function postId( state = null, action ) { switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return action.post.id; + case 'SET_EDITED_POST': + return action.postId; + } + + return state; +} + +export function templateId( state = null, action ) { + switch ( action.type ) { + case 'SET_CURRENT_TEMPLATE_ID': + return action.id; } return state; @@ -92,8 +101,8 @@ export function postId( state = null, action ) { export function postType( state = null, action ) { switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return action.post.type; + case 'SET_EDITED_POST': + return action.postType; } return state; @@ -237,28 +246,6 @@ export function postAutosavingLock( state = {}, action ) { return state; } -/** - * Reducer returning whether the editor is ready to be rendered. - * The editor is considered ready to be rendered once - * the post object is loaded properly and the initial blocks parsed. - * - * @param {boolean} state - * @param {Object} action - * - * @return {boolean} Updated state. - */ -export function isReady( state = false, action ) { - switch ( action.type ) { - case 'SETUP_EDITOR_STATE': - return true; - - case 'TEAR_DOWN_EDITOR': - return false; - } - - return state; -} - /** * Reducer returning the post editor setting. * @@ -288,16 +275,34 @@ export function renderingMode( state = 'all', action ) { return state; } +/** + * Reducer returning the editing canvas device type. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function deviceType( state = 'Desktop', action ) { + switch ( action.type ) { + case 'SET_DEVICE_TYPE': + return action.deviceType; + } + + return state; +} + export default combineReducers( { postId, postType, + templateId, saving, deleting, postLock, template, postSavingLock, - isReady, editorSettings, postAutosavingLock, renderingMode, + deviceType, } ); diff --git a/packages/editor/src/store/reducer.native.js b/packages/editor/src/store/reducer.native.js index 991addd88620b..7566dfc5dfd03 100644 --- a/packages/editor/src/store/reducer.native.js +++ b/packages/editor/src/store/reducer.native.js @@ -13,7 +13,6 @@ import { postLock, postSavingLock, template, - isReady, editorSettings, } from './reducer.js'; @@ -87,7 +86,6 @@ export default combineReducers( { postLock, postSavingLock, template, - isReady, editorSettings, clipboard, notices, diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 78944335bd398..3b3f315812430 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -205,6 +205,17 @@ export function getCurrentPostId( state ) { return state.postId; } +/** + * Returns the template ID currently being rendered/edited + * + * @param {Object} state Global application state. + * + * @return {string?} Template ID. + */ +export function getCurrentTemplateId( state ) { + return state.templateId; +} + /** * Returns the number of revisions of the post currently being edited. * @@ -819,54 +830,54 @@ export function getEditedPostPreviewLink( state ) { * is a single block within the post and it is of a type known to match a * default post format. Returns null if the format cannot be determined. * - * @param {Object} state Global application state. - * * @return {?string} Suggested post format. */ -export function getSuggestedPostFormat( state ) { - const blocks = getEditorBlocks( state ); - - if ( blocks.length > 2 ) return null; - - let name; - // If there is only one block in the content of the post grab its name - // so we can derive a suitable post format from it. - if ( blocks.length === 1 ) { - name = blocks[ 0 ].name; - // Check for core/embed `video` and `audio` eligible suggestions. - if ( name === 'core/embed' ) { - const provider = blocks[ 0 ].attributes?.providerNameSlug; - if ( [ 'youtube', 'vimeo' ].includes( provider ) ) { - name = 'core/video'; - } else if ( [ 'spotify', 'soundcloud' ].includes( provider ) ) { - name = 'core/audio'; +export const getSuggestedPostFormat = createRegistrySelector( + ( select ) => () => { + const blocks = select( blockEditorStore ).getBlocks(); + + if ( blocks.length > 2 ) return null; + + let name; + // If there is only one block in the content of the post grab its name + // so we can derive a suitable post format from it. + if ( blocks.length === 1 ) { + name = blocks[ 0 ].name; + // Check for core/embed `video` and `audio` eligible suggestions. + if ( name === 'core/embed' ) { + const provider = blocks[ 0 ].attributes?.providerNameSlug; + if ( [ 'youtube', 'vimeo' ].includes( provider ) ) { + name = 'core/video'; + } else if ( [ 'spotify', 'soundcloud' ].includes( provider ) ) { + name = 'core/audio'; + } } } - } - // If there are two blocks in the content and the last one is a text blocks - // grab the name of the first one to also suggest a post format from it. - if ( blocks.length === 2 && blocks[ 1 ].name === 'core/paragraph' ) { - name = blocks[ 0 ].name; - } + // If there are two blocks in the content and the last one is a text blocks + // grab the name of the first one to also suggest a post format from it. + if ( blocks.length === 2 && blocks[ 1 ].name === 'core/paragraph' ) { + name = blocks[ 0 ].name; + } - // We only convert to default post formats in core. - switch ( name ) { - case 'core/image': - return 'image'; - case 'core/quote': - case 'core/pullquote': - return 'quote'; - case 'core/gallery': - return 'gallery'; - case 'core/video': - return 'video'; - case 'core/audio': - return 'audio'; - default: - return null; + // We only convert to default post formats in core. + switch ( name ) { + case 'core/image': + return 'image'; + case 'core/quote': + case 'core/pullquote': + return 'quote'; + case 'core/gallery': + return 'gallery'; + case 'core/video': + return 'video'; + case 'core/audio': + return 'audio'; + default: + return null; + } } -} +); /** * Returns the content of the post being edited. @@ -1174,7 +1185,7 @@ export function getEditorSelection( state ) { * @return {boolean} is Ready. */ export function __unstableIsEditorReady( state ) { - return state.isReady; + return !! state.postId; } /** @@ -1199,6 +1210,17 @@ export function getRenderingMode( state ) { return state.renderingMode; } +/** + * Returns the current editing canvas device type. + * + * @param {Object} state Global application state. + * + * @return {string} Device type. + */ +export function getDeviceType( state ) { + return state.deviceType; +} + /* * Backward compatibility */ diff --git a/packages/editor/src/store/test/selectors.js b/packages/editor/src/store/test/selectors.js index c18377c4e385e..211ff717c88bd 100644 --- a/packages/editor/src/store/test/selectors.js +++ b/packages/editor/src/store/test/selectors.js @@ -122,6 +122,10 @@ selectorNames.forEach( ( name ) => { }, }; }, + + getBlocks() { + return state.getBlocks && state.getBlocks(); + }, } ); selectorNames.forEach( ( otherName ) => { @@ -2155,16 +2159,9 @@ describe( 'selectors', () => { describe( 'getSuggestedPostFormat', () => { it( 'returns null if cannot be determined', () => { const state = { - editor: { - present: { - blocks: { - value: [], - }, - edits: {}, - }, + getBlocks() { + return []; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); @@ -2172,77 +2169,56 @@ describe( 'selectors', () => { it( 'return null if only one block of type `core/embed` and provider not matched', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'instagram', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'instagram', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); } ); it( 'return null if only one block of type `core/embed` and provider not exists', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); } ); it( 'returns null if there is more than one block in the post', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 123, - name: 'core/image', - attributes: {}, - innerBlocks: [], - }, - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 123, + name: 'core/image', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], + }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBeNull(); @@ -2250,23 +2226,16 @@ describe( 'selectors', () => { it( 'returns Image if the first block is of type `core/image`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 123, - name: 'core/image', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 123, + name: 'core/image', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'image' ); @@ -2274,23 +2243,16 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); @@ -2298,25 +2260,18 @@ describe( 'selectors', () => { it( 'returns Video if the first block is of type `core/embed from youtube`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'youtube', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'youtube', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'video' ); @@ -2324,25 +2279,18 @@ describe( 'selectors', () => { it( 'returns Audio if the first block is of type `core/embed from soundcloud`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 567, - name: 'core/embed', - attributes: { - providerNameSlug: 'soundcloud', - }, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 567, + name: 'core/embed', + attributes: { + providerNameSlug: 'soundcloud', + }, + innerBlocks: [], }, - edits: {}, - }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'audio' ); @@ -2350,29 +2298,22 @@ describe( 'selectors', () => { it( 'returns Quote if the first block is of type `core/quote` and second is of type `core/paragraph`', () => { const state = { - editor: { - present: { - blocks: { - value: [ - { - clientId: 456, - name: 'core/quote', - attributes: {}, - innerBlocks: [], - }, - { - clientId: 789, - name: 'core/paragraph', - attributes: {}, - innerBlocks: [], - }, - ], + getBlocks() { + return [ + { + clientId: 456, + name: 'core/quote', + attributes: {}, + innerBlocks: [], }, - edits: {}, - }, + { + clientId: 789, + name: 'core/paragraph', + attributes: {}, + innerBlocks: [], + }, + ]; }, - initialEdits: {}, - currentPost: {}, }; expect( getSuggestedPostFormat( state ) ).toBe( 'quote' ); diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 986cb645c271f..50359984af162 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -1,4 +1,5 @@ @import "./components/autocompleters/style.scss"; +@import "./components/document-bar/style.scss"; @import "./components/document-outline/style.scss"; @import "./components/editor-notices/style.scss"; @import "./components/entities-saved-states/style.scss"; @@ -16,10 +17,12 @@ @import "./components/post-schedule/style.scss"; @import "./components/post-sync-status/style.scss"; @import "./components/post-taxonomies/style.scss"; +@import "./components/post-template/style.scss"; @import "./components/post-text-editor/style.scss"; @import "./components/post-title/style.scss"; @import "./components/post-url/style.scss"; @import "./components/post-visibility/style.scss"; @import "./components/post-trash/style.scss"; +@import "./components/preview-dropdown/style.scss"; @import "./components/table-of-contents/style.scss"; @import "./components/template-validation-notice/style.scss"; diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index aeae4f73e766f..8b39bea46f785 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Breaking Change + +- Update Docker usage to `docker compose` V2 following [deprecation](https://docs.docker.com/compose/migrate/) of `docker-compose` V1. + ## 8.13.0 (2023-11-29) ## 8.12.0 (2023-11-16) diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index 1788315b60b9d..896df6cd59fed 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -58,10 +58,10 @@ const withSpinner = 'err' in error && 'out' in error ) { - // Error is a docker-compose error. That means something docker-related failed. + // Error is a docker compose error. That means something docker-related failed. // https://github.com/PDMLab/docker-compose/blob/HEAD/src/index.ts spinner.fail( - 'Error while running docker-compose command.' + 'Error while running docker compose command.' ); if ( error.out ) { process.stdout.write( error.out ); diff --git a/packages/env/lib/commands/clean.js b/packages/env/lib/commands/clean.js index e3977b3b63b8c..587080eee99db 100644 --- a/packages/env/lib/commands/clean.js +++ b/packages/env/lib/commands/clean.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); /** * Internal dependencies diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index fbbff0c8a2898..20f76250271a9 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); const util = require( 'util' ); const fs = require( 'fs' ).promises; const path = require( 'path' ); diff --git a/packages/env/lib/commands/logs.js b/packages/env/lib/commands/logs.js index 3a749b20b3dab..b581835b2f994 100644 --- a/packages/env/lib/commands/logs.js +++ b/packages/env/lib/commands/logs.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); /** * Internal dependencies diff --git a/packages/env/lib/commands/run.js b/packages/env/lib/commands/run.js index def29b6523139..88dc99374afbe 100644 --- a/packages/env/lib/commands/run.js +++ b/packages/env/lib/commands/run.js @@ -74,10 +74,13 @@ function spawnCommandDirectly( config, container, command, envCwd, spinner ) { container === 'mysql' || container === 'tests-mysql' ? '/' : '/var/www/html', - envCwd + // Remove spaces and single quotes from both ends of the path. + // This is needed because Windows treats single quotes as a literal character. + envCwd.trim().replace( /^'|'$/g, '' ) ); const composeCommand = [ + 'compose', '-f', config.dockerComposeConfigPath, 'exec', @@ -98,7 +101,7 @@ function spawnCommandDirectly( config, container, command, envCwd, spinner ) { // cannot use it to spawn an interactive command. Thus, we run docker- // compose on the CLI directly. const childProc = spawn( - 'docker-compose', + 'docker', composeCommand, { stdio: 'inherit' }, spinner diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index 2765e9c4e3198..4203ac7463228 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); const util = require( 'util' ); const path = require( 'path' ); const fs = require( 'fs' ).promises; diff --git a/packages/env/lib/commands/stop.js b/packages/env/lib/commands/stop.js index 3700c3f2aa581..5393ef8c6a000 100644 --- a/packages/env/lib/commands/stop.js +++ b/packages/env/lib/commands/stop.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); /** * Internal dependencies diff --git a/packages/env/lib/test/cli.js b/packages/env/lib/test/cli.js index ba850e3259f4c..542aea598a42f 100644 --- a/packages/env/lib/test/cli.js +++ b/packages/env/lib/test/cli.js @@ -138,7 +138,7 @@ describe( 'env cli', () => { await env.start.mock.results[ 0 ].value.catch( () => {} ); expect( spinner.fail ).toHaveBeenCalledWith( - 'Error while running docker-compose command.' + 'Error while running docker compose command.' ); expect( process.stderr.write ).toHaveBeenCalledWith( 'failure error' ); expect( process.exit ).toHaveBeenCalledWith( 1 ); diff --git a/packages/env/lib/wordpress.js b/packages/env/lib/wordpress.js index e8c20aa70f215..423547fad688b 100644 --- a/packages/env/lib/wordpress.js +++ b/packages/env/lib/wordpress.js @@ -2,7 +2,7 @@ /** * External dependencies */ -const dockerCompose = require( 'docker-compose' ); +const { v2: dockerCompose } = require( 'docker-compose' ); const util = require( 'util' ); const fs = require( 'fs' ).promises; const path = require( 'path' ); diff --git a/packages/env/package.json b/packages/env/package.json index 94ee81a31d59b..cb362b6c9f3d1 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -34,7 +34,7 @@ "dependencies": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", - "docker-compose": "^0.22.2", + "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", "inquirer": "^7.1.0", diff --git a/packages/format-library/src/text-color/index.native.js b/packages/format-library/src/text-color/index.native.js index 7c44e4efc001a..c19f2a3700ea7 100644 --- a/packages/format-library/src/text-color/index.native.js +++ b/packages/format-library/src/text-color/index.native.js @@ -33,9 +33,7 @@ const name = 'core/text-color'; const title = __( 'Text color' ); function getComputedStyleProperty( element, property ) { - const { - props: { style = {} }, - } = element; + const style = element?.props?.style ?? {}; if ( property === 'background-color' ) { const { backgroundColor, baseColors } = style; diff --git a/packages/format-library/src/text-color/test/index.native.js b/packages/format-library/src/text-color/test/index.native.js index c7350cfe4bb6c..2ff9b5edfac21 100644 --- a/packages/format-library/src/text-color/test/index.native.js +++ b/packages/format-library/src/text-color/test/index.native.js @@ -2,17 +2,29 @@ * External dependencies */ import { + fireEvent, getEditorHtml, initializeEditor, - fireEvent, + render, waitFor, } from 'test/helpers'; /** * WordPress dependencies */ -import { setDefaultBlockName, unregisterBlockType } from '@wordpress/blocks'; +import { + registerBlockType, + setDefaultBlockName, + unregisterBlockType, +} from '@wordpress/blocks'; import { coreBlocks } from '@wordpress/block-library'; +import { BlockControls, BlockEdit } from '@wordpress/block-editor'; +import { SlotFillProvider } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { textColor } from '..'; const COLOR_PINK = '#f78da7'; const paragraph = coreBlocks[ 'core/paragraph' ]; @@ -164,4 +176,52 @@ describe( 'Text color', () => { expect( getEditorHtml() ).toMatchSnapshot(); } ); + + it( 'renders when "contentRef" is undefined', () => { + registerBlockType( 'core/test-block', { + save: () => {}, + category: 'text', + title: 'block title', + edit: ( { children } ) => <>{ children }</>, + } ); + const TextColorEdit = textColor.edit; + // Empty text with black color set as the text color + const textValue = { + formats: [], + replacements: [], + text: '', + start: 0, + end: 0, + activeFormats: [ + { + type: 'core/text-color', + attributes: { + style: 'background-color:rgba(0, 0, 0, 0);color:#111111', + class: 'has-contrast-color', + }, + }, + ], + }; + + const { getByLabelText } = render( + <SlotFillProvider> + <BlockEdit name="core/test-block" isSelected mayDisplayControls> + <TextColorEdit + isActive={ true } + activeAttributes={ {} } + value={ textValue } + onChange={ jest.fn() } + // This ref is usually defined by the `RichText` component. + // However, there are rare cases (probably related to slow performance + // in low-end devices) where it's undefined upon mounting. + contentRef={ undefined } + /> + </BlockEdit> + <BlockControls.Slot /> + </SlotFillProvider> + ); + + const textColorButton = getByLabelText( 'Text color' ); + expect( textColorButton ).toBeDefined(); + } ); } ); diff --git a/packages/icons/src/index.js b/packages/icons/src/index.js index bb078b348e604..36b2971423442 100644 --- a/packages/icons/src/index.js +++ b/packages/icons/src/index.js @@ -238,6 +238,7 @@ export { default as swatch } from './library/swatch'; export { default as tableColumnAfter } from './library/table-column-after'; export { default as tableColumnBefore } from './library/table-column-before'; export { default as tableColumnDelete } from './library/table-column-delete'; +export { default as tableOfContents } from './library/table-of-contents'; export { default as tableRowAfter } from './library/table-row-after'; export { default as tableRowBefore } from './library/table-row-before'; export { default as tableRowDelete } from './library/table-row-delete'; diff --git a/packages/icons/src/library/archive.js b/packages/icons/src/library/archive.js index d63516f728625..f8429667408cb 100644 --- a/packages/icons/src/library/archive.js +++ b/packages/icons/src/library/archive.js @@ -5,7 +5,11 @@ import { SVG, Path } from '@wordpress/primitives'; const archive = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M19 6.2h-5.9l-.6-1.1c-.3-.7-1-1.1-1.8-1.1H5c-1.1 0-2 .9-2 2v11.8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8.2c0-1.1-.9-2-2-2zm.5 11.6c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V6c0-.3.2-.5.5-.5h5.8c.2 0 .4.1.4.3l1 2H19c.3 0 .5.2.5.5v9.5zM8 12.8h8v-1.5H8v1.5zm0 3h8v-1.5H8v1.5z" /> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M11.934 7.406a1 1 0 0 0 .914.594H19a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h5.764a.5.5 0 0 1 .447.276l.723 1.63Zm1.064-1.216a.5.5 0 0 0 .462.31H19a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.764a2 2 0 0 1 1.789 1.106l.445 1.084ZM8.5 10.5h7V12h-7v-1.5Zm7 3.5h-7v1.5h7V14Z" + /> </SVG> ); diff --git a/packages/icons/src/library/columns.js b/packages/icons/src/library/columns.js index 994c32467f472..0d6cd87779c0d 100644 --- a/packages/icons/src/library/columns.js +++ b/packages/icons/src/library/columns.js @@ -5,7 +5,11 @@ import { Path, SVG } from '@wordpress/primitives'; const columns = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M19 6H6c-1.1 0-2 .9-2 2v9c0 1.1.9 2 2 2h13c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-4.1 1.5v10H10v-10h4.9zM5.5 17V8c0-.3.2-.5.5-.5h2.5v10H6c-.3 0-.5-.2-.5-.5zm14 0c0 .3-.2.5-.5.5h-2.6v-10H19c.3 0 .5.2.5.5v9z" /> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M15 7.5h-5v10h5v-10Zm1.5 0v10H19a.5.5 0 0 0 .5-.5V8a.5.5 0 0 0-.5-.5h-2.5ZM6 7.5h2.5v10H6a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5ZM6 6h13a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Z" + /> </SVG> ); diff --git a/packages/icons/src/library/copy.js b/packages/icons/src/library/copy.js index 981865c13a327..5c9e6c9704deb 100644 --- a/packages/icons/src/library/copy.js +++ b/packages/icons/src/library/copy.js @@ -5,7 +5,11 @@ import { SVG, Path } from '@wordpress/primitives'; const copy = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M20.2 8v11c0 .7-.6 1.2-1.2 1.2H6v1.5h13c1.5 0 2.7-1.2 2.7-2.8V8zM18 16.4V4.6c0-.9-.7-1.6-1.6-1.6H4.6C3.7 3 3 3.7 3 4.6v11.8c0 .9.7 1.6 1.6 1.6h11.8c.9 0 1.6-.7 1.6-1.6zm-13.5 0V4.6c0-.1.1-.1.1-.1h11.8c.1 0 .1.1.1.1v11.8c0 .1-.1.1-.1.1H4.6l-.1-.1z" /> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M5 4.5h11a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V5a.5.5 0 0 1 .5-.5ZM3 5a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm17 3v10.75c0 .69-.56 1.25-1.25 1.25H6v1.5h12.75a2.75 2.75 0 0 0 2.75-2.75V8H20Z" + /> </SVG> ); diff --git a/packages/icons/src/library/crop.js b/packages/icons/src/library/crop.js index 0a293b24a5bde..9f4a25f5f0d44 100644 --- a/packages/icons/src/library/crop.js +++ b/packages/icons/src/library/crop.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const crop = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M16.5 7.8v7H18v-7c0-1-.8-1.8-1.8-1.8h-7v1.5h7c.2 0 .3.1.3.3zm-8.7 8.7c-.1 0-.2-.1-.2-.2V2H6v4H2v1.5h4v8.8c0 1 .8 1.8 1.8 1.8h8.8v4H18v-4h4v-1.5H7.8z" /> + <Path d="M18 20v-2h2v-1.5H7.75a.25.25 0 0 1-.25-.25V4H6v2H4v1.5h2v8.75c0 .966.784 1.75 1.75 1.75h8.75v2H18ZM9.273 7.5h6.977a.25.25 0 0 1 .25.25v6.977H18V7.75A1.75 1.75 0 0 0 16.25 6H9.273v1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/file.js b/packages/icons/src/library/file.js index 3d87df413da1b..6b7d29b3ae923 100644 --- a/packages/icons/src/library/file.js +++ b/packages/icons/src/library/file.js @@ -5,7 +5,11 @@ import { Path, SVG } from '@wordpress/primitives'; const file = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M19 6.2h-5.9l-.6-1.1c-.3-.7-1-1.1-1.8-1.1H5c-1.1 0-2 .9-2 2v11.8c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V8.2c0-1.1-.9-2-2-2zm.5 11.6c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V6c0-.3.2-.5.5-.5h5.8c.2 0 .4.1.4.3l1 2H19c.3 0 .5.2.5.5v9.5z" /> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M12.848 8a1 1 0 0 1-.914-.594l-.723-1.63a.5.5 0 0 0-.447-.276H5a.5.5 0 0 0-.5.5v11.5a.5.5 0 0 0 .5.5h14a.5.5 0 0 0 .5-.5v-9A.5.5 0 0 0 19 8h-6.152Zm.612-1.5a.5.5 0 0 1-.462-.31l-.445-1.084A2 2 0 0 0 10.763 4H5a2 2 0 0 0-2 2v11.5a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-5.54Z" + /> </SVG> ); diff --git a/packages/icons/src/library/page.js b/packages/icons/src/library/page.js index 9f5338f46aea2..e2439254f1418 100644 --- a/packages/icons/src/library/page.js +++ b/packages/icons/src/library/page.js @@ -5,7 +5,8 @@ import { SVG, Path } from '@wordpress/primitives'; const page = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M7 5.5h10a.5.5 0 01.5.5v12a.5.5 0 01-.5.5H7a.5.5 0 01-.5-.5V6a.5.5 0 01.5-.5zM17 4H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2zm-1 3.75H8v1.5h8v-1.5zM8 11h8v1.5H8V11zm6 3.25H8v1.5h6v-1.5z" /> + <Path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" /> + <Path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/pages.js b/packages/icons/src/library/pages.js index 7e7dff92cc71d..d038b21f109c8 100644 --- a/packages/icons/src/library/pages.js +++ b/packages/icons/src/library/pages.js @@ -5,7 +5,9 @@ import { SVG, Path } from '@wordpress/primitives'; const pages = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M7 13.8h6v-1.5H7v1.5zM18 16V4c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2zM5.5 16V4c0-.3.2-.5.5-.5h10c.3 0 .5.2.5.5v12c0 .3-.2.5-.5.5H6c-.3 0-.5-.2-.5-.5zM7 10.5h8V9H7v1.5zm0-3.3h8V5.8H7v1.4zM20.2 6v13c0 .7-.6 1.2-1.2 1.2H8v1.5h11c1.5 0 2.7-1.2 2.7-2.8V6h-1.5z" /> + <Path d="M14.5 5.5h-7V7h7V5.5ZM7.5 9h7v1.5h-7V9Zm7 3.5h-7V14h7v-1.5Z" /> + <Path d="M16 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2ZM6 3.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V4a.5.5 0 0 1 .5-.5Z" /> + <Path d="M20 8v11c0 .69-.31 1-.999 1H6v1.5h13.001c1.52 0 2.499-.982 2.499-2.5V8H20Z" /> </SVG> ); diff --git a/packages/icons/src/library/plus.js b/packages/icons/src/library/plus.js index 591aee12b2dfd..45f600c37c9c5 100644 --- a/packages/icons/src/library/plus.js +++ b/packages/icons/src/library/plus.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const plus = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M18 11.2h-5.2V6h-1.6v5.2H6v1.6h5.2V18h1.6v-5.2H18z" /> + <Path d="M11 12.5V17.5H12.5V12.5H17.5V11H12.5V6H11V11H6V12.5H11Z" /> </SVG> ); diff --git a/packages/icons/src/library/post-excerpt.js b/packages/icons/src/library/post-excerpt.js index 742e50d69240b..f2f2b11f00695 100644 --- a/packages/icons/src/library/post-excerpt.js +++ b/packages/icons/src/library/post-excerpt.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const postExcerpt = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M12.75 9.333c0 .521-.102.977-.327 1.354-.23.386-.555.628-.893.774-.545.234-1.183.227-1.544.222l-.12-.001v-1.5h.123c.414.001.715.002.948-.099a.395.395 0 00.199-.166c.05-.083.114-.253.114-.584V7.2H8.8V4h3.95v5.333zM7.95 9.333c0 .521-.102.977-.327 1.354-.23.386-.555.628-.893.774-.545.234-1.183.227-1.544.222l-.12-.001v-1.5h.123c.414.001.715.002.948-.099a.394.394 0 00.198-.166c.05-.083.115-.253.115-.584V7.2H4V4h3.95v5.333zM13 20H4v-1.5h9V20zM20 16H4v-1.5h16V16z" /> + <Path d="M8.001 3.984V9.47c0 1.518-.98 2.5-2.499 2.5h-.5v-1.5h.5c.69 0 1-.31 1-1V6.984H4v-3h4.001ZM4 20h9v-1.5H4V20Zm16-4H4v-1.5h16V16ZM13.001 3.984V9.47c0 1.518-.98 2.5-2.499 2.5h-.5v-1.5h.5c.69 0 1-.31 1-1V6.984H9v-3h4.001Z" /> </SVG> ); diff --git a/packages/icons/src/library/post-list.js b/packages/icons/src/library/post-list.js index 7656d492d17fd..32adce23053bc 100644 --- a/packages/icons/src/library/post-list.js +++ b/packages/icons/src/library/post-list.js @@ -5,7 +5,7 @@ import { Path, SVG } from '@wordpress/primitives'; const postList = ( <SVG viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <Path d="M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 14c0 .3-.2.5-.5.5H6c-.3 0-.5-.2-.5-.5V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v12zM7 11h2V9H7v2zm0 4h2v-2H7v2zm3-4h7V9h-7v2zm0 4h7v-2h-7v2z" /> + <Path d="M18 5.5H6a.5.5 0 0 0-.5.5v12a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5V6a.5.5 0 0 0-.5-.5ZM6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm1 5h1.5v1.5H7V9Zm1.5 4.5H7V15h1.5v-1.5ZM10 9h7v1.5h-7V9Zm7 4.5h-7V15h7v-1.5Z" /> </SVG> ); diff --git a/packages/icons/src/library/table-of-contents.js b/packages/icons/src/library/table-of-contents.js new file mode 100644 index 0000000000000..00e1ec1ab5801 --- /dev/null +++ b/packages/icons/src/library/table-of-contents.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/primitives'; + +const tableOfContents = ( + <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M20 9.484h-8.889v-1.5H20v1.5Zm0 7h-4.889v-1.5H20v1.5Zm-14 .032a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm0 1a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" + /> + <Path d="M13 15.516a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 8.484a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" /> + </SVG> +); + +export default tableOfContents; diff --git a/packages/icons/src/library/tag.js b/packages/icons/src/library/tag.js index 4054c59371c01..9714f7366378b 100644 --- a/packages/icons/src/library/tag.js +++ b/packages/icons/src/library/tag.js @@ -5,7 +5,7 @@ import { SVG, Path } from '@wordpress/primitives'; const tag = ( <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <Path d="M20.1 11.2l-6.7-6.7c-.1-.1-.3-.2-.5-.2H5c-.4-.1-.8.3-.8.7v7.8c0 .2.1.4.2.5l6.7 6.7c.2.2.5.4.7.5s.6.2.9.2c.3 0 .6-.1.9-.2.3-.1.5-.3.8-.5l5.6-5.6c.4-.4.7-1 .7-1.6.1-.6-.2-1.2-.6-1.6zM19 13.4L13.4 19c-.1.1-.2.1-.3.2-.2.1-.4.1-.6 0-.1 0-.2-.1-.3-.2l-6.5-6.5V5.8h6.8l6.5 6.5c.2.2.2.4.2.6 0 .1 0 .3-.2.5zM9 8c-.6 0-1 .4-1 1s.4 1 1 1 1-.4 1-1-.4-1-1-1z" /> + <Path d="M4.75 4a.75.75 0 0 0-.75.75v7.826c0 .2.08.39.22.53l6.72 6.716a2.313 2.313 0 0 0 3.276-.001l5.61-5.611-.531-.53.532.528a2.315 2.315 0 0 0 0-3.264L13.104 4.22a.75.75 0 0 0-.53-.22H4.75ZM19 12.576a.815.815 0 0 1-.236.574l-5.61 5.611a.814.814 0 0 1-1.153 0L5.5 12.264V5.5h6.763l6.5 6.502a.816.816 0 0 1 .237.574ZM8.75 9.75a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" /> </SVG> ); diff --git a/packages/interactivity/docs/1-getting-started.md b/packages/interactivity/docs/1-getting-started.md index 0b4708b78b350..85af202180735 100644 --- a/packages/interactivity/docs/1-getting-started.md +++ b/packages/interactivity/docs/1-getting-started.md @@ -6,13 +6,13 @@ To get started with the Interactivity API, you can follow this [**Quick Start Gu - [Quick Start Guide](#quick-start-guide) - [1. Scaffold an interactive block](#1-scaffold-an-interactive-block) - - [2. Generate the build](#2-generate-the-build) + - [2. Generate the build](#2-generate-the-build) - [3. Use it in your WordPress installation ](#3-use-it-in-your-wordpress-installation) - [Requirements of the Interactivity API](#requirements-of-the-interactivity-aPI) - [A local WordPress installation](#a-local-wordpress-installation) - [Latest vesion of Gutenberg](#latest-vesion-of-gutenberg) - [Node.js](#nodejs) - - [Code requirements](#code-requirements) + - [Code requirements](#code-requirements) - [Add `interactivity` support to `block.json`](#add-interactivity-support-to-blockjson) - [Add `wp-interactive` directive to a DOM element](#add-wp-interactive-directive-to-a-dom-element) @@ -23,26 +23,38 @@ To get started with the Interactivity API, you can follow this [**Quick Start Gu We can scaffold a WordPress plugin that registers an interactive block (using the Interactivity API) by using a [template](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) with the `@wordpress/create-block` command. ``` -npx @wordpress/create-block my-first-interactive-block --template @wordpress/create-block-interactive-template +npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template ``` -#### 2. Generate the build +> **Note** +> The Interactivity API recently switched from [using modules instead of scripts in the frontend](https://github.com/WordPress/gutenberg/pull/56143). Therefore, in order to test this scaffolded block, you will need to add the following line to the `package.json` file of the generated plugin: + +```json +"files": [ + "src/view.js" +] +``` +> This should be updated in the [scripts package](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/) soon. + -When the plugin folder is generated, we should launch the build process to get the final version of the interactive block that can be used from WordPress. + +#### 2. Generate the build + +When the plugin folder is generated, we should launch the build process to get the final version of the interactive block that can be used from WordPress. ``` cd my-first-interactive-block && npm start ``` -#### 3. Use it in your WordPress installation +#### 3. Use it in your WordPress installation If you have a local WordPress installation already running, you can launch the commands above inside the `plugins` folder of that installation. If not, you can use [`wp-now`](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) to launch a WordPress site with the plugin installed by executing from the generated folder (and from a different terminal window or tab) the following command ``` -npx @wp-now/wp-now start +npx @wp-now/wp-now start ``` -At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. +At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. > **Note** > We recommend you to also check the [API Reference](./2-api-reference.md) docs for your first exploration of the Interactivity API @@ -53,19 +65,19 @@ To start working with the Interactivity API you'll need to have a [proper WordPr #### A local WordPress installation -You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. +You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. -To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. +To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. #### Latest vesion of Gutenberg -The Interactivity API is currently only available as an experimental feature from Gutenberg 16.2, so you'll need to have Gutenberg 16.2 or higher version installed and activated in your WordPress installation. +The Interactivity API is currently only available as an experimental feature from Gutenberg 17.2, so you'll need to have Gutenberg 17.2 or higher version installed and activated in your WordPress installation. #### Node.js Block development requires [Node](https://nodejs.org/en), so you'll need to have Node installed and running on your machine. Any version modern should work, but please check the minimum version requirements if you run into any issues with any of the Node.js tools used in WordPress development. -#### Code requirements +#### Code requirements ##### Add `interactivity` support to `block.json` @@ -86,4 +98,4 @@ To "activate" the Interactivity API in a DOM element (and its children) we add t <div data-wp-interactive='{ "namespace": "myPlugin" }'> <!-- Interactivity API zone --> </div> -``` \ No newline at end of file +``` diff --git a/packages/interface/src/components/complementary-area/index.js b/packages/interface/src/components/complementary-area/index.js index 887c447d9291e..af6456bec0966 100644 --- a/packages/interface/src/components/complementary-area/index.js +++ b/packages/interface/src/components/complementary-area/index.js @@ -182,6 +182,7 @@ function ComplementaryArea( { icon={ showIconLabels ? check : icon } showTooltip={ ! showIconLabels } variant={ showIconLabels ? 'tertiary' : undefined } + size="compact" /> ) } </PinnedItems> diff --git a/packages/interface/src/components/more-menu-dropdown/index.js b/packages/interface/src/components/more-menu-dropdown/index.js index d6a32d57b05f8..dafa4b2e4b3b4 100644 --- a/packages/interface/src/components/more-menu-dropdown/index.js +++ b/packages/interface/src/components/more-menu-dropdown/index.js @@ -38,6 +38,7 @@ export default function MoreMenuDropdown( { toggleProps={ { tooltipPosition: 'bottom', ...toggleProps, + size: 'compact', } } > { ( onClose ) => children( onClose ) } diff --git a/packages/interface/src/components/pinned-items/style.scss b/packages/interface/src/components/pinned-items/style.scss index 693750644c62a..66062b7fa3dbb 100644 --- a/packages/interface/src/components/pinned-items/style.scss +++ b/packages/interface/src/components/pinned-items/style.scss @@ -26,7 +26,7 @@ } // Gap between pinned items. - gap: $grid-unit-05; + gap: $grid-unit-10; // Account for larger grid from parent container gap. margin-right: -$grid-unit-05; diff --git a/packages/interface/src/components/preferences-modal/README.md b/packages/interface/src/components/preferences-modal/README.md index 96ecdf03dcc13..f873ccf297ec1 100644 --- a/packages/interface/src/components/preferences-modal/README.md +++ b/packages/interface/src/components/preferences-modal/README.md @@ -28,11 +28,11 @@ function MyEditorPreferencesModal() { 'Review settings, such as visibility and tags.' ) } label={ __( - 'Include pre-publish checklist' + 'Enable pre-publish flow' ) } /> </PreferencesModalSection> - ) + ) } { @@ -47,7 +47,7 @@ function MyEditorPreferencesModal() { > // Section content here </PreferencesModalSection> - ) + ) } ] diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index c98ccc24cb5d8..9531b4980c1e2 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -28,8 +28,7 @@ "sideEffects": false, "dependencies": { "@babel/runtime": "^7.16.0", - "@wordpress/i18n": "file:../i18n", - "change-case": "^4.1.2" + "@wordpress/i18n": "file:../i18n" }, "publishConfig": { "access": "public" diff --git a/packages/keycodes/src/index.js b/packages/keycodes/src/index.js index a9efb210496c3..6b01109e2a40d 100644 --- a/packages/keycodes/src/index.js +++ b/packages/keycodes/src/index.js @@ -9,11 +9,6 @@ * shortcut combos directly to keyboardShortcut(). */ -/** - * External dependencies - */ -import { capitalCase } from 'change-case'; - /** * WordPress dependencies */ @@ -148,6 +143,17 @@ export const ZERO = 48; export { isAppleOS }; +/** + * Capitalise the first character of a string. + * @param {string} string String to capitalise. + * @return {string} Capitalised string. + */ +function capitaliseFirstCharacter( string ) { + return string.length < 2 + ? string.toUpperCase() + : string.charAt( 0 ).toUpperCase() + string.slice( 1 ); +} + /** * Map the values of an object with a specified callback and return the result object. * @@ -260,14 +266,7 @@ export const displayShortcutList = mapValues( /** @type {string[]} */ ( [] ) ); - // Symbols (~`,.) are removed by the default regular expression, - // so override the rule to allow symbols used for shortcuts. - // see: https://github.com/blakeembrey/change-case#options - const capitalizedCharacter = capitalCase( character, { - stripRegexp: /[^A-Z0-9~`,\.\\\-]/gi, - } ); - - return [ ...modifierKeys, capitalizedCharacter ]; + return [ ...modifierKeys, capitaliseFirstCharacter( character ) ]; }; } ); @@ -335,7 +334,7 @@ export const shortcutAriaLabel = mapValues( return [ ...modifier( _isApple ), character ] .map( ( key ) => - capitalCase( replacementKeyMap[ key ] ?? key ) + capitaliseFirstCharacter( replacementKeyMap[ key ] ?? key ) ) .join( isApple ? ' ' : ' + ' ); }; diff --git a/packages/media-utils/src/components/media-upload/index.js b/packages/media-utils/src/components/media-upload/index.js index bf2da5c470a5e..c62f755a27fb5 100644 --- a/packages/media-utils/src/components/media-upload/index.js +++ b/packages/media-utils/src/components/media-upload/index.js @@ -224,45 +224,13 @@ const getAttachmentsCollection = ( ids ) => { }; class MediaUpload extends Component { - constructor( { - allowedTypes, - gallery = false, - unstableFeaturedImageFlow = false, - modalClass, - multiple = false, - title = __( 'Select or Upload Media' ), - } ) { + constructor() { super( ...arguments ); this.openModal = this.openModal.bind( this ); this.onOpen = this.onOpen.bind( this ); this.onSelect = this.onSelect.bind( this ); this.onUpdate = this.onUpdate.bind( this ); this.onClose = this.onClose.bind( this ); - - const { wp } = window; - - if ( gallery ) { - this.buildAndSetGalleryFrame(); - } else { - const frameConfig = { - title, - multiple, - }; - if ( !! allowedTypes ) { - frameConfig.library = { type: allowedTypes }; - } - - this.frame = wp.media( frameConfig ); - } - - if ( modalClass ) { - this.frame.$el.addClass( modalClass ); - } - - if ( unstableFeaturedImageFlow ) { - this.buildAndSetFeatureImageFrame(); - } - this.initializeListeners(); } initializeListeners() { @@ -348,7 +316,7 @@ class MediaUpload extends Component { } componentWillUnmount() { - this.frame.remove(); + this.frame?.remove(); } onUpdate( selections ) { @@ -444,9 +412,38 @@ class MediaUpload extends Component { } openModal() { - if ( this.props.gallery ) { + const { + allowedTypes, + gallery = false, + unstableFeaturedImageFlow = false, + modalClass, + multiple = false, + title = __( 'Select or Upload Media' ), + } = this.props; + const { wp } = window; + + if ( gallery ) { this.buildAndSetGalleryFrame(); + } else { + const frameConfig = { + title, + multiple, + }; + if ( !! allowedTypes ) { + frameConfig.library = { type: allowedTypes }; + } + + this.frame = wp.media( frameConfig ); + } + + if ( modalClass ) { + this.frame.$el.addClass( modalClass ); + } + + if ( unstableFeaturedImageFlow ) { + this.buildAndSetFeatureImageFrame(); } + this.initializeListeners(); this.frame.open(); } diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 3b1cead6f71a1..2fa13bc3fdddf 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -44,7 +44,8 @@ "@wordpress/icons": "file:../icons", "@wordpress/notices": "file:../notices", "@wordpress/private-apis": "file:../private-apis", - "@wordpress/url": "file:../url" + "@wordpress/url": "file:../url", + "nanoid": "^3.3.4" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/patterns/src/components/partial-syncing-controls.js b/packages/patterns/src/components/partial-syncing-controls.js new file mode 100644 index 0000000000000..42c39ce69e87b --- /dev/null +++ b/packages/patterns/src/components/partial-syncing-controls.js @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { nanoid } from 'nanoid'; + +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { BaseControl, CheckboxControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PARTIAL_SYNCING_SUPPORTED_BLOCKS } from '../constants'; + +function PartialSyncingControls( { name, attributes, setAttributes } ) { + const syncedAttributes = PARTIAL_SYNCING_SUPPORTED_BLOCKS[ name ]; + + function updateConnections( attributeName, isChecked ) { + if ( ! isChecked ) { + let updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: undefined, + }, + }; + if ( Object.keys( updatedConnections.attributes ).length === 1 ) { + updatedConnections.attributes = undefined; + } + if ( + Object.keys( updatedConnections ).length === 1 && + updateConnections.attributes === undefined + ) { + updatedConnections = undefined; + } + setAttributes( { + connections: updatedConnections, + } ); + return; + } + + const updatedConnections = { + ...attributes.connections, + attributes: { + ...attributes.connections?.attributes, + [ attributeName ]: { + source: 'pattern_attributes', + }, + }, + }; + + if ( typeof attributes.metadata?.id === 'string' ) { + setAttributes( { connections: updatedConnections } ); + return; + } + + const id = nanoid( 6 ); + setAttributes( { + connections: updatedConnections, + metadata: { + ...attributes.metadata, + id, + }, + } ); + } + + return ( + <InspectorControls group="advanced"> + <BaseControl __nextHasNoMarginBottom> + <BaseControl.VisualLabel> + { __( 'Synced attributes' ) } + </BaseControl.VisualLabel> + { Object.entries( syncedAttributes ).map( + ( [ attributeName, label ] ) => ( + <CheckboxControl + key={ attributeName } + __nextHasNoMarginBottom + label={ label } + checked={ + attributes.connections?.attributes?.[ + attributeName + ]?.source === 'pattern_attributes' + } + onChange={ ( isChecked ) => { + updateConnections( attributeName, isChecked ); + } } + /> + ) + ) } + </BaseControl> + </InspectorControls> + ); +} + +export default PartialSyncingControls; diff --git a/packages/patterns/src/components/rename-pattern-category-modal.js b/packages/patterns/src/components/rename-pattern-category-modal.js index 3e9e90da2f821..3df57757315b8 100644 --- a/packages/patterns/src/components/rename-pattern-category-modal.js +++ b/packages/patterns/src/components/rename-pattern-category-modal.js @@ -138,6 +138,7 @@ export default function RenamePatternCategoryModal( { <TextControl ref={ textControlRef } __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Name' ) } value={ name } onChange={ onChange } @@ -154,10 +155,15 @@ export default function RenamePatternCategoryModal( { ) } </VStack> <HStack justify="right"> - <Button variant="tertiary" onClick={ onRequestClose }> + <Button + __next40pxDefaultSize + variant="tertiary" + onClick={ onRequestClose } + > { __( 'Cancel' ) } </Button> <Button + __next40pxDefaultSize variant="primary" type="submit" aria-disabled={ diff --git a/packages/patterns/src/components/rename-pattern-modal.js b/packages/patterns/src/components/rename-pattern-modal.js index 9b905c04b1e20..9c6aef7116530 100644 --- a/packages/patterns/src/components/rename-pattern-modal.js +++ b/packages/patterns/src/components/rename-pattern-modal.js @@ -93,6 +93,7 @@ export default function RenamePatternModal( { <VStack spacing="5"> <TextControl __nextHasNoMarginBottom + __next40pxDefaultSize label={ __( 'Name' ) } value={ name } onChange={ setName } @@ -100,11 +101,19 @@ export default function RenamePatternModal( { /> <HStack justify="right"> - <Button variant="tertiary" onClick={ onRequestClose }> + <Button + __next40pxDefaultSize + variant="tertiary" + onClick={ onRequestClose } + > { __( 'Cancel' ) } </Button> - <Button variant="primary" type="submit"> + <Button + __next40pxDefaultSize + variant="primary" + type="submit" + > { __( 'Save' ) } </Button> </HStack> diff --git a/packages/patterns/src/constants.js b/packages/patterns/src/constants.js index 465970b17b7aa..3e533d834fd75 100644 --- a/packages/patterns/src/constants.js +++ b/packages/patterns/src/constants.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + export const PATTERN_TYPES = { theme: 'pattern', user: 'wp_block', @@ -14,3 +19,8 @@ export const PATTERN_SYNC_TYPES = { full: 'fully', unsynced: 'unsynced', }; + +// TODO: This should not be hardcoded. Maybe there should be a config and/or an UI. +export const PARTIAL_SYNCING_SUPPORTED_BLOCKS = { + 'core/paragraph': { content: __( 'Content' ) }, +}; diff --git a/packages/patterns/src/private-apis.js b/packages/patterns/src/private-apis.js index 770a78fd4fa9d..b357efb1bc107 100644 --- a/packages/patterns/src/private-apis.js +++ b/packages/patterns/src/private-apis.js @@ -7,12 +7,14 @@ import DuplicatePatternModal from './components/duplicate-pattern-modal'; import RenamePatternModal from './components/rename-pattern-modal'; import PatternsMenuItems from './components'; import RenamePatternCategoryModal from './components/rename-pattern-category-modal'; +import PartialSyncingControls from './components/partial-syncing-controls'; import { PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } from './constants'; export const privateApis = {}; @@ -22,9 +24,11 @@ lock( privateApis, { RenamePatternModal, PatternsMenuItems, RenamePatternCategoryModal, + PartialSyncingControls, PATTERN_TYPES, PATTERN_DEFAULT_CATEGORY, PATTERN_USER_CATEGORY, EXCLUDED_PATTERN_SOURCES, PATTERN_SYNC_TYPES, + PARTIAL_SYNCING_SUPPORTED_BLOCKS, } ); diff --git a/packages/private-apis/src/implementation.js b/packages/private-apis/src/implementation.js index 400d84c8cf584..a7da5bc972655 100644 --- a/packages/private-apis/src/implementation.js +++ b/packages/private-apis/src/implementation.js @@ -27,6 +27,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [ '@wordpress/patterns', '@wordpress/reusable-blocks', '@wordpress/router', + '@wordpress/dataviews', ]; /** diff --git a/packages/react-native-aztec/android/build.gradle b/packages/react-native-aztec/android/build.gradle index 7647548360ca7..18093ff1c2c13 100644 --- a/packages/react-native-aztec/android/build.gradle +++ b/packages/react-native-aztec/android/build.gradle @@ -11,7 +11,7 @@ buildscript { espressoVersion = '3.0.1' // libs - aztecVersion = 'v1.8.0' + aztecVersion = 'v1.9.0' wordpressUtilsVersion = '3.3.0' // main diff --git a/packages/react-native-aztec/package.json b/packages/react-native-aztec/package.json index 631781600d78d..fbf34269306ee 100644 --- a/packages/react-native-aztec/package.json +++ b/packages/react-native-aztec/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-aztec", - "version": "1.109.0", + "version": "1.109.2", "description": "Aztec view for react-native.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java index c6e20b29db072..c1dc4bab896b3 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/GutenbergBridgeJS2Parent.java @@ -60,6 +60,10 @@ interface BlockTypeImpressionsCallback { void onRequestBlockTypeImpressions(ReadableMap impressions); } + interface ConnectionStatusCallback { + void onRequestConnectionStatus(boolean isConnected); + } + // Ref: https://github.com/facebook/react-native/blob/HEAD/Libraries/polyfills/console.js#L376 enum LogLevel { TRACE(0), @@ -183,4 +187,6 @@ void gutenbergDidRequestUnsupportedBlockFallback(ReplaceUnsupportedBlockCallback void toggleUndoButton(boolean isDisabled); void toggleRedoButton(boolean isDisabled); + + void requestConnectionStatus(ConnectionStatusCallback connectionStatusCallback); } diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java index d922d863cb301..0073db769d9cd 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/ReactNativeGutenbergBridge/RNReactNativeGutenbergBridgeModule.java @@ -23,6 +23,7 @@ import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.modules.core.DeviceEventManagerModule; +import org.wordpress.mobile.ReactNativeGutenbergBridge.GutenbergBridgeJS2Parent.ConnectionStatusCallback; import org.wordpress.mobile.ReactNativeGutenbergBridge.GutenbergBridgeJS2Parent.MediaType; import org.wordpress.mobile.ReactNativeGutenbergBridge.GutenbergBridgeJS2Parent.OtherMediaOptionsReceivedCallback; import org.wordpress.mobile.ReactNativeGutenbergBridge.GutenbergBridgeJS2Parent.FocalPointPickerTooltipShownCallback; @@ -85,6 +86,8 @@ public class RNReactNativeGutenbergBridgeModule extends ReactContextBaseJavaModu public static final String MAP_KEY_FEATURED_IMAGE_ID = "featuredImageId"; + public static final String MAP_KEY_IS_CONNECTED = "isConnected"; + private boolean mIsDarkMode; public RNReactNativeGutenbergBridgeModule(ReactApplicationContext reactContext, @@ -533,4 +536,18 @@ public void generateHapticFeedback() { } } } + + @ReactMethod + public void requestConnectionStatus(final Callback jsCallback) { + ConnectionStatusCallback connectionStatusCallback = requestConnectionStatusCallback(jsCallback); + mGutenbergBridgeJS2Parent.requestConnectionStatus(connectionStatusCallback); + } + + private ConnectionStatusCallback requestConnectionStatusCallback(final Callback jsCallback) { + return new GutenbergBridgeJS2Parent.ConnectionStatusCallback() { + @Override public void onRequestConnectionStatus(boolean isConnected) { + jsCallback.invoke(isConnected); + } + }; + } } diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/DeferredEventEmitter.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/DeferredEventEmitter.java index 7dd4dbf3811fe..fe83bc8a14b54 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/DeferredEventEmitter.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/DeferredEventEmitter.java @@ -15,6 +15,7 @@ import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; +import static org.wordpress.mobile.ReactNativeGutenbergBridge.RNReactNativeGutenbergBridgeModule.MAP_KEY_IS_CONNECTED; import static org.wordpress.mobile.ReactNativeGutenbergBridge.RNReactNativeGutenbergBridgeModule.MAP_KEY_MEDIA_FILE_UPLOAD_MEDIA_ID; import static org.wordpress.mobile.ReactNativeGutenbergBridge.RNReactNativeGutenbergBridgeModule.MAP_KEY_MEDIA_FILE_UPLOAD_MEDIA_NEW_ID; import static org.wordpress.mobile.ReactNativeGutenbergBridge.RNReactNativeGutenbergBridgeModule.MAP_KEY_MEDIA_FILE_UPLOAD_MEDIA_URL; @@ -44,6 +45,8 @@ public interface JSEventEmitter { private static final String EVENT_FEATURED_IMAGE_ID_NATIVE_UPDATED = "featuredImageIdNativeUpdated"; + private static final String EVENT_CONNECTION_STATUS_CHANGE = "connectionStatusChange"; + private static final String MAP_KEY_MEDIA_FILE_STATE = "state"; private static final String MAP_KEY_MEDIA_FILE_MEDIA_ACTION_PROGRESS = "progress"; private static final String MAP_KEY_MEDIA_FILE_MEDIA_SERVER_ID = "mediaServerId"; @@ -222,6 +225,12 @@ public void sendToJSFeaturedImageId(int mediaId) { queueActionToJS(EVENT_FEATURED_IMAGE_ID_NATIVE_UPDATED, writableMap); } + public void onConnectionStatusChange(boolean isConnected) { + WritableMap writableMap = new WritableNativeMap(); + writableMap.putBoolean(MAP_KEY_IS_CONNECTED, isConnected); + queueActionToJS(EVENT_CONNECTION_STATUS_CHANGE, writableMap); + } + @Override public void onReplaceMediaFilesEditedBlock(String mediaFiles, String blockId) { WritableMap writableMap = new WritableNativeMap(); writableMap.putString(MAP_KEY_REPLACE_BLOCK_HTML, mediaFiles); diff --git a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java index 69adb653211da..c0916d1417a34 100644 --- a/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java +++ b/packages/react-native-bridge/android/react-native-bridge/src/main/java/org/wordpress/mobile/WPAndroidGlue/WPAndroidGlueCode.java @@ -112,6 +112,7 @@ public class WPAndroidGlueCode { private OnToggleUndoButtonListener mOnToggleUndoButtonListener; private OnToggleRedoButtonListener mOnToggleRedoButtonListener; + private OnConnectionStatusEventListener mOnConnectionStatusEventListener; private boolean mIsEditorMounted; private String mContentHtml = ""; @@ -259,6 +260,10 @@ public interface OnToggleRedoButtonListener { void onToggleRedoButton(boolean isDisabled); } + public interface OnConnectionStatusEventListener { + boolean onRequestConnectionStatus(); + } + public void mediaSelectionCancelled() { mAppendsMultipleSelectedToSiblingBlocks = false; } @@ -594,6 +599,12 @@ public void toggleUndoButton(boolean isDisabled) { public void toggleRedoButton(boolean isDisabled) { mOnToggleRedoButtonListener.onToggleRedoButton(isDisabled); } + + @Override + public void requestConnectionStatus(ConnectionStatusCallback connectionStatusCallback) { + boolean isConnected = mOnConnectionStatusEventListener.onRequestConnectionStatus(); + connectionStatusCallback.onRequestConnectionStatus(isConnected); + } }, mIsDarkMode); return Arrays.asList( @@ -688,6 +699,7 @@ public void attachToContainer(ViewGroup viewGroup, OnSendEventToHostListener onSendEventToHostListener, OnToggleUndoButtonListener onToggleUndoButtonListener, OnToggleRedoButtonListener onToggleRedoButtonListener, + OnConnectionStatusEventListener onConnectionStatusEventListener, boolean isDarkMode) { MutableContextWrapper contextWrapper = (MutableContextWrapper) mReactRootView.getContext(); contextWrapper.setBaseContext(viewGroup.getContext()); @@ -713,6 +725,7 @@ public void attachToContainer(ViewGroup viewGroup, mOnSendEventToHostListener = onSendEventToHostListener; mOnToggleUndoButtonListener = onToggleUndoButtonListener; mOnToggleRedoButtonListener = onToggleRedoButtonListener; + mOnConnectionStatusEventListener = onConnectionStatusEventListener; sAddCookiesInterceptor.setOnAuthHeaderRequestedListener(onAuthHeaderRequestedListener); @@ -1149,6 +1162,10 @@ public void sendToJSFeaturedImageId(int mediaId) { mDeferredEventEmitter.sendToJSFeaturedImageId(mediaId); } + public void connectionStatusChange(boolean isConnected) { + mDeferredEventEmitter.onConnectionStatusChange(isConnected); + } + public void replaceUnsupportedBlock(String content, String blockId) { if (mReplaceUnsupportedBlockCallback != null) { mReplaceUnsupportedBlockCallback.replaceUnsupportedBlock(content, blockId); diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css index f8f2e8fe2b4cd..b0ce8f3dc948d 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css @@ -8,7 +8,7 @@ display: none; } -.edit-post-visual-editor__post-title-wrapper { +.editor-editor-canvas__post-title-wrapper { display: none; } diff --git a/packages/react-native-bridge/index.js b/packages/react-native-bridge/index.js index 89f9f029901f9..8e9065cc568e5 100644 --- a/packages/react-native-bridge/index.js +++ b/packages/react-native-bridge/index.js @@ -3,6 +3,11 @@ */ import { NativeModules, NativeEventEmitter, Platform } from 'react-native'; +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + const { RNReactNativeGutenbergBridge } = NativeModules; const isIOS = Platform.OS === 'ios'; const isAndroid = Platform.OS === 'android'; @@ -185,6 +190,49 @@ export function subscribeOnRedoPressed( callback ) { return gutenbergBridgeEvents.addListener( 'onRedoPressed', callback ); } +export function useIsConnected() { + const [ isConnected, setIsConnected ] = useState( null ); + + useEffect( () => { + let isCurrent = true; + + RNReactNativeGutenbergBridge.requestConnectionStatus( + ( isBridgeConnected ) => { + if ( ! isCurrent ) { + return; + } + + setIsConnected( isBridgeConnected ); + } + ); + + return () => { + isCurrent = false; + }; + }, [] ); + + useEffect( () => { + const subscription = subscribeConnectionStatus( + ( { isConnected: isBridgeConnected } ) => { + setIsConnected( isBridgeConnected ); + } + ); + + return () => { + subscription.remove(); + }; + }, [] ); + + return { isConnected }; +} + +function subscribeConnectionStatus( callback ) { + return gutenbergBridgeEvents.addListener( + 'connectionStatusChange', + callback + ); +} + /** * Request media picker for the given media source. * diff --git a/packages/react-native-bridge/ios/Gutenberg.swift b/packages/react-native-bridge/ios/Gutenberg.swift index 4175c1e2343c3..de0d1b513f00d 100644 --- a/packages/react-native-bridge/ios/Gutenberg.swift +++ b/packages/react-native-bridge/ios/Gutenberg.swift @@ -210,6 +210,11 @@ public class Gutenberg: UIResponder { bridgeModule.sendEventIfNeeded(.onRedoPressed, body: nil) } + public func connectionStatusChange(isConnected: Bool) { + var data: [String: Any] = ["isConnected": isConnected] + bridgeModule.sendEventIfNeeded(.connectionStatusChange, body: data) + } + private func properties(from editorSettings: GutenbergEditorSettings?) -> [String : Any] { var settingsUpdates = [String : Any]() settingsUpdates["isFSETheme"] = editorSettings?.isFSETheme ?? false diff --git a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift index 83d087bccab9d..8890cd4de0f7e 100644 --- a/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift +++ b/packages/react-native-bridge/ios/GutenbergBridgeDelegate.swift @@ -283,6 +283,8 @@ public protocol GutenbergBridgeDelegate: AnyObject { func gutenbergDidRequestToggleUndoButton(_ isDisabled: Bool) func gutenbergDidRequestToggleRedoButton(_ isDisabled: Bool) + + func gutenbergDidRequestConnectionStatus() -> Bool } // MARK: - Optional GutenbergBridgeDelegate methods diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m index d333f8c1722ad..3d68e51ebcacb 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.m @@ -42,5 +42,6 @@ @interface RCT_EXTERN_MODULE(RNReactNativeGutenbergBridge, NSObject) RCT_EXTERN_METHOD(generateHapticFeedback) RCT_EXTERN_METHOD(toggleUndoButton:(BOOL)isDisabled) RCT_EXTERN_METHOD(toggleRedoButton:(BOOL)isDisabled) +RCT_EXTERN_METHOD(requestConnectionStatus:(RCTResponseSenderBlock)callback) @end diff --git a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift index 8cf4f685bd22c..ec763b2b8aaa2 100644 --- a/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift +++ b/packages/react-native-bridge/ios/RNReactNativeGutenbergBridge.swift @@ -421,6 +421,11 @@ public class RNReactNativeGutenbergBridge: RCTEventEmitter { func toggleRedoButton(_ isDisabled: Bool) { self.delegate?.gutenbergDidRequestToggleRedoButton(isDisabled) } + + @objc + func requestConnectionStatus(_ callback: @escaping RCTResponseSenderBlock) { + callback([self.delegate?.gutenbergDidRequestConnectionStatus() ?? true]) + } } // MARK: - RCTBridgeModule delegate @@ -450,6 +455,7 @@ extension RNReactNativeGutenbergBridge { case showEditorHelp case onUndoPressed case onRedoPressed + case connectionStatusChange } public override func supportedEvents() -> [String]! { diff --git a/packages/react-native-bridge/package.json b/packages/react-native-bridge/package.json index 20ae851c89686..6b9bdb782d66d 100644 --- a/packages/react-native-bridge/package.json +++ b/packages/react-native-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-bridge", - "version": "1.109.0", + "version": "1.109.2", "description": "Native bridge library used to integrate the block editor into a native App.", "private": true, "author": "The WordPress Contributors", diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 4e509a232b3e5..33fa3d26e6c0a 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,21 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [*] [internal] Move InserterButton from components package to block-editor package [#56494] +- [*] [internal] Move ImageLinkDestinationsScreen from components package to block-editor package [#56775] +- [*] Fix crash when blockType wrapperProps are not defined [#56846] +- [*] Guard against an Image block styles crash due to null block values [#56903] +- [**] Fix crash when sharing unsupported media types on Android [#56791] + +## 1.109.2 +- [**] Fix issue related to text color format and receiving in rare cases an undefined ref from `RichText` component [#56686] +- [**] Fixes a crash on pasting MS Word list markup [#56653] +- [**] Address rare cases where a null value is passed to a heading block, causing a crash [#56757] +- [**] Fixes a crash related to HTML to blocks conversion when no transformations are available [#56723] +- [**] Fixes a crash related to undefined attributes in `getFormatColors` function of `RichText` component [#56684] +- [**] Fixes an issue with custom color variables not being parsed when using global styles [#56752] + +## 1.109.1 - [***] Fix issue when backspacing in an empty Paragraph block [#56496] ## 1.109.0 diff --git a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java index d718b34f25db3..4477f1cc1d9f3 100644 --- a/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java +++ b/packages/react-native-editor/android/app/src/main/java/com/gutenberg/MainApplication.java @@ -308,6 +308,11 @@ public void toggleRedoButton(boolean isDisabled) { mainActivity.updateRedoItem(isDisabled); } } + + @Override + public void requestConnectionStatus(ConnectionStatusCallback connectionStatusCallback) { + connectionStatusCallback.onRequestConnectionStatus(true); + } }, isDarkMode()); return new DefaultReactNativeHost(this) { diff --git a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift index b269d1feb8ddf..ef95c7e65862f 100644 --- a/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift +++ b/packages/react-native-editor/ios/GutenbergDemo/GutenbergViewController.swift @@ -345,6 +345,10 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } } } + + func gutenbergDidRequestConnectionStatus() -> Bool { + return true + } } extension GutenbergViewController: GutenbergWebDelegate { diff --git a/packages/react-native-editor/ios/Podfile.lock b/packages/react-native-editor/ios/Podfile.lock index d6f0ca39a09bf..51b5554191ea8 100644 --- a/packages/react-native-editor/ios/Podfile.lock +++ b/packages/react-native-editor/ios/Podfile.lock @@ -13,7 +13,7 @@ PODS: - ReactCommon/turbomodule/core (= 0.71.11) - fmt (6.2.1) - glog (0.3.5) - - Gutenberg (1.109.0): + - Gutenberg (1.109.2): - React-Core (= 0.71.11) - React-CoreModules (= 0.71.11) - React-RCTImage (= 0.71.11) @@ -429,7 +429,7 @@ PODS: - React-RCTImage - RNSVG (13.9.0): - React-Core - - RNTAztecView (1.109.0): + - RNTAztecView (1.109.2): - React-Core - WordPress-Aztec-iOS (= 1.19.9) - SDWebImage (5.11.1): @@ -617,7 +617,7 @@ SPEC CHECKSUMS: FBReactNativeSpec: f07662560742d82a5b73cee116c70b0b49bcc220 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - Gutenberg: dd556a8be3f8b5225862823f050e57d0a22e0614 + Gutenberg: 2da422f5cdffef9f66fc57f87ddba4dbda5ceb9d hermes-engine: 34c863b446d0135b85a6536fa5fd89f48196f848 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: 60305b2e989864154bd9be3d772730f08fc6a59c @@ -662,7 +662,7 @@ SPEC CHECKSUMS: RNReanimated: d4f363f4987ae0ade3e36ff81c94e68261bf4b8d RNScreens: 68fd1060f57dd1023880bf4c05d74784b5392789 RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 - RNTAztecView: 8415d8e322e98d087b3f8fbba0669e84d6b235cb + RNTAztecView: dc2635b4d33818f4c113717ff67071c1e367ed8c SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d WordPress-Aztec-iOS: fbebd569c61baa252b3f5058c0a2a9a6ada686bb diff --git a/packages/react-native-editor/package.json b/packages/react-native-editor/package.json index b599c2cc51c65..bcc15b44b4ca1 100644 --- a/packages/react-native-editor/package.json +++ b/packages/react-native-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-native-editor", - "version": "1.109.0", + "version": "1.109.2", "description": "Mobile WordPress gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/react-native-editor/src/jsdom-patches.js b/packages/react-native-editor/src/jsdom-patches.js index f33dd892f8c18..c86e4c82f3127 100644 --- a/packages/react-native-editor/src/jsdom-patches.js +++ b/packages/react-native-editor/src/jsdom-patches.js @@ -171,6 +171,18 @@ Element.prototype.closest = function ( selector ) { return null; }; +/** + * Implementation of Element.prototype.remove based on polyfills: + * - https://github.com/chenzhenxi/element-remove/blob/master/index.js + * (referenced in https://developer.mozilla.org/en-US/docs/Web/API/Element/remove#see_also) + * - https://github.com/JakeChampion/polyfill-library/blob/master/polyfills/Element/prototype/remove/polyfill.js + */ +Element.prototype.remove = function () { + if ( this.parentNode ) { + this.parentNode.removeChild( this ); + } +}; + /** * Helper function to check if a node implements the NonDocumentTypeChildNode * interface diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index 90726ff238c1b..90fd15e1c905c 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -355,6 +355,19 @@ _Returns_ - `RichTextValue`: A new value with replacements applied. +### RichTextData + +The RichTextData class is used to instantiate a wrapper around rich text values, with methods that can be used to transform or manipulate the data. + +- Create an emtpy instance: `new RichTextData()`. +- Create one from an html string: `RichTextData.fromHTMLString( +'<em>hello</em>' )`. +- Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( +document.querySelector( 'p' ) )`. +- Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. +- Create one from a rich text value: `new RichTextData( { text: '...', +formats: [ ... ] } )`. + ### RichTextValue An object which represents a formatted string. See main `@wordpress/rich-text` documentation for more information. diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 0e9291b7a5e03..a2b5734d5c204 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -8,7 +8,7 @@ import { useRegistry } from '@wordpress/data'; /** * Internal dependencies */ -import { collapseWhiteSpace, create } from '../create'; +import { create, RichTextData } from '../create'; import { apply } from '../to-dom'; import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; @@ -70,11 +70,18 @@ export function useRichText( { function setRecordFromProps() { _value.current = value; - record.current = create( { - html: preserveWhiteSpace - ? value - : collapseWhiteSpace( typeof value === 'string' ? value : '' ), - } ); + record.current = value; + if ( ! ( value instanceof RichTextData ) ) { + record.current = value + ? RichTextData.fromHTMLString( value, { preserveWhiteSpace } ) + : RichTextData.empty(); + } + // To do: make rich text internally work with RichTextData. + record.current = { + text: record.current.text, + formats: record.current.formats, + replacements: record.current.replacements, + }; if ( disableFormats ) { record.current.formats = Array( value.length ); record.current.replacements = Array( value.length ); @@ -117,17 +124,18 @@ export function useRichText( { if ( disableFormats ) { _value.current = newRecord.text; } else { - _value.current = toHTMLString( { - value: __unstableBeforeSerialize - ? { - ...newRecord, - formats: __unstableBeforeSerialize( newRecord ), - } - : newRecord, - } ); + const newFormats = __unstableBeforeSerialize + ? __unstableBeforeSerialize( newRecord ) + : newRecord.formats; + newRecord = { ...newRecord, formats: newFormats }; + if ( typeof value === 'string' ) { + _value.current = toHTMLString( { value: newRecord } ); + } else { + _value.current = new RichTextData( newRecord ); + } } - const { start, end, formats, text } = newRecord; + const { start, end, formats, text } = record.current; // Selection must be updated first, so it is recorded in history when // the content change happens. diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index a23baf70078bc..a35fabbd4e2fa 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -10,6 +10,8 @@ import { store as richTextStore } from './store'; import { createElement } from './create-element'; import { mergePair } from './concat'; import { OBJECT_REPLACEMENT_CHARACTER, ZWNBSP } from './special-characters'; +import { toHTMLString } from './to-html-string'; +import { getTextContent } from './get-text-content'; /** @typedef {import('./types').RichTextValue} RichTextValue */ @@ -96,6 +98,86 @@ function toFormat( { tagName, attributes } ) { }; } +// Ideally we use a private property. +const RichTextInternalData = Symbol( 'RichTextInternalData' ); + +/** + * The RichTextData class is used to instantiate a wrapper around rich text + * values, with methods that can be used to transform or manipulate the data. + * + * - Create an emtpy instance: `new RichTextData()`. + * - Create one from an html string: `RichTextData.fromHTMLString( + * '<em>hello</em>' )`. + * - Create one from a wrapper HTMLElement: `RichTextData.fromHTMLElement( + * document.querySelector( 'p' ) )`. + * - Create one from plain text: `RichTextData.fromPlainText( '1\n2' )`. + * - Create one from a rich text value: `new RichTextData( { text: '...', + * formats: [ ... ] } )`. + * + * @todo Add methods to manipulate the data, such as applyFormat, slice etc. + */ +export class RichTextData { + static empty() { + return new RichTextData(); + } + static fromPlainText( text ) { + return new RichTextData( create( { text } ) ); + } + static fromHTMLString( html ) { + return new RichTextData( create( { html } ) ); + } + static fromHTMLElement( htmlElement, options = {} ) { + const { preserveWhiteSpace = false } = options; + const element = preserveWhiteSpace + ? htmlElement + : collapseWhiteSpace( htmlElement ); + const richTextData = new RichTextData( create( { element } ) ); + Object.defineProperty( richTextData, 'originalHTML', { + value: htmlElement.innerHTML, + } ); + return richTextData; + } + constructor( init = createEmptyValue() ) { + // Setting text, formats, and replacements as enumerable properties + // unfortunately visualises these in the e2e tests. As long as the class + // instance doesn't have any enumerable properties, it will be + // visualised as a string. + Object.defineProperty( this, RichTextInternalData, { value: init } ); + } + toPlainText() { + return getTextContent( this[ RichTextInternalData ] ); + } + // We could expose `toHTMLElement` at some point as well, but we'd only use + // it internally. + toHTMLString() { + return ( + this.originalHTML || + toHTMLString( { value: this[ RichTextInternalData ] } ) + ); + } + valueOf() { + return this.toHTMLString(); + } + toString() { + return this.toHTMLString(); + } + toJSON() { + return this.toHTMLString(); + } + get length() { + return this.text.length; + } + get formats() { + return this[ RichTextInternalData ].formats; + } + get replacements() { + return this[ RichTextInternalData ].replacements; + } + get text() { + return this[ RichTextInternalData ].text; + } +} + /** * Create a RichText value from an `Element` tree (DOM), an HTML string or a * plain text string, with optionally a `Range` object to set the selection. If @@ -128,7 +210,6 @@ function toFormat( { tagName, attributes } ) { * @param {string} [$1.html] HTML to create value from. * @param {Range} [$1.range] Range to create value from. * @param {boolean} [$1.__unstableIsEditableTree] - * * @return {RichTextValue} A rich text value. */ export function create( { @@ -138,6 +219,14 @@ export function create( { range, __unstableIsEditableTree: isEditableTree, } = {} ) { + if ( html instanceof RichTextData ) { + return { + text: html.text, + formats: html.formats, + replacements: html.replacements, + }; + } + if ( typeof text === 'string' && text.length > 0 ) { return { formats: Array( text.length ), @@ -268,10 +357,42 @@ function filterRange( node, range, filter ) { * @see * https://developer.mozilla.org/en-US/docs/Web/CSS/white-space-collapse#collapsing_of_white_space * - * @param {string} string + * @param {HTMLElement} element + * @param {boolean} isRoot + * + * @return {HTMLElement} New element with collapsed whitespace. */ -export function collapseWhiteSpace( string ) { - return string.replace( /[\n\r\t]+/g, ' ' ); +function collapseWhiteSpace( element, isRoot = true ) { + const clone = element.cloneNode( true ); + clone.normalize(); + Array.from( clone.childNodes ).forEach( ( node, i, nodes ) => { + if ( node.nodeType === node.TEXT_NODE ) { + let newNodeValue = node.nodeValue; + + if ( /[\n\t\r\f]/.test( newNodeValue ) ) { + newNodeValue = newNodeValue.replace( /[\n\t\r\f]+/g, ' ' ); + } + + if ( newNodeValue.indexOf( ' ' ) !== -1 ) { + newNodeValue = newNodeValue.replace( / {2,}/g, ' ' ); + } + + if ( i === 0 && newNodeValue.startsWith( ' ' ) ) { + newNodeValue = newNodeValue.slice( 1 ); + } else if ( + isRoot && + i === nodes.length - 1 && + newNodeValue.endsWith( ' ' ) + ) { + newNodeValue = newNodeValue.slice( 0, -1 ); + } + + node.nodeValue = newNodeValue; + } else if ( node.nodeType === node.ELEMENT_NODE ) { + collapseWhiteSpace( node, false ); + } + } ); + return clone; } /** diff --git a/packages/rich-text/src/index.ts b/packages/rich-text/src/index.ts index 14d26cab8f7fb..f82317d81573d 100644 --- a/packages/rich-text/src/index.ts +++ b/packages/rich-text/src/index.ts @@ -1,7 +1,7 @@ export { store } from './store'; export { applyFormat } from './apply-format'; export { concat } from './concat'; -export { create } from './create'; +export { RichTextData, create } from './create'; export { getActiveFormat } from './get-active-format'; export { getActiveFormats } from './get-active-formats'; export { getActiveObject } from './get-active-object'; diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index ab55ff9adf5b8..7ad87c315df80 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fix + +- Fix CSS imports not minified ([#56516](https://github.com/WordPress/gutenberg/pull/56516)). + ## 26.18.0 (2023-11-29) ### Internal diff --git a/packages/scripts/config/webpack.config.js b/packages/scripts/config/webpack.config.js index 05b37945795a7..1e060d0e142c9 100644 --- a/packages/scripts/config/webpack.config.js +++ b/packages/scripts/config/webpack.config.js @@ -69,6 +69,7 @@ const cssLoaders = [ { loader: require.resolve( 'css-loader' ), options: { + importLoaders: 1, sourceMap: ! isProduction, modules: { auto: true, diff --git a/packages/scripts/scripts/test-playwright.js b/packages/scripts/scripts/test-playwright.js index 71bc6a63320cf..4a8b0762336ab 100644 --- a/packages/scripts/scripts/test-playwright.js +++ b/packages/scripts/scripts/test-playwright.js @@ -24,21 +24,28 @@ const { hasProjectFile, hasArgInCLI, getArgsFromCLI, + getAsBooleanFromENV, } = require( '../utils' ); -const result = spawn( - 'node', - [ - path.resolve( require.resolve( 'playwright-core' ), '..', 'cli.js' ), - 'install', - ], - { - stdio: 'inherit', - } -); +if ( ! getAsBooleanFromENV( 'PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD' ) ) { + const result = spawn( + 'node', + [ + path.resolve( + require.resolve( 'playwright-core' ), + '..', + 'cli.js' + ), + 'install', + ], + { + stdio: 'inherit', + } + ); -if ( result.status > 0 ) { - process.exit( result.status ); + if ( result.status > 0 ) { + process.exit( result.status ); + } } const config = diff --git a/packages/scripts/utils/index.js b/packages/scripts/utils/index.js index 870c2423361b5..ae93160381df4 100644 --- a/packages/scripts/utils/index.js +++ b/packages/scripts/utils/index.js @@ -1,6 +1,7 @@ /** * Internal dependencies */ +const { getAsBooleanFromENV } = require( './process' ); const { getArgFromCLI, getArgsFromCLI, @@ -28,6 +29,7 @@ const { getPackageProp, hasPackageProp } = require( './package' ); module.exports = { fromProjectRoot, fromConfigRoot, + getAsBooleanFromENV, getArgFromCLI, getArgsFromCLI, getFileArgsFromCLI, diff --git a/packages/scripts/utils/process.js b/packages/scripts/utils/process.js index de07d36a8b59c..48b884dc085bc 100644 --- a/packages/scripts/utils/process.js +++ b/packages/scripts/utils/process.js @@ -1,3 +1,8 @@ +const getAsBooleanFromENV = ( name ) => { + const value = process.env[ name ]; + return !! value && value !== 'false' && value !== '0'; +}; + const getArgsFromCLI = ( excludePrefixes ) => { const args = process.argv.slice( 2 ); if ( excludePrefixes ) { @@ -12,6 +17,7 @@ const getArgsFromCLI = ( excludePrefixes ) => { module.exports = { exit: process.exit, + getAsBooleanFromENV, getArgsFromCLI, getCurrentWorkingDirectory: process.cwd, }; diff --git a/phpunit/class-block-fixture-test.php b/phpunit/class-block-fixture-test.php index a3e47cc7b2833..adf49445261d1 100644 --- a/phpunit/class-block-fixture-test.php +++ b/phpunit/class-block-fixture-test.php @@ -7,6 +7,12 @@ class Block_Fixture_Test extends WP_UnitTestCase { + public function filter_allowed_html( $tags ) { + $tags['form']['class'] = true; + $tags['form']['enctype'] = true; + return $tags; + } + /** * Tests that running the serialised block content through KSES doesn't cause the * HTML to change. @@ -20,7 +26,9 @@ public function test_kses_doesnt_change_fixtures( $block, $filename ) { $block = preg_replace( "/href=['\"]data:[^'\"]+['\"]/", 'href="https://wordpress.org/foo.jpg"', $block ); $block = preg_replace( '/url\(data:[^)]+\)/', 'url(https://wordpress.org/foo.jpg)', $block ); + add_filter( 'wp_kses_allowed_html', array( $this, 'filter_allowed_html' ) ); $kses_block = wp_kses_post( $block ); + remove_filter( 'wp_kses_allowed_html', array( $this, 'filter_allowed_html' ) ); // KSES adds a space at the end of self-closing tags, add it to the original to match. $block = preg_replace( '|([^ ])/>|', '$1 />', $block ); diff --git a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php index 857e8fa297cf1..2ae53f9338389 100644 --- a/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php +++ b/phpunit/class-gutenberg-rest-global-styles-revisions-controller-test.php @@ -14,26 +14,11 @@ class Gutenberg_REST_Global_Styles_Revisions_Controller_Test extends WP_Test_RES */ protected static $admin_id; - /** - * @var int - */ - protected static $second_admin_id; - - /** - * @var int - */ - protected static $author_id; - /** * @var int */ protected static $global_styles_id; - /** - * @var int - */ - private $total_revisions; - /** * @var array */ @@ -44,47 +29,17 @@ class Gutenberg_REST_Global_Styles_Revisions_Controller_Test extends WP_Test_RES */ private $revision_1_id; - /** - * @var array - */ - private $revision_2; - - /** - * @var int - */ - private $revision_2_id; - - /** - * @var array - */ - private $revision_3; - - /** - * @var int - */ - private $revision_3_id; - /** * Create fake data before our tests run. * * @param WP_UnitTest_Factory $factory Helper that lets us create fake data. */ public static function wpSetupBeforeClass( $factory ) { - self::$admin_id = $factory->user->create( - array( - 'role' => 'administrator', - ) - ); - self::$second_admin_id = $factory->user->create( + self::$admin_id = $factory->user->create( array( 'role' => 'administrator', ) ); - self::$author_id = $factory->user->create( - array( - 'role' => 'author', - ) - ); wp_set_current_user( self::$admin_id ); // This creates the global styles for the current theme. @@ -199,8 +154,6 @@ public static function wpSetupBeforeClass( $factory ) { */ public static function wpTearDownAfterClass() { self::delete_user( self::$admin_id ); - self::delete_user( self::$second_admin_id ); - self::delete_user( self::$author_id ); } /** @@ -209,24 +162,16 @@ public static function wpTearDownAfterClass() { public function set_up() { parent::set_up(); switch_theme( 'emptytheme' ); - $revisions = wp_get_post_revisions( self::$global_styles_id ); - $this->total_revisions = count( $revisions ); - + $revisions = wp_get_post_revisions( self::$global_styles_id ); $this->revision_1 = array_pop( $revisions ); $this->revision_1_id = $this->revision_1->ID; - $this->revision_2 = array_pop( $revisions ); - $this->revision_2_id = $this->revision_2->ID; - - $this->revision_3 = array_pop( $revisions ); - $this->revision_3_id = $this->revision_3->ID; - /* * For some reason the `rest_api_init` doesn't run early enough to ensure an overwritten `get_item_schema()` * is used. So we manually call it here. * See: https://github.com/WordPress/gutenberg/pull/52370#issuecomment-1643331655. */ - $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_4(); + $global_styles_revisions_controller = new Gutenberg_REST_Global_Styles_Revisions_Controller_6_5(); $global_styles_revisions_controller->register_routes(); } @@ -237,11 +182,6 @@ public function set_up() { */ public function test_register_routes() { $routes = rest_get_server()->get_routes(); - $this->assertArrayHasKey( - '/wp/v2/global-styles/(?P<parent>[\d]+)/revisions', - $routes, - 'Global style revisions based on the given parentID route does not exist.' - ); $this->assertArrayHasKey( '/wp/v2/global-styles/(?P<parent>[\d]+)/revisions/(?P<id>[\d]+)', $routes, @@ -277,32 +217,6 @@ protected function check_get_revision_response( $response_revision_item, $revisi ); } - /** - * @ticket 58524 - * - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller_6_4::get_items - */ - public function test_get_items() { - wp_set_current_user( self::$admin_id ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - $this->assertSame( 200, $response->get_status(), 'Response status is 200.' ); - $this->assertCount( $this->total_revisions, $data, 'Check that correct number of revisions exists.' ); - - // Reverse chronology. - $this->assertSame( $this->revision_3_id, $data[0]['id'] ); - $this->check_get_revision_response( $data[0], $this->revision_3 ); - - $this->assertSame( $this->revision_2_id, $data[1]['id'] ); - $this->check_get_revision_response( $data[1], $this->revision_2 ); - - $this->assertSame( $this->revision_1_id, $data[2]['id'] ); - $this->check_get_revision_response( $data[2], $this->revision_1 ); - } - /** * @ticket 59810 * @@ -336,26 +250,19 @@ public function test_get_item_invalid_revision_id_should_error() { } /** - * @ticket 58524 - * - * @covers Gutenberg_REST_Global_Styles_Revisions_Controller_6_4::get_item_schema + * @doesNotPerformAssertions */ - public function test_get_item_schema() { - $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/global-styles/' . self::$global_styles_id . '/revisions' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $properties = $data['schema']['properties']; + public function test_get_items() { + // Unit tests have been more to WordPress Core for test_get_items(). + // No unique compat unit tests exist. + } - $this->assertCount( 9, $properties, 'Schema properties array has exactly 9 elements.' ); - $this->assertArrayHasKey( 'id', $properties, 'Schema properties array has "id" key.' ); - $this->assertArrayHasKey( 'styles', $properties, 'Schema properties array has "styles" key.' ); - $this->assertArrayHasKey( 'settings', $properties, 'Schema properties array has "settings" key.' ); - $this->assertArrayHasKey( 'parent', $properties, 'Schema properties array has "parent" key.' ); - $this->assertArrayHasKey( 'author', $properties, 'Schema properties array has "author" key.' ); - $this->assertArrayHasKey( 'date', $properties, 'Schema properties array has "date" key.' ); - $this->assertArrayHasKey( 'date_gmt', $properties, 'Schema properties array has "date_gmt" key.' ); - $this->assertArrayHasKey( 'modified', $properties, 'Schema properties array has "modified" key.' ); - $this->assertArrayHasKey( 'modified_gmt', $properties, 'Schema properties array has "modified_gmt" key.' ); + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Unit tests have been more to WordPress Core for test_get_item_schema(). + // No unique compat unit tests exist. } /** diff --git a/phpunit/class-wp-navigation-block-renderer-test.php b/phpunit/class-wp-navigation-block-renderer-test.php index 73f3c89d79883..124e0fe91bd1e 100644 --- a/phpunit/class-wp-navigation-block-renderer-test.php +++ b/phpunit/class-wp-navigation-block-renderer-test.php @@ -62,4 +62,23 @@ public function test_gutenberg_get_markup_for_inner_block_site_title() { $expected = '<li class="wp-block-navigation-item"><h1 class="wp-block-site-title"><a href="http://' . WP_TESTS_DOMAIN . '" target="_self" rel="home">Test Blog</a></h1></li>'; $this->assertEquals( $expected, $result ); } + + /** + * Test that the `get_inner_blocks_from_navigation_post` method returns an empty block list for a non-existent post. + * + * @group navigation-renderer + * + * @covers WP_Navigation_Block_Renderer::get_inner_blocks_from_navigation_post + */ + public function test_gutenberg_get_inner_blocks_from_navigation_post_returns_empty_block_list() { + $reflection = new ReflectionClass( 'WP_Navigation_Block_Renderer' ); + $method = $reflection->getMethod( 'get_inner_blocks_from_navigation_post' ); + $method->setAccessible( true ); + $attributes = array( 'ref' => 0 ); + + $actual = $method->invoke( $reflection, $attributes ); + $expected = new WP_Block_List( array(), $attributes ); + $this->assertEquals( $actual, $expected ); + $this->assertCount( 0, $actual ); + } } diff --git a/platform-docs/docs/advanced/_category_.json b/platform-docs/docs/advanced/_category_.json deleted file mode 100644 index a5787a8693001..0000000000000 --- a/platform-docs/docs/advanced/_category_.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "position": 4, - "label": "Advanced", - "collapsible": true, - "collapsed": true, - "link": { - "type": "generated-index", - "title": "Advanced" - } -} diff --git a/platform-docs/docs/advanced/create-format.md b/platform-docs/docs/advanced/create-format.md deleted file mode 100644 index 3779c5a85e5be..0000000000000 --- a/platform-docs/docs/advanced/create-format.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Create a RichText format \ No newline at end of file diff --git a/platform-docs/docs/advanced/dynamic.md b/platform-docs/docs/advanced/dynamic.md deleted file mode 100644 index 369670cb1af49..0000000000000 --- a/platform-docs/docs/advanced/dynamic.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Augmenting blocks \ No newline at end of file diff --git a/platform-docs/docs/advanced/interactivity.md b/platform-docs/docs/advanced/interactivity.md deleted file mode 100644 index ba35f82cb2144..0000000000000 --- a/platform-docs/docs/advanced/interactivity.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 2 ---- - -# Interactivity API \ No newline at end of file diff --git a/platform-docs/docs/advanced/wordpress.md b/platform-docs/docs/advanced/wordpress.md deleted file mode 100644 index ce2e40f7c702e..0000000000000 --- a/platform-docs/docs/advanced/wordpress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 4 ---- - -# Gutenberg and WordPress \ No newline at end of file diff --git a/platform-docs/docs/basic-concepts/ui.md b/platform-docs/docs/basic-concepts/ui.md index 8b6e706683d08..0dccef3c239b0 100644 --- a/platform-docs/docs/basic-concepts/ui.md +++ b/platform-docs/docs/basic-concepts/ui.md @@ -17,7 +17,7 @@ The Gutenberg platform allows you to render these pieces separately and lay them ## The Block Toolbar -Wrapping your `BlockCanvas` component within the `BlockTools` wrapper allows the editor to render a block toolbar adjacent to the selected block. +The block toolbar is rendered automatically next to the selected block by default. But if you set the flag `hasFixedToolbar` to true in your `BlockEditorProvider` settings, you will be able to use the `BlockToolbar` component to render the block toolbar in your place of choice. ## The Block Inspector diff --git a/platform-docs/docs/create-block/nested-blocks.md b/platform-docs/docs/create-block/nested-blocks.md index 78352ba3ebe43..e5090d4f36113 100644 --- a/platform-docs/docs/create-block/nested-blocks.md +++ b/platform-docs/docs/create-block/nested-blocks.md @@ -2,4 +2,230 @@ sidebar_position: 5 --- -# Nested blocks \ No newline at end of file +# Nested Blocks + +You can create a single block that nests other blocks using the [InnerBlocks](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-editor/src/components/inner-blocks/README.md) component. This is used in the Columns block, Social Links block, or any block you want to contain other blocks. + +**Note:** A single block can only contain one `InnerBlocks` component. + +Here is the basic InnerBlocks usage. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; + +registerBlockType( 'create-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + + return ( + <div { ...blockProps }> + <InnerBlocks /> + </div> + ); + }, + + save: () => { + const blockProps = useBlockProps.save(); + + return ( + <div { ...blockProps }> + <InnerBlocks.Content /> + </div> + ); + }, +} ); +``` + +## Allowed Blocks + +Using the `allowedBlocks` property, you can define the set of blocks allowed in your InnerBlock. This restricts the blocks that can be included only to those listed, all other blocks will not show in the inserter. + +```jsx +const ALLOWED_BLOCKS = [ 'core/heading', 'core/paragraph' ]; +//... +<InnerBlocks allowedBlocks={ ALLOWED_BLOCKS } />; +``` + +## Default Block + +By default `InnerBlocks` opens a list of permitted blocks via `allowedBlocks` when the block appender is clicked. You can modify the default block and its attributes that are inserted when the initial block appender is clicked by using the `defaultBlock` property. For example: + +```jsx +<InnerBlocks + defaultBlock={ [ 'core/paragraph', { placeholder: 'Lorem ipsum...' } ] } + directInsert +/> +``` + +By default this behavior is disabled until the `directInsert` prop is set to `true`. This allows you to specify conditions for when the default block should or should not be inserted. + +## Template + +Use the template property to define a set of blocks that prefill the InnerBlocks component when inserted. You can set attributes on the blocks to define their use. The example below shows a book review template using the InnerBlocks component and setting placeholder values to show the block usage. + +```js +const MY_TEMPLATE = [ + [ 'core/image', {} ], + [ 'core/heading', { placeholder: 'Book Title' } ], + [ 'core/paragraph', { placeholder: 'Summary' } ], +]; + +//... + + edit: () => { + return ( + <InnerBlocks + template={ MY_TEMPLATE } + templateLock="all" + /> + ); + }, +``` + +Use the `templateLock` property to lock down the template. Using `all` locks the template completely so no changes can be made. Using `insert` prevents additional blocks from being inserted, but existing blocks can be reordered. See [templateLock documentation](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-editor/src/components/inner-blocks/README.md#templatelock) for additional information. + +## Using Parent and Ancestor Relationships in Blocks + +A common pattern for using InnerBlocks is to create a custom block that will only be available if its parent block is inserted. This allows builders to establish a relationship between blocks while limiting a nested block's discoverability. Currently, there are two relationships builders can use: `parent` and `ancestor`. The differences are: + +- If you assign a `parent` then you’re stating that the nested block can only be used and inserted as a **direct descendant of the parent**. +- If you assign an `ancestor` then you’re stating that the nested block can only be used and inserted as a **descendent of the parent**. + +The key difference between `parent` and `ancestor` is that `parent` has finer specificity, while an `ancestor` has greater flexibility in its nested hierarchy. + +### Defining Parent Block Relationship + +An example of this is the Column block, which is assigned the `parent` block setting. This allows the Column block to only be available as a nested direct descendant in its parent Columns block. Otherwise, the Column block will not be available as an option within the block inserter. See [Column code for reference](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-library/src/column). + +When defining a direct descendent block, use the `parent` block setting to define which block is the parent. This prevents the nested block from showing in the inserter outside of the InnerBlock it is defined for. + +```js +{ + title: 'Column', + parent: [ 'core/columns' ], + // ... +} +``` + +### Defining Ancestor Block Relationship + +An example of this is the Comment Author Name block, which is assigned the `ancestor` block setting. This allows the Comment Author Name block to only be available as a nested descendant in its ancestral Comment Template block. Otherwise, the Comment Author Name block will not be available as an option within the block inserter. See [Comment Author Name code for reference](https://github.com/WordPress/gutenberg/tree/HEAD/packages/block-library/src/comment-author-name). + +The `ancestor` relationship allows the Comment Author Name block to be anywhere in the hierarchical tree, and not _just_ a direct child of the parent Comment Template block, while still limiting its availability within the block inserter to only be visible an an option to insert if the Comment Template block is available. + +When defining a descendent block, use the `ancestor` block setting. This prevents the nested block from showing in the inserter outside of the InnerBlock it is defined for. + +```js +{ + title: 'Comment Author Name', + ancestor: [ 'core/comment-template' ] + // ... +} +``` + +## Using a React Hook + +You can use a react hook called `useInnerBlocksProps` instead of the `InnerBlocks` component. This hook allows you to take more control over the markup of inner blocks areas. + +The `useInnerBlocksProps` is exported from the `@wordpress/block-editor` package same as the `InnerBlocks` component itself and supports everything the component does. It also works like the `useBlockProps` hook. + +Here is the basic `useInnerBlocksProps` hook usage. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'create-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps(); + + return ( + <div { ...blockProps }> + <div { ...innerBlocksProps } /> + </div> + ); + }, + + save: () => { + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save(); + + return ( + <div { ...blockProps }> + <div { ...innerBlocksProps } /> + </div> + ); + }, +} ); +``` + +This hook can also pass objects returned from the `useBlockProps` hook to the `useInnerBlocksProps` hook. This reduces the number of elements we need to create. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'gcreate-block/gutenpride-container', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const innerBlocksProps = useInnerBlocksProps( blockProps ); + + return <div { ...innerBlocksProps } />; + }, + + save: () => { + const blockProps = useBlockProps.save(); + const innerBlocksProps = useInnerBlocksProps.save( blockProps ); + + return <div { ...innerBlocksProps } />; + }, +} ); +``` + +The above code will render to the following markup in the editor: + +```html +<div> + <!-- Inner Blocks get inserted here --> +</div> +``` + +Another benefit of the hook approach is using the returned value, which is just an object, and deconstructing to get the react children from the object. This property contains the actual child inner blocks thus we can place elements on the same level as our inner blocks. + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; + +registerBlockType( 'gutenberg-examples/example-06', { + // ... + + edit: () => { + const blockProps = useBlockProps(); + const { children, ...innerBlocksProps } = useInnerBlocksProps( blockProps ); + + return ( + <div {...innerBlocksProps}> + { children } + <!-- Insert any arbitrary html here at the same level as the children --> + </div> + ); + }, + + // ... +} ); +``` + +```html +<div> + <!-- Inner Blocks get inserted here --> + <!-- The custom html gets rendered on the same level --> +</div> +``` diff --git a/platform-docs/docs/create-block/writing-flow.md b/platform-docs/docs/create-block/writing-flow.md deleted file mode 100644 index 452e060f01b1a..0000000000000 --- a/platform-docs/docs/create-block/writing-flow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -sidebar_position: 6 ---- - -# Writing Flow and Pasting \ No newline at end of file diff --git a/platform-docs/src/components/HomepageFeatures/index.js b/platform-docs/src/components/HomepageFeatures/index.js index 40aa82d49bd78..9f42ddd6425e1 100644 --- a/platform-docs/src/components/HomepageFeatures/index.js +++ b/platform-docs/src/components/HomepageFeatures/index.js @@ -2,7 +2,6 @@ * External dependencies */ import React from 'react'; -import clsx from 'clsx'; /** * Internal dependencies @@ -40,7 +39,7 @@ const FeatureList = [ function Feature( { Svg, title, description } ) { return ( - <div className={ clsx( 'col col--4' ) }> + <div className={ styles.feature }> <div className="text--center"> <Svg className={ styles.featureSvg } role="img" /> </div> @@ -55,12 +54,10 @@ function Feature( { Svg, title, description } ) { export default function HomepageFeatures() { return ( <section className={ styles.features }> - <div className="container"> - <div className="row"> - { FeatureList.map( ( props, idx ) => ( - <Feature key={ idx } { ...props } /> - ) ) } - </div> + <div className="row"> + { FeatureList.map( ( props, idx ) => ( + <Feature key={ idx } { ...props } /> + ) ) } </div> </section> ); diff --git a/platform-docs/src/components/HomepageFeatures/styles.module.css b/platform-docs/src/components/HomepageFeatures/styles.module.css index 1f0d53211e56c..1d42ee1c02cc9 100644 --- a/platform-docs/src/components/HomepageFeatures/styles.module.css +++ b/platform-docs/src/components/HomepageFeatures/styles.module.css @@ -2,11 +2,20 @@ display: flex; align-items: center; padding: 2rem 0; - width: 100%; background: var(--ifm-color-secondary); } +.features > div { + max-width: var(--ifm-container-width); + margin: auto; + justify-content: space-between; +} + .featureSvg { height: 200px; width: 200px; } + +.feature { + max-width: 30%; +} diff --git a/platform-docs/src/components/HomepageTrustedBy/index.js b/platform-docs/src/components/HomepageTrustedBy/index.js new file mode 100644 index 0000000000000..f48205e0a057c --- /dev/null +++ b/platform-docs/src/components/HomepageTrustedBy/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * Internal dependencies + */ +import styles from './styles.module.css'; + +const Users = [ + { + title: 'WordPress', + img: require( '@site/static/img/wordpress.png' ).default, + height: 60, + }, + { + title: 'Tumblr', + img: require( '@site/static/img/tumblr.png' ).default, + height: 18, + }, + { + title: 'Day One', + img: require( '@site/static/img/dayone.png' ).default, + height: 100, + }, +]; + +function User( { img, title, height } ) { + return ( + <div className={ styles.col }> + <img src={ img } alt={ title } style={ { height } } /> + </div> + ); +} + +export default function HomepageTrustedBy() { + return ( + <section className={ styles.container }> + <div> + <h2 className={ styles.title }>Trusted by</h2> + <div className={ styles.row }> + { Users.map( ( props, idx ) => ( + <User key={ idx } { ...props } /> + ) ) } + </div> + </div> + </section> + ); +} diff --git a/platform-docs/src/components/HomepageTrustedBy/styles.module.css b/platform-docs/src/components/HomepageTrustedBy/styles.module.css new file mode 100644 index 0000000000000..8481a032b4338 --- /dev/null +++ b/platform-docs/src/components/HomepageTrustedBy/styles.module.css @@ -0,0 +1,27 @@ +.container { + padding: 5rem 0 2rem; + background: #fff; + color: var(--ifm-color-gray-800); +} + +.container > div { + margin: auto; + max-width: var(--ifm-container-width); +} + +.title { + text-align: center; + margin-bottom: 0; + flex-shrink: 0; +} + +.row { + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; +} + +.col > img { + filter: grayscale(1); +} diff --git a/platform-docs/src/pages/index.js b/platform-docs/src/pages/index.js index a61a9254c4029..bb1226f7d8bd6 100644 --- a/platform-docs/src/pages/index.js +++ b/platform-docs/src/pages/index.js @@ -12,6 +12,7 @@ import HomepageFeatures from '@site/src/components/HomepageFeatures'; * Internal dependencies */ import styles from './index.module.css'; +import HomepageTrustedBy from '../components/HomepageTrustedBy'; function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); @@ -43,6 +44,7 @@ export default function Home() { <HomepageHeader /> <main> <HomepageFeatures /> + <HomepageTrustedBy /> </main> </Layout> ); diff --git a/platform-docs/static/img/dayone.png b/platform-docs/static/img/dayone.png new file mode 100644 index 0000000000000000000000000000000000000000..7fab9ab6ec2b463552de2bc673b3ada5c21a81fd GIT binary patch literal 3122 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 literal 0 HcmV?d00001 diff --git a/platform-docs/static/img/tumblr.png b/platform-docs/static/img/tumblr.png new file mode 100644 index 0000000000000000000000000000000000000000..333aac82b419a45e18794b10c827acd4361daf07 GIT binary patch literal 12340 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 CADSEh literal 0 HcmV?d00001 diff --git a/platform-docs/static/img/wordpress.png b/platform-docs/static/img/wordpress.png new file mode 100644 index 0000000000000000000000000000000000000000..eb195a12c14bf9b3e1439e208e2fc0f2c27a99bd GIT binary patch literal 102930 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%0Dy`siDJb=wT2Qn`+OF_rPnd*YFXJITj9Z3U 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 PF9}<#%a`gbeZKn#a6t8; literal 0 HcmV?d00001 diff --git a/schemas/json/block.json b/schemas/json/block.json index d5b4a04452eaa..7e0c8715a4abd 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -114,6 +114,7 @@ "object", "array", "string", + "rich-text", "integer", "number" ] @@ -159,6 +160,7 @@ "enum": [ "attribute", "text", + "rich-text", "html", "raw", "query", diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 782e59794a5d1..10695f493c40d 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -623,6 +623,10 @@ "description": "CSS font-family value.", "type": "string" }, + "preview": { + "description": "URL to a preview image of the font family.", + "type": "string" + }, "fontFace": { "description": "Array of font-face declarations.", "type": "array", @@ -713,6 +717,10 @@ "unicodeRange": { "description": "CSS unicode-range value.", "type": "string" + }, + "preview": { + "description": "URL to a preview image of the font face.", + "type": "string" } }, "required": [ "fontFamily", "src" ], diff --git a/storybook/main.js b/storybook/main.js index add59003bbc8b..45123a7c1ff23 100644 --- a/storybook/main.js +++ b/storybook/main.js @@ -33,6 +33,7 @@ const stories = [ '../packages/components/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/icons/src/**/stories/*.story.@(js|tsx|mdx)', '../packages/edit-site/src/**/stories/*.story.@(js|tsx|mdx)', + '../packages/dataviews/src/**/stories/*.story.@(js|tsx|mdx)', ].filter( Boolean ); module.exports = { diff --git a/storybook/package-styles/config.js b/storybook/package-styles/config.js index 536b447a294c6..21215fcad5c21 100644 --- a/storybook/package-styles/config.js +++ b/storybook/package-styles/config.js @@ -11,6 +11,8 @@ import formatLibraryLtr from '../package-styles/format-library-ltr.lazy.scss'; import formatLibraryRtl from '../package-styles/format-library-rtl.lazy.scss'; import editSiteLtr from '../package-styles/edit-site-ltr.lazy.scss'; import editSiteRtl from '../package-styles/edit-site-rtl.lazy.scss'; +import dataviewsLtr from '../package-styles/dataviews-ltr.lazy.scss'; +import dataviewsRtl from '../package-styles/dataviews-rtl.lazy.scss'; /** * Stylesheets to lazy load when the story's context.componentId matches the @@ -51,6 +53,11 @@ const CONFIG = [ ltr: [ componentsLtr ], rtl: [ componentsRtl ], }, + { + componentIdMatcher: /^dataviews-/, + ltr: [ dataviewsLtr, componentsLtr ], + rtl: [ dataviewsRtl, componentsRtl ], + }, ]; export default CONFIG; diff --git a/storybook/package-styles/dataviews-ltr.lazy.scss b/storybook/package-styles/dataviews-ltr.lazy.scss new file mode 100644 index 0000000000000..b5e1aa042803a --- /dev/null +++ b/storybook/package-styles/dataviews-ltr.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/dataviews/build-style/style"; diff --git a/storybook/package-styles/dataviews-rtl.lazy.scss b/storybook/package-styles/dataviews-rtl.lazy.scss new file mode 100644 index 0000000000000..d97479a1a5658 --- /dev/null +++ b/storybook/package-styles/dataviews-rtl.lazy.scss @@ -0,0 +1 @@ +@import "../../packages/dataviews/build-style/style-rtl"; diff --git a/storybook/stories/playground/box/index.js b/storybook/stories/playground/box/index.js index 444b7810e5e89..3fb3c3b5862c4 100644 --- a/storybook/stories/playground/box/index.js +++ b/storybook/stories/playground/box/index.js @@ -6,7 +6,7 @@ import { registerCoreBlocks } from '@wordpress/block-library'; import { BlockEditorProvider, BlockCanvas, - BlockTools, + BlockToolbar, } from '@wordpress/block-editor'; /** @@ -36,7 +36,7 @@ export default function EditorBox() { hasFixedToolbar: true, } } > - <BlockTools /> + <BlockToolbar hideDragHandle /> <BlockCanvas height="100%" styles={ editorStyles } /> </BlockEditorProvider> </div> diff --git a/storybook/stories/playground/fullpage/index.js b/storybook/stories/playground/fullpage/index.js index 961c15f71f31d..8b8c037ceb72a 100644 --- a/storybook/stories/playground/fullpage/index.js +++ b/storybook/stories/playground/fullpage/index.js @@ -5,7 +5,6 @@ import { useEffect, useState } from '@wordpress/element'; import { BlockCanvas, BlockEditorProvider, - BlockTools, BlockInspector, } from '@wordpress/block-editor'; import { registerCoreBlocks } from '@wordpress/block-library'; @@ -46,9 +45,9 @@ export default function EditorFullPage() { <div className="playground__sidebar"> <BlockInspector /> </div> - <BlockTools className="playground__content"> + <div className="playground__content"> <BlockCanvas height="100%" styles={ editorStyles } /> - </BlockTools> + </div> </BlockEditorProvider> </div> ); diff --git a/storybook/stories/playground/with-undo-redo/index.js b/storybook/stories/playground/with-undo-redo/index.js index a51d8624282a6..8bef2d184f8c5 100644 --- a/storybook/stories/playground/with-undo-redo/index.js +++ b/storybook/stories/playground/with-undo-redo/index.js @@ -7,7 +7,7 @@ import { registerCoreBlocks } from '@wordpress/block-library'; import { BlockEditorProvider, BlockCanvas, - BlockTools, + BlockToolbar, } from '@wordpress/block-editor'; import { Button } from '@wordpress/components'; import { undo as undoIcon, redo as redoIcon } from '@wordpress/icons'; @@ -58,7 +58,7 @@ export default function EditorWithUndoRedo() { icon={ redoIcon } label="Redo" /> - <BlockTools /> + <BlockToolbar hideDragHandle /> </div> <BlockCanvas height="100%" styles={ editorStyles } /> </BlockEditorProvider> diff --git a/storybook/stories/playground/with-undo-redo/style.css b/storybook/stories/playground/with-undo-redo/style.css index a3f0bd5d23deb..6ed082a1de719 100644 --- a/storybook/stories/playground/with-undo-redo/style.css +++ b/storybook/stories/playground/with-undo-redo/style.css @@ -6,5 +6,9 @@ display: flex; align-items: center; border-bottom: 1px solid #ddd; - height: 48px; + height: 46px; } + +.editor-with-undo-redo__toolbar .components-accessible-toolbar.block-editor-block-contextual-toolbar { + border-bottom: none; +} \ No newline at end of file diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 85590e9ec1596..f1041fa60061a 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -729,7 +729,9 @@ test.describe( 'Image', () => { await page .getByRole( 'button', { name: 'Publish', exact: true } ) .click(); - await page.getByRole( 'button', { name: 'Upload all' } ).click(); + await page + .getByRole( 'button', { name: 'Upload', exact: true } ) + .click(); await expect( page.locator( '.components-spinner' ) ).toHaveCount( 0 ); diff --git a/test/e2e/specs/editor/plugins/block-context.spec.js b/test/e2e/specs/editor/plugins/block-context.spec.js index 1fc91debd1145..c819f29bc7383 100644 --- a/test/e2e/specs/editor/plugins/block-context.spec.js +++ b/test/e2e/specs/editor/plugins/block-context.spec.js @@ -64,7 +64,11 @@ test.describe( 'Block context', () => { .fill( '123' ); await editorPage - .getByRole( 'button', { name: 'Preview', expanded: false } ) + .getByRole( 'button', { + name: 'View', + expanded: false, + exact: true, + } ) .click(); await editorPage .getByRole( 'menuitem', { name: 'Preview in new tab' } ) diff --git a/test/e2e/specs/editor/plugins/custom-post-types.spec.js b/test/e2e/specs/editor/plugins/custom-post-types.spec.js index 17a497f26cee0..01dde03650ef7 100644 --- a/test/e2e/specs/editor/plugins/custom-post-types.spec.js +++ b/test/e2e/specs/editor/plugins/custom-post-types.spec.js @@ -31,7 +31,7 @@ test.describe( 'Test Custom Post Types', () => { await editor.openDocumentSettingsSidebar(); await page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { + .getByRole( 'tab', { name: 'Hierarchical No Title', } ) .click(); diff --git a/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js b/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js new file mode 100644 index 0000000000000..3bf0ff459cb7f --- /dev/null +++ b/test/e2e/specs/editor/plugins/inner-blocks-locking-all-embed.spec.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const EMBED_URLS = [ + '/oembed/1.0/proxy', + `rest_route=${ encodeURIComponent( '/oembed/1.0/proxy' ) }`, +]; +const MOCK_RESPONSES = { + url: 'https://twitter.com/wordpress', + html: '<p>Mock success response.</p>', + type: 'rich', + provider_name: 'Twitter', + provider_url: 'https://twitter.com', + version: '1.0', +}; + +test.describe( 'Embed block inside a locked all parent', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( + 'gutenberg-test-innerblocks-locking-all-embed' + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-innerblocks-locking-all-embed' + ); + } ); + + test( 'embed block should be able to embed external content', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await page.route( + ( url ) => EMBED_URLS.some( ( u ) => url.href.includes( u ) ), + async ( route ) => { + await route.fulfill( { + json: MOCK_RESPONSES, + } ); + } + ); + + await editor.insertBlock( { + name: 'test/test-inner-blocks-locking-all-embed', + } ); + await page + .getByRole( 'textbox', { name: 'Embed URL' } ) + .fill( 'https://twitter.com/wordpress' ); + await page.keyboard.press( 'Enter' ); + + await expect( + page.getByRole( 'document', { name: 'Block: Twitter' } ) + ).toBeVisible(); + } ); +} ); diff --git a/test/e2e/specs/editor/various/a11y.spec.js b/test/e2e/specs/editor/various/a11y.spec.js index 0a5e421debedb..05c4ea3b8e97e 100644 --- a/test/e2e/specs/editor/various/a11y.spec.js +++ b/test/e2e/specs/editor/various/a11y.spec.js @@ -148,9 +148,6 @@ test.describe( 'a11y (@firefox, @webkit)', () => { const blocksTab = preferencesModal.locator( 'role=tab[name="Blocks"i]' ); - const panelsTab = preferencesModal.locator( - 'role=tab[name="Panels"i]' - ); // Check initial focus is on the modal dialog container. await expect( preferencesModal ).toBeFocused(); @@ -204,13 +201,5 @@ test.describe( 'a11y (@firefox, @webkit)', () => { await expect( closeButton ).toBeFocused(); await pageUtils.pressKeys( 'Shift+Tab' ); await expect( preferencesModalContent ).not.toBeFocused(); - - // The Panels tab panel content is short and not scrollable. - // Check it's not focusable. - await clickAndFocusTab( panelsTab ); - await pageUtils.pressKeys( 'Shift+Tab' ); - await expect( closeButton ).toBeFocused(); - await pageUtils.pressKeys( 'Shift+Tab' ); - await expect( preferencesModalContent ).not.toBeFocused(); } ); } ); diff --git a/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js index f0bfe5bff203f..a695b0a9ead67 100644 --- a/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js +++ b/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js @@ -127,7 +127,7 @@ test.describe( 'Navigating the block hierarchy', () => { await pageUtils.pressKeys( 'ctrl+`' ); // Navigate to the block settings sidebar and tweak the column count. - await pageUtils.pressKeys( 'Tab', { times: 5 } ); + await pageUtils.pressKeys( 'Tab', { times: 4 } ); await expect( page.getByRole( 'slider', { name: 'Columns' } ) ).toBeFocused(); diff --git a/test/e2e/specs/editor/various/footnotes.spec.js b/test/e2e/specs/editor/various/footnotes.spec.js index 14a2fc653e387..6102f48749543 100644 --- a/test/e2e/specs/editor/various/footnotes.spec.js +++ b/test/e2e/specs/editor/various/footnotes.spec.js @@ -362,7 +362,7 @@ test.describe( 'Footnotes', () => { await editor.openDocumentSettingsSidebar(); await page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Post' } ) + .getByRole( 'tab', { name: 'Post' } ) .click(); await page.locator( 'a:text("2 Revisions")' ).click(); await page.locator( '.revisions-controls .ui-slider-handle' ).focus(); @@ -440,7 +440,7 @@ test.describe( 'Footnotes', () => { await editor.openDocumentSettingsSidebar(); await page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Post' } ) + .getByRole( 'tab', { name: 'Post' } ) .click(); // Visit the published post. diff --git a/test/e2e/specs/editor/various/inserting-blocks.spec.js b/test/e2e/specs/editor/various/inserting-blocks.spec.js index a48fe117c97a2..4d26a198fc345 100644 --- a/test/e2e/specs/editor/various/inserting-blocks.spec.js +++ b/test/e2e/specs/editor/various/inserting-blocks.spec.js @@ -17,6 +17,13 @@ test.use( { test.describe( 'Inserting blocks (@firefox, @webkit)', () => { test.afterAll( async ( { requestUtils } ) => { await requestUtils.deleteAllPosts(); + await requestUtils.deleteAllBlocks(); + await requestUtils.deleteAllPatternCategories(); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllBlocks(); + await requestUtils.deleteAllPatternCategories(); } ); test( 'inserts blocks by dragging and dropping from the global inserter', async ( { @@ -58,34 +65,16 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const paragraphBoundingBox = await paragraphBlock.boundingBox(); await expect( insertingBlocksUtils.indicator ).toBeVisible(); - // Expect the indicator to be below the paragraph block. - await expect - .poll( () => - insertingBlocksUtils.indicator - .boundingBox() - .then( ( { y } ) => y ) - ) - .toBeGreaterThan( paragraphBoundingBox.y ); + await insertingBlocksUtils.expectIndicatorBelowParagraph( + paragraphBoundingBox + ); await page.mouse.down(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( - // Hover on the right side of the block to avoid collapsing with the preview. - paragraphBoundingBox.x + paragraphBoundingBox.width - 1, - // Hover on the bottom of the paragraph block. - paragraphBoundingBox.y + paragraphBoundingBox.height - 1 - ); - } - // Expect the indicator to be below the paragraph block. - await expect - .poll( () => - insertingBlocksUtils.indicator - .boundingBox() - .then( ( { y } ) => y ) - ) - .toBeGreaterThan( paragraphBoundingBox.y ); + + await insertingBlocksUtils.dragOver( paragraphBoundingBox ); + await insertingBlocksUtils.expectIndicatorBelowParagraph( + paragraphBoundingBox + ); // Expect the draggable-chip to appear. await expect( insertingBlocksUtils.draggableChip ).toBeVisible(); @@ -139,16 +128,8 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const paragraphBoundingBox = await paragraphBlock.boundingBox(); await page.mouse.down(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( - // Hover on the right side of the block to avoid collapsing with the preview. - paragraphBoundingBox.x + paragraphBoundingBox.width - 1, - // Hover on the bottom of the paragraph block. - paragraphBoundingBox.y + paragraphBoundingBox.height - 1 - ); - } + + await insertingBlocksUtils.dragOver( paragraphBoundingBox ); await expect( insertingBlocksUtils.indicator ).toBeVisible(); await expect( insertingBlocksUtils.draggableChip ).toBeVisible(); @@ -210,26 +191,13 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const paragraphBoundingBox = await paragraphBlock.boundingBox(); await page.mouse.down(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( - // Hover on the right side of the block to avoid collapsing with the preview. - paragraphBoundingBox.x + paragraphBoundingBox.width - 1, - // Hover on the bottom of the paragraph block. - paragraphBoundingBox.y + paragraphBoundingBox.height - 1 - ); - } + + await insertingBlocksUtils.dragOver( paragraphBoundingBox ); await expect( insertingBlocksUtils.indicator ).toBeVisible(); - // Expect the indicator to be below the paragraph block. - await expect - .poll( () => - insertingBlocksUtils.indicator - .boundingBox() - .then( ( { y } ) => y ) - ) - .toBeGreaterThan( paragraphBoundingBox.y ); + await insertingBlocksUtils.expectIndicatorBelowParagraph( + paragraphBoundingBox + ); await expect( insertingBlocksUtils.draggableChip ).toBeVisible(); @@ -238,6 +206,103 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { expect( await editor.getEditedPostContent() ).toMatchSnapshot(); } ); + test( 'inserts synced patterns by dragging and dropping from the global inserter', async ( { + admin, + page, + editor, + insertingBlocksUtils, + }, testInfo ) => { + testInfo.fixme( + testInfo.project.name === 'firefox', + 'The clientX value is always 0 in firefox, see https://github.com/microsoft/playwright/issues/17761 for more info.' + ); + const PATTERN_NAME = 'My synced pattern'; + + await admin.createNewPost(); + await editor.switchToLegacyCanvas(); + + // We need a dummy block in place to display the drop indicator due to a bug. + // @see https://github.com/WordPress/gutenberg/issues/44064 + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Dummy text' }, + } ); + + const paragraphBlock = page.locator( + '[data-type="core/paragraph"] >> text=Dummy text' + ); + + // Create a synced pattern from the paragraph block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'A useful paragraph to reuse' }, + } ); + await editor.showBlockToolbar(); + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Create pattern' } ).click(); + const createPatternDialog = page.getByRole( 'dialog', { + name: 'Create pattern', + } ); + await createPatternDialog + .getByRole( 'textbox', { name: 'Name' } ) + .fill( PATTERN_NAME ); + await createPatternDialog + .getByRole( 'checkbox', { name: 'Synced' } ) + .setChecked( true ); + await createPatternDialog + .getByRole( 'button', { name: 'Create' } ) + .click(); + const patternBlock = page.getByRole( 'document', { + name: 'Block: Pattern', + } ); + await expect( patternBlock ).toBeFocused(); + + // Insert a synced pattern. + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Toggle block inserter"i]' + ); + await page.fill( + 'role=region[name="Block Library"i] >> role=searchbox[name="Search for blocks and patterns"i]', + PATTERN_NAME + ); + await page.hover( + `role=listbox[name="Block Patterns"i] >> role=option[name="${ PATTERN_NAME }"i]` + ); + + const paragraphBoundingBox = await paragraphBlock.boundingBox(); + + await page.mouse.down(); + + await insertingBlocksUtils.dragOver( paragraphBoundingBox ); + await expect( insertingBlocksUtils.indicator ).toBeVisible(); + await insertingBlocksUtils.expectIndicatorBelowParagraph( + paragraphBoundingBox + ); + await expect( insertingBlocksUtils.draggableChip ).toBeVisible(); + + await page.mouse.up(); + + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { + content: 'Dummy text', + }, + }, + { + name: 'core/block', + attributes: { ref: expect.any( Number ) }, + }, + { + name: 'core/block', + attributes: { ref: expect.any( Number ) }, + }, + ] ); + } ); + test( 'cancels dragging patterns from the global inserter by pressing Escape', async ( { admin, page, @@ -278,16 +343,8 @@ test.describe( 'Inserting blocks (@firefox, @webkit)', () => { const paragraphBoundingBox = await paragraphBlock.boundingBox(); await page.mouse.down(); - // Call the move function twice to make sure the `dragOver` event is sent. - // @see https://github.com/microsoft/playwright/issues/17153 - for ( let i = 0; i < 2; i += 1 ) { - await page.mouse.move( - // Hover on the right side of the block to avoid collapsing with the preview. - paragraphBoundingBox.x + paragraphBoundingBox.width - 1, - // Hover on the bottom of the paragraph block. - paragraphBoundingBox.y + paragraphBoundingBox.height - 1 - ); - } + + await insertingBlocksUtils.dragOver( paragraphBoundingBox ); await expect( insertingBlocksUtils.indicator ).toBeVisible(); await expect( insertingBlocksUtils.draggableChip ).toBeVisible(); @@ -388,4 +445,23 @@ class InsertingBlocksUtils { 'data-testid=block-draggable-chip >> visible=true' ); } + async dragOver( boundingBox ) { + // Call the move function twice to make sure the `dragOver` event is sent. + // @see https://github.com/microsoft/playwright/issues/17153 + for ( let i = 0; i < 2; i += 1 ) { + await this.page.mouse.move( + // Hover on the right side of the block to avoid collapsing with the preview. + boundingBox.x + boundingBox.width - 1, + // Hover on the bottom of the paragraph block. + boundingBox.y + boundingBox.height - 1 + ); + } + } + + async expectIndicatorBelowParagraph( paragraphBoundingBox ) { + // Expect the indicator to be below the paragraph block. + await expect + .poll( () => this.indicator.boundingBox().then( ( { y } ) => y ) ) + .toBeGreaterThan( paragraphBoundingBox.y ); + } } diff --git a/test/e2e/specs/editor/various/is-typing.spec.js b/test/e2e/specs/editor/various/is-typing.spec.js index 0cd5e0d6f6495..8063f688409c4 100644 --- a/test/e2e/specs/editor/various/is-typing.spec.js +++ b/test/e2e/specs/editor/various/is-typing.spec.js @@ -14,24 +14,27 @@ test.describe( 'isTyping', () => { // Insert paragraph await page.keyboard.type( 'Type' ); - const blockToolbar = page.locator( - 'role=toolbar[name="Block tools"i]' + const blockToolbarPopover = page.locator( + '[data-wp-component="Popover"]', + { + has: page.locator( 'role=toolbar[name="Block tools"i]' ), + } ); - // Toolbar should not be showing - await expect( blockToolbar ).toBeHidden(); + // Toolbar Popover should not be showing + await expect( blockToolbarPopover ).toBeHidden(); // Moving the mouse shows the toolbar. await editor.showBlockToolbar(); - // Toolbar is visible. - await expect( blockToolbar ).toBeVisible(); + // Toolbar Popover is visible. + await expect( blockToolbarPopover ).toBeVisible(); // Typing again hides the toolbar await page.keyboard.type( ' and continue' ); - // Toolbar is hidden again - await expect( blockToolbar ).toBeHidden(); + // Toolbar Popover is hidden again + await expect( blockToolbarPopover ).toBeHidden(); } ); test( 'should not close the dropdown when typing in it', async ( { @@ -41,17 +44,22 @@ test.describe( 'isTyping', () => { // Add a block with a dropdown in the toolbar that contains an input. await editor.insertBlock( { name: 'core/query' } ); - // Tab to Start Blank Button - await page.keyboard.press( 'Tab' ); - // Select the Start Blank Button - await page.keyboard.press( 'Enter' ); - // Select the First variation - await page.keyboard.press( 'Enter' ); + await editor.canvas + .getByRole( 'document', { name: 'Block: Query Loop' } ) + .getByRole( 'button', { name: 'Start blank' } ) + .click(); + + await editor.canvas + .getByRole( 'button', { name: 'Title & Date' } ) + .click(); + // Moving the mouse shows the toolbar. await editor.showBlockToolbar(); // Open the dropdown. - await page.getByRole( 'button', { name: 'Display settings' } ).click(); - + const displaySettings = page.getByRole( 'button', { + name: 'Display settings', + } ); + await displaySettings.click(); const itemsPerPageInput = page.getByLabel( 'Items per Page' ); // Make sure we're where we think we are await expect( itemsPerPageInput ).toBeFocused(); diff --git a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js index 080abe011206a..84536c88227ce 100644 --- a/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js +++ b/test/e2e/specs/editor/various/keyboard-navigable-blocks.spec.js @@ -75,9 +75,7 @@ test.describe( 'Order of block keyboard navigation', () => { ); await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( - 'Post (selected)' - ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Post' ); } ); test( 'allows tabbing in navigation mode if no block is selected (reverse)', async ( { @@ -151,7 +149,7 @@ test.describe( 'Order of block keyboard navigation', () => { ); await page.keyboard.press( 'Tab' ); - await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Post' ); + await KeyboardNavigableBlocks.expectLabelToHaveFocus( 'Block' ); await pageUtils.pressKeys( 'shift+Tab' ); await KeyboardNavigableBlocks.expectLabelToHaveFocus( @@ -233,7 +231,7 @@ class KeyboardNavigableBlocks { await expect( activeElement ).toHaveText( paragraphText ); await this.page.keyboard.press( 'Tab' ); - await this.expectLabelToHaveFocus( 'Post' ); + await this.expectLabelToHaveFocus( 'Block' ); // Need to shift+tab here to end back in the block. If not, we'll be in the next region and it will only require 4 region jumps instead of 5. await this.pageUtils.pressKeys( 'shift+Tab' ); diff --git a/test/e2e/specs/editor/various/multi-entity-saving.spec.js b/test/e2e/specs/editor/various/multi-entity-saving.spec.js new file mode 100644 index 0000000000000..7a7298c137c4b --- /dev/null +++ b/test/e2e/specs/editor/various/multi-entity-saving.spec.js @@ -0,0 +1,210 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Editor - Multi-entity save flow', () => { + let originalSiteTitle, originalBlogDescription; + + test.beforeEach( async ( { requestUtils } ) => { + const siteSettings = await requestUtils.getSiteSettings(); + + originalSiteTitle = siteSettings.title; + originalBlogDescription = siteSettings.description; + } ); + + test.afterEach( async ( { requestUtils, editor } ) => { + await requestUtils.updateSiteSettings( { + title: originalSiteTitle, + description: originalBlogDescription, + } ); + + // Restore the Publish sidebar. + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: true, + } ); + } ); + + test( 'Save flow should work as expected', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Test Post...' ); + await page.keyboard.press( 'Enter' ); + + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + const publishButton = topBar.getByRole( 'button', { name: 'Publish' } ); + + // Should not trigger multi-entity save button with only post edited. + await expect( publishButton ).toBeEnabled(); + await expect( publishButton ).not.toHaveClass( /has-changes-dot/ ); + + const openPublishPanel = page.getByRole( 'button', { + name: 'Open publish panel', + } ); + const openSavePanel = page.getByRole( 'button', { + name: 'Open save panel', + } ); + const publishPanel = page.getByRole( 'region', { + name: 'Editor publish', + } ); + + // Should only have publish panel a11y button active with only post edited. + await expect( openPublishPanel ).toBeVisible(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Add a title block and edit it. + await editor.insertBlock( { + name: 'core/site-title', + } ); + const siteTitleField = editor.canvas.getByRole( 'textbox', { + name: 'Site title text', + } ); + await siteTitleField.fill( `${ originalSiteTitle }...` ); + + // Should trigger multi-entity save button once template part edited. + await expect( publishButton ).toHaveClass( /has-changes-dot/ ); + + // Should only have save panel a11y button active after child entities edited. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeVisible(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Opening panel has boxes checked by default. + await publishButton.click(); + await expect( publishPanel ).toContainText( 'Are you ready to save?' ); + const allCheckboxes = await publishPanel + .getByRole( 'checkbox' ) + .count(); + await expect( + publishPanel.getByRole( 'checkbox', { checked: true } ) + ).toHaveCount( allCheckboxes ); + + // Should not show other panels (or their a11y buttons) while save panel opened. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to publish?' + ); + + // Publish panel should open after saving. + await publishPanel.getByRole( 'button', { name: 'Save' } ).click(); + await expect( publishPanel ).toContainText( + 'Are you ready to publish?' + ); + + // No other panels (or their a11y buttons) should be present with publish panel open. + await expect( openPublishPanel ).toBeHidden(); + await expect( openSavePanel ).toBeHidden(); + await expect( publishPanel ).not.toContainText( + 'Are you ready to save?' + ); + + // Close publish panel. + await publishPanel.getByRole( 'button', { name: 'Cancel' } ).click(); + + // Verify saving is disabled. + await expect( + topBar.getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(); + await expect( publishButton ).not.toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeHidden(); + + await editor.publishPost(); + + // Update the post. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Updated post title' ); + + const updateButton = topBar.getByRole( 'button', { name: 'Update' } ); + + // Verify update button is enabled. + await expect( updateButton ).toBeEnabled(); + + // Verify multi-entity saving not enabled. + await expect( updateButton ).not.toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeHidden(); + + await siteTitleField.fill( `${ originalSiteTitle }!` ); + + // Multi-entity saving should be enabled. + await expect( updateButton ).toHaveClass( /has-changes-dot/ ); + await expect( openSavePanel ).toBeVisible(); + } ); + + test( 'Site blocks should save individually', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost(); + await editor.setPreferences( 'core/edit-post', { + isPublishSidebarEnabled: false, + } ); + + // Add site blocks. + await editor.insertBlock( { + name: 'core/site-title', + } ); + await editor.insertBlock( { + name: 'core/site-tagline', + } ); + + const siteTitleField = editor.canvas.getByRole( 'textbox', { + name: 'Site title text', + } ); + + // Ensure title is retrieved before typing. + await expect( siteTitleField ).toHaveText( originalSiteTitle ); + + await siteTitleField.fill( `${ originalSiteTitle }...` ); + await editor.canvas + .getByRole( 'document', { + name: 'Block: Site Tagline', + } ) + .fill( 'Just another WordPress site' ); + + const topBar = page.getByRole( 'region', { name: 'Editor top bar' } ); + const publishPanel = page.getByRole( 'region', { + name: 'Editor publish', + } ); + + await topBar.getByRole( 'button', { name: 'Publish' } ).click(); + await expect( publishPanel.getByRole( 'checkbox' ) ).toHaveCount( 3 ); + + // Skip site title saving. + await publishPanel + .getByRole( 'checkbox', { + name: 'Title', + } ) + .setChecked( false ); + + await publishPanel.getByRole( 'button', { name: 'Save' } ).click(); + + // Wait for the snackbar notice that the post has been published. + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .filter( { hasText: 'published' } ) + .waitFor(); + + await topBar.getByRole( 'button', { name: 'Update' } ).click(); + + await expect( publishPanel.getByRole( 'checkbox' ) ).toHaveCount( 1 ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/new-post.spec.js b/test/e2e/specs/editor/various/new-post.spec.js index cc0243eb8e631..b3591db1ec50b 100644 --- a/test/e2e/specs/editor/various/new-post.spec.js +++ b/test/e2e/specs/editor/various/new-post.spec.js @@ -32,9 +32,9 @@ test.describe( 'new editor state', () => { await expect( title ).toBeEditable(); await expect( title ).toHaveText( '' ); - // Should display the Preview button. + // Should display the View button. await expect( - page.locator( 'role=button[name="Preview"i]' ) + page.locator( 'role=button[name="View"i]' ) ).toBeVisible(); // Should display the Post Formats UI. diff --git a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js index b9bfbf9dd6b05..021199cc09495 100644 --- a/test/e2e/specs/editor/various/post-editor-template-mode.spec.js +++ b/test/e2e/specs/editor/various/post-editor-template-mode.spec.js @@ -131,7 +131,9 @@ class PostEditorTemplateMode { // Only match the beginning of Select template: because it contains the template name or slug afterwards. await this.editorSettingsSidebar - .locator( 'role=button[name^="Select template"i]' ) + .getByRole( 'button', { + name: 'Template options', + } ) .click(); } @@ -139,17 +141,22 @@ class PostEditorTemplateMode { await this.disableTemplateWelcomeGuide(); await this.openTemplatePopover(); - - await this.page.locator( 'role=button[name="Edit template"i]' ).click(); + await this.page + .getByRole( 'menuitem', { + name: 'Edit template', + } ) + .click(); // Check that we switched properly to edit mode. await this.page.waitForSelector( 'role=button[name="Dismiss this notice"] >> text=Editing template. Changes made here affect all posts and pages that use the template.' ); - await expect( - this.editorTopBar.getByRole( 'heading[level=1]' ) - ).toHaveText( 'Editing template: Single Entries' ); + const title = this.editorTopBar.getByRole( 'heading', { + name: 'Editing template: Single Entries', + } ); + + await expect( title ).toBeVisible(); } async createPostAndSaveDraft() { diff --git a/test/e2e/specs/editor/various/pref-modal.spec.js b/test/e2e/specs/editor/various/pref-modal.spec.js new file mode 100644 index 0000000000000..f99c7d32a22a9 --- /dev/null +++ b/test/e2e/specs/editor/various/pref-modal.spec.js @@ -0,0 +1,55 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Preferences modal', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.describe( 'Preferences modal adaps to viewport', () => { + test( 'Enable pre-publish flow is visible on desktop ', async ( { + page, + } ) => { + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' + ); + await page.click( 'role=menuitem[name="Preferences"i]' ); + + const prePublishToggle = page.locator( + 'role=checkbox[name="Enable pre-publish flow"i]' + ); + + await expect( prePublishToggle ).toBeVisible(); + } ); + } ); + test.describe( 'Preferences modal adaps to viewport', () => { + test( 'Enable pre-publish flow is not visible on mobile ', async ( { + page, + } ) => { + await page.setViewportSize( { width: 500, height: 800 } ); + + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Options"i]' + ); + await page.click( 'role=menuitem[name="Preferences"i]' ); + + const generalButton = page.locator( + 'role=button[name="General"i]' + ); + + const generalTabPanel = page.locator( + 'role=tabPanel[name="General"i]' + ); + + const prePublishToggle = page.locator( + 'role=checkbox[name="Enable pre-publish flow"i]' + ); + + await expect( generalButton ).toBeVisible(); + await expect( generalTabPanel ).toBeHidden(); + await expect( prePublishToggle ).toBeHidden(); + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/various/preview.spec.js b/test/e2e/specs/editor/various/preview.spec.js index 8a4ee5a6bd81d..0657a45567baf 100644 --- a/test/e2e/specs/editor/various/preview.spec.js +++ b/test/e2e/specs/editor/various/preview.spec.js @@ -22,11 +22,6 @@ test.describe( 'Preview', () => { } ) => { const editorPage = page; - // Disabled until content present. - await expect( - editorPage.locator( 'role=button[name="Preview"i]' ) - ).toBeDisabled(); - await editor.canvas .locator( 'role=textbox[name="Add title"i]' ) .type( 'Hello World' ); @@ -301,7 +296,7 @@ test.describe( 'Preview with private custom post type', () => { } ); // Open the view menu. - await page.click( 'role=button[name="Preview"i]' ); + await page.click( 'role=button[name="View"i]' ); await expect( page.locator( 'role=menuitem[name="Preview in new tab"i]' ) @@ -316,7 +311,7 @@ class PreviewUtils { async waitForPreviewNavigation( previewPage ) { const previewToggle = this.page.locator( - 'role=button[name="Preview"i][expanded=false]' + 'role=button[name="View"i][expanded=false]' ); const isDropdownClosed = await previewToggle.isVisible(); if ( isDropdownClosed ) { @@ -335,9 +330,9 @@ class PreviewUtils { ); await this.page.click( 'role=menuitem[name="Preferences"i]' ); - // Navigate to panels section. + // Navigate to general section. await this.page.click( - 'role=dialog[name="Preferences"i] >> role=tab[name="Panels"i]' + 'role=dialog[name="Preferences"i] >> role=tab[name="General"i]' ); // Find custom fields checkbox. diff --git a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js index 0223821613f55..a8e49f7a6b84d 100644 --- a/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js +++ b/test/e2e/specs/editor/various/shortcut-focus-toolbar.spec.js @@ -98,6 +98,10 @@ test.describe( 'Focus toolbar shortcut (alt + F10)', () => { // Test: Focus the block toolbar from empty block await editor.insertBlock( { name: 'core/paragraph' } ); + // This fails if we don't wait for the block toolbar to show. + await expect( + toolbarUtils.blockToolbarParagraphButton + ).toBeVisible(); await toolbarUtils.moveToToolbarShortcut(); await expect( toolbarUtils.blockToolbarParagraphButton diff --git a/test/e2e/specs/site-editor/block-removal.spec.js b/test/e2e/specs/site-editor/block-removal.spec.js index 5c46ac769efd3..27d3762364b44 100644 --- a/test/e2e/specs/site-editor/block-removal.spec.js +++ b/test/e2e/specs/site-editor/block-removal.spec.js @@ -34,7 +34,9 @@ test.describe( 'Site editor block removal prompt', () => { // Expect the block removal prompt to have appeared await expect( - page.getByText( 'Query Loop displays a list of posts or pages.' ) + page.getByText( + 'Post or page content will not be displayed if you delete these blocks.' + ) ).toBeVisible(); } ); @@ -57,7 +59,7 @@ test.describe( 'Site editor block removal prompt', () => { // Expect the block removal prompt to have appeared await expect( page.getByText( - 'Post Template displays each post or page in a Query Loop.' + 'Post or page content will not be displayed if you delete this block.' ) ).toBeVisible(); } ); diff --git a/test/e2e/specs/site-editor/font-library.spec.js b/test/e2e/specs/site-editor/font-library.spec.js index 6aca027a30e78..531398fb49590 100644 --- a/test/e2e/specs/site-editor/font-library.spec.js +++ b/test/e2e/specs/site-editor/font-library.spec.js @@ -70,5 +70,26 @@ test.describe( 'Font Library', () => { page.getByRole( 'heading', { name: 'Fonts' } ) ).toBeVisible(); } ); + + test( 'should show font variant panel when clicking on a font family', async ( { + page, + } ) => { + await page.getByRole( 'button', { name: /styles/i } ).click(); + await page + .getByRole( 'button', { name: /typography styles/i } ) + .click(); + await page + .getByRole( 'button', { + name: /manage fonts/i, + } ) + .click(); + await page.getByRole( 'button', { name: /system font/i } ).click(); + await expect( + page.getByRole( 'heading', { name: /system font/i } ) + ).toBeVisible(); + await expect( + page.getByRole( 'checkbox', { name: /system font normal/i } ) + ).toBeVisible(); + } ); } ); } ); diff --git a/test/e2e/specs/site-editor/new-templates-list.spec.js b/test/e2e/specs/site-editor/new-templates-list.spec.js index be6008080200c..34daeb6d40f09 100644 --- a/test/e2e/specs/site-editor/new-templates-list.spec.js +++ b/test/e2e/specs/site-editor/new-templates-list.spec.js @@ -14,26 +14,32 @@ test.describe( 'Templates', () => { await Promise.all( [ requestUtils.activateTheme( 'twentytwentyone' ), requestUtils.deactivatePlugin( 'gutenberg-test-dataviews' ), + requestUtils.deleteAllTemplates( 'wp_template' ), ] ); } ); test( 'Sorting', async ( { admin, page } ) => { await admin.visitSiteEditor( { path: '/wp_template/all' } ); // Descending by title. - await page.getByRole( 'button', { name: 'Template' } ).click(); - await page.getByRole( 'menuitem', { name: 'Sort descending' } ).click(); + await page + .getByRole( 'button', { name: 'Template', exact: true } ) + .click(); + await page + .getByRole( 'menuitemradio', { + name: 'Sort descending', + } ) + .click(); const firstTitle = page .getByRole( 'region', { name: 'Template', includeHidden: true, } ) - .getByRole( 'heading', { - level: 3, - includeHidden: true, - } ) + .getByRole( 'link', { includeHidden: true } ) .first(); await expect( firstTitle ).toHaveText( 'Tag Archives' ); // Ascending by title. - await page.getByRole( 'menuitem', { name: 'Sort ascending' } ).click(); + await page + .getByRole( 'menuitemradio', { name: 'Sort ascending' } ) + .click(); await expect( firstTitle ).toHaveText( 'Category Archives' ); } ); test( 'Filtering', async ( { requestUtils, admin, page } ) => { @@ -48,7 +54,7 @@ test.describe( 'Templates', () => { await page.keyboard.type( 'tag' ); const titles = page .getByRole( 'region', { name: 'Template' } ) - .getByRole( 'heading', { level: 3 } ); + .getByRole( 'link' ); await expect( titles ).toHaveCount( 1 ); await expect( titles.first() ).toHaveText( 'Tag Archives' ); await page.getByRole( 'button', { name: 'Reset filters' } ).click(); @@ -57,7 +63,7 @@ test.describe( 'Templates', () => { // Filter by author. await page.getByRole( 'button', { name: 'Add filter' } ).click(); await page.getByRole( 'menuitem', { name: 'Author' } ).hover(); - await page.getByRole( 'menuitemcheckbox', { name: 'admin' } ).click(); + await page.getByRole( 'menuitem', { name: 'admin' } ).click(); await expect( titles ).toHaveCount( 1 ); await expect( titles.first() ).toHaveText( 'Date Archives' ); @@ -68,17 +74,13 @@ test.describe( 'Templates', () => { await expect( titles ).toHaveCount( 3 ); await page.getByRole( 'button', { name: 'Add filter' } ).click(); await page.getByRole( 'menuitem', { name: 'Author' } ).hover(); - await page - .getByRole( 'menuitemcheckbox', { name: 'Emptytheme' } ) - .click(); + await page.getByRole( 'menuitem', { name: 'Emptytheme' } ).click(); await expect( titles ).toHaveCount( 2 ); - - await requestUtils.deleteAllTemplates( 'wp_template' ); } ); test( 'Field visibility', async ( { admin, page } ) => { await admin.visitSiteEditor( { path: '/wp_template/all' } ); await page.getByRole( 'button', { name: 'Description' } ).click(); - await page.getByRole( 'menuitem', { name: 'Hide' } ).click(); + await page.getByRole( 'menuitemradio', { name: 'Hide' } ).click(); await expect( page.getByRole( 'button', { name: 'Description' } ) ).toBeHidden(); diff --git a/test/e2e/specs/site-editor/pages.spec.js b/test/e2e/specs/site-editor/pages.spec.js index af58daeaedbe4..6de94c3c2d673 100644 --- a/test/e2e/specs/site-editor/pages.spec.js +++ b/test/e2e/specs/site-editor/pages.spec.js @@ -119,7 +119,7 @@ test.describe( 'Pages', () => { .getByRole( 'region', { name: 'Editor settings' } ) .getByRole( 'button', { name: 'Template options' } ) .click(); - await page.getByRole( 'button', { name: 'Edit template' } ).click(); + await page.getByRole( 'menuitem', { name: 'Edit template' } ).click(); await expect( editor.canvas.getByRole( 'document', { name: 'Block: Content', @@ -129,7 +129,7 @@ test.describe( 'Pages', () => { ); await expect( page.locator( - 'role=button[name="Dismiss this notice"i] >> text="You are editing a template."' + 'role=button[name="Dismiss this notice"i] >> text="Editing template. Changes made here affect all posts and pages that use the template."' ) ).toBeVisible(); @@ -215,24 +215,13 @@ test.describe( 'Pages', () => { } ) ).toBeHidden(); - // Content blocks are wrapped in a Group block by default. + // Ensure post title component to be visible. await expect( - editor.canvas - .getByRole( 'document', { - name: 'Block: Group', - } ) - .getByRole( 'document', { - name: 'Block: Content', - } ) + editor.canvas.getByRole( 'textbox', { + name: 'Add Title', + } ) ).toBeVisible(); - // Ensure order is preserved between toggling. - await page - .locator( - '[aria-label="Block: Content"] + [aria-label="Block: Title"]' - ) - .isVisible(); - // Remove focus from templateOptionsButton button. await editor.canvas.locator( 'body' ).click(); diff --git a/test/e2e/specs/site-editor/push-to-global-styles.spec.js b/test/e2e/specs/site-editor/push-to-global-styles.spec.js index 9507245c192d2..71a57fd04e515 100644 --- a/test/e2e/specs/site-editor/push-to-global-styles.spec.js +++ b/test/e2e/specs/site-editor/push-to-global-styles.spec.js @@ -53,6 +53,16 @@ test.describe( 'Push to Global Styles button', () => { } ) ).toBeDisabled(); + // Enable letter case. + const typographyOptions = page.getByRole( 'button', { + name: 'Typography options', + } ); + await typographyOptions.click(); + await page + .getByRole( 'menuitemcheckbox', { name: 'Letter case' } ) + .click(); + await typographyOptions.click(); + // Make the Heading block uppercase await page.getByRole( 'button', { name: 'Uppercase' } ).click(); diff --git a/test/e2e/specs/site-editor/site-editor-export.spec.js b/test/e2e/specs/site-editor/site-editor-export.spec.js new file mode 100644 index 0000000000000..a0a56c18089cc --- /dev/null +++ b/test/e2e/specs/site-editor/site-editor-export.spec.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Site Editor Templates Export', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.activateTheme( 'emptytheme' ), + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllTemplates( 'wp_template_part' ), + ] ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test( 'clicking export should download emptytheme.zip file', async ( { + admin, + page, + } ) => { + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + canvas: 'edit', + } ); + await page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Options' } ) + .click(); + + const promise = page.waitForEvent( 'download' ); + await page.getByRole( 'menuitem', { name: 'Export' } ).click(); + const download = await promise; + expect( download.suggestedFilename() ).toBe( 'emptytheme.zip' ); + } ); +} ); diff --git a/test/e2e/specs/site-editor/title.spec.js b/test/e2e/specs/site-editor/title.spec.js index aa2942670c5a8..3224c519f4f9e 100644 --- a/test/e2e/specs/site-editor/title.spec.js +++ b/test/e2e/specs/site-editor/title.spec.js @@ -22,11 +22,13 @@ test.describe( 'Site editor title', () => { postType: 'wp_template', canvas: 'edit', } ); - const title = page.locator( - 'role=region[name="Editor top bar"i] >> role=heading[level=1]' - ); + const title = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'heading', { + name: 'Editing template: Index', + } ); - await expect( title ).toHaveText( 'Editing template:Index' ); + await expect( title ).toBeVisible(); } ); test( 'displays the selected template name in the title for the header template', async ( { @@ -39,10 +41,12 @@ test.describe( 'Site editor title', () => { postType: 'wp_template_part', canvas: 'edit', } ); - const title = page.locator( - 'role=region[name="Editor top bar"i] >> role=heading[level=1]' - ); + const title = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'heading', { + name: 'Editing template part: header', + } ); - await expect( title ).toHaveText( 'Editing template part:header' ); + await expect( title ).toBeVisible(); } ); } ); diff --git a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js index 2d51b5ac5014b..a27bb28adbb91 100644 --- a/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js +++ b/test/e2e/specs/site-editor/user-global-styles-revisions.spec.js @@ -55,6 +55,11 @@ test.describe( 'Global styles revisions', () => { name: /^Changes saved by /, } ); + // Shows changes made in the revision. + await expect( + page.getByTestId( 'global-styles-revision-changes' ) + ).toHaveText( 'Colors' ); + // There should be 2 revisions not including the reset to theme defaults button. await expect( revisionButtons ).toHaveCount( currentRevisions.length + 1 diff --git a/test/integration/fixtures/blocks/core__form-input.html b/test/integration/fixtures/blocks/core__form-input.html index 718c592641bc3..33f1fe88c2c6a 100644 --- a/test/integration/fixtures/blocks/core__form-input.html +++ b/test/integration/fixtures/blocks/core__form-input.html @@ -1,3 +1,3 @@ -<!-- wp:form-input {"label":"Name","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Name</span><input class="wp-block-form-input" type="text" name="Name" required aria-required="true"/></label> +<!-- wp:form-input --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Label</span><input class="wp-block-form-input__input" type="text" name="label" aria-required="false"/></label></div> <!-- /wp:form-input --> diff --git a/test/integration/fixtures/blocks/core__form-input.json b/test/integration/fixtures/blocks/core__form-input.json index 68dfb9a36e4e6..fee4df284f115 100644 --- a/test/integration/fixtures/blocks/core__form-input.json +++ b/test/integration/fixtures/blocks/core__form-input.json @@ -1,11 +1,14 @@ [ { - "name": "core/missing", + "name": "core/form-input", "isValid": true, "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>", - "originalContent": "<!-- wp:form-input {\"label\":\"Name\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n<!-- /wp:form-input -->" + "type": "text", + "label": "Label", + "inlineLabel": false, + "required": false, + "value": "", + "visibilityPermissions": "all" }, "innerBlocks": [] } diff --git a/test/integration/fixtures/blocks/core__form-input.parsed.json b/test/integration/fixtures/blocks/core__form-input.parsed.json index 73058fc2e17f0..5470c653c403b 100644 --- a/test/integration/fixtures/blocks/core__form-input.parsed.json +++ b/test/integration/fixtures/blocks/core__form-input.parsed.json @@ -1,14 +1,11 @@ [ { "blockName": "core/form-input", - "attrs": { - "label": "Name", - "required": true - }, + "attrs": {}, "innerBlocks": [], - "innerHTML": "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n", + "innerHTML": "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Label</span><input class=\"wp-block-form-input__input\" type=\"text\" name=\"label\" aria-required=\"false\"/></label></div>\n", "innerContent": [ - "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n" + "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Label</span><input class=\"wp-block-form-input__input\" type=\"text\" name=\"label\" aria-required=\"false\"/></label></div>\n" ] } ] diff --git a/test/integration/fixtures/blocks/core__form-input.serialized.html b/test/integration/fixtures/blocks/core__form-input.serialized.html index 718c592641bc3..33f1fe88c2c6a 100644 --- a/test/integration/fixtures/blocks/core__form-input.serialized.html +++ b/test/integration/fixtures/blocks/core__form-input.serialized.html @@ -1,3 +1,3 @@ -<!-- wp:form-input {"label":"Name","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Name</span><input class="wp-block-form-input" type="text" name="Name" required aria-required="true"/></label> +<!-- wp:form-input --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Label</span><input class="wp-block-form-input__input" type="text" name="label" aria-required="false"/></label></div> <!-- /wp:form-input --> diff --git a/test/integration/fixtures/blocks/core__form-input__deprecated-v1.html b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.html new file mode 100644 index 0000000000000..08ea661838620 --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.html @@ -0,0 +1,6 @@ +<!-- wp:form-input --> +<label class="wp-block-form-input__label"> + <span class="wp-block-form-input__label-content">Label</span> + <input class="wp-block-form-input__input" type="text" name="label" aria-required="false"/> +</label> +<!-- /wp:form-input --> diff --git a/test/integration/fixtures/blocks/core__form-input__deprecated-v1.json b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.json new file mode 100644 index 0000000000000..fee4df284f115 --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.json @@ -0,0 +1,15 @@ +[ + { + "name": "core/form-input", + "isValid": true, + "attributes": { + "type": "text", + "label": "Label", + "inlineLabel": false, + "required": false, + "value": "", + "visibilityPermissions": "all" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__form-input__deprecated-v1.parsed.json b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.parsed.json new file mode 100644 index 0000000000000..645337cbfdb4a --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.parsed.json @@ -0,0 +1,11 @@ +[ + { + "blockName": "core/form-input", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n<label class=\"wp-block-form-input__label\">\n\t<span class=\"wp-block-form-input__label-content\">Label</span>\n\t<input class=\"wp-block-form-input__input\" type=\"text\" name=\"label\" aria-required=\"false\"/>\n</label>\n", + "innerContent": [ + "\n<label class=\"wp-block-form-input__label\">\n\t<span class=\"wp-block-form-input__label-content\">Label</span>\n\t<input class=\"wp-block-form-input__input\" type=\"text\" name=\"label\" aria-required=\"false\"/>\n</label>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__form-input__deprecated-v1.serialized.html b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.serialized.html new file mode 100644 index 0000000000000..33f1fe88c2c6a --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-input__deprecated-v1.serialized.html @@ -0,0 +1,3 @@ +<!-- wp:form-input --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Label</span><input class="wp-block-form-input__input" type="text" name="label" aria-required="false"/></label></div> +<!-- /wp:form-input --> diff --git a/test/integration/fixtures/blocks/core__form-submission-notification.html b/test/integration/fixtures/blocks/core__form-submission-notification.html new file mode 100644 index 0000000000000..04eaeef77097a --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submission-notification.html @@ -0,0 +1,5 @@ +<!-- wp:form-submission-notification --> +<div class="wp-block-form-submission-notification form-notification-type-success"><!-- wp:paragraph {"style":{"elements":{"link":{"color":{"text":"#000000"}}}},"backgroundColor":"#00D084","textColor":"#000000"} --> +<p class="has-000000-color has-00-d-084-background-color has-text-color has-background has-link-color">Your form has been submitted successfully.</p> +<!-- /wp:paragraph --></div> +<!-- /wp:form-submission-notification --> diff --git a/test/integration/fixtures/blocks/core__form-submission-notification.json b/test/integration/fixtures/blocks/core__form-submission-notification.json new file mode 100644 index 0000000000000..dac7502e9716c --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submission-notification.json @@ -0,0 +1,31 @@ +[ + { + "name": "core/form-submission-notification", + "isValid": true, + "attributes": { + "type": "success" + }, + "innerBlocks": [ + { + "name": "core/paragraph", + "isValid": true, + "attributes": { + "content": "Your form has been submitted successfully.", + "dropCap": false, + "backgroundColor": "#00D084", + "textColor": "#000000", + "style": { + "elements": { + "link": { + "color": { + "text": "#000000" + } + } + } + } + }, + "innerBlocks": [] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__form-submission-notification.parsed.json b/test/integration/fixtures/blocks/core__form-submission-notification.parsed.json new file mode 100644 index 0000000000000..c339aef3b765c --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submission-notification.parsed.json @@ -0,0 +1,35 @@ +[ + { + "blockName": "core/form-submission-notification", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/paragraph", + "attrs": { + "style": { + "elements": { + "link": { + "color": { + "text": "#000000" + } + } + } + }, + "backgroundColor": "#00D084", + "textColor": "#000000" + }, + "innerBlocks": [], + "innerHTML": "\n<p class=\"has-000000-color has-00-d-084-background-color has-text-color has-background has-link-color\">Your form has been submitted successfully.</p>\n", + "innerContent": [ + "\n<p class=\"has-000000-color has-00-d-084-background-color has-text-color has-background has-link-color\">Your form has been submitted successfully.</p>\n" + ] + } + ], + "innerHTML": "\n<div class=\"wp-block-form-submission-notification form-notification-type-success\"></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-form-submission-notification form-notification-type-success\">", + null, + "</div>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__form-submission-notification.serialized.html b/test/integration/fixtures/blocks/core__form-submission-notification.serialized.html new file mode 100644 index 0000000000000..696d0ff211848 --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submission-notification.serialized.html @@ -0,0 +1,5 @@ +<!-- wp:form-submission-notification --> +<div class="wp-block-form-submission-notification form-notification-type-success"><!-- wp:paragraph {"backgroundColor":"#00D084","textColor":"#000000","style":{"elements":{"link":{"color":{"text":"#000000"}}}}} --> +<p class="has-000000-color has-00-d-084-background-color has-text-color has-background has-link-color">Your form has been submitted successfully.</p> +<!-- /wp:paragraph --></div> +<!-- /wp:form-submission-notification --> diff --git a/test/integration/fixtures/blocks/core__form-submit-button.html b/test/integration/fixtures/blocks/core__form-submit-button.html new file mode 100644 index 0000000000000..a47c464232750 --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submit-button.html @@ -0,0 +1,7 @@ +<!-- wp:form-submit-button --> +<div class="wp-block-form-submit-button"><!-- wp:buttons --> +<div class="wp-block-buttons"><!-- wp:button {"tagName":"button","type":"submit"} --> +<div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Submit</button></div> +<!-- /wp:button --></div> +<!-- /wp:buttons --></div> +<!-- /wp:form-submit-button --> diff --git a/test/integration/fixtures/blocks/core__form-submit-button.json b/test/integration/fixtures/blocks/core__form-submit-button.json new file mode 100644 index 0000000000000..eca9cbb3f1a5d --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submit-button.json @@ -0,0 +1,26 @@ +[ + { + "name": "core/form-submit-button", + "isValid": true, + "attributes": {}, + "innerBlocks": [ + { + "name": "core/buttons", + "isValid": true, + "attributes": {}, + "innerBlocks": [ + { + "name": "core/button", + "isValid": true, + "attributes": { + "tagName": "button", + "type": "submit", + "text": "Submit" + }, + "innerBlocks": [] + } + ] + } + ] + } +] diff --git a/test/integration/fixtures/blocks/core__form-submit-button.parsed.json b/test/integration/fixtures/blocks/core__form-submit-button.parsed.json new file mode 100644 index 0000000000000..3c674769ca06d --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submit-button.parsed.json @@ -0,0 +1,38 @@ +[ + { + "blockName": "core/form-submit-button", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/buttons", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/button", + "attrs": { + "tagName": "button", + "type": "submit" + }, + "innerBlocks": [], + "innerHTML": "\n<div class=\"wp-block-button\"><button type=\"submit\" class=\"wp-block-button__link wp-element-button\">Submit</button></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-button\"><button type=\"submit\" class=\"wp-block-button__link wp-element-button\">Submit</button></div>\n" + ] + } + ], + "innerHTML": "\n<div class=\"wp-block-buttons\"></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-buttons\">", + null, + "</div>\n" + ] + } + ], + "innerHTML": "\n<div class=\"wp-block-form-submit-button\"></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-form-submit-button\">", + null, + "</div>\n" + ] + } +] diff --git a/test/integration/fixtures/blocks/core__form-submit-button.serialized.html b/test/integration/fixtures/blocks/core__form-submit-button.serialized.html new file mode 100644 index 0000000000000..a47c464232750 --- /dev/null +++ b/test/integration/fixtures/blocks/core__form-submit-button.serialized.html @@ -0,0 +1,7 @@ +<!-- wp:form-submit-button --> +<div class="wp-block-form-submit-button"><!-- wp:buttons --> +<div class="wp-block-buttons"><!-- wp:button {"tagName":"button","type":"submit"} --> +<div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Submit</button></div> +<!-- /wp:button --></div> +<!-- /wp:buttons --></div> +<!-- /wp:form-submit-button --> diff --git a/test/integration/fixtures/blocks/core__form.html b/test/integration/fixtures/blocks/core__form.html index ab18e0e11c81a..825389eb75ecd 100644 --- a/test/integration/fixtures/blocks/core__form.html +++ b/test/integration/fixtures/blocks/core__form.html @@ -1,21 +1,27 @@ <!-- wp:form --> -<form><!-- wp:form-input {"label":"Name","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Name</span><input class="wp-block-form-input" type="text" name="Name" required aria-required="true"/></label> +<form class="wp-block-form" enctype="text/plain"> +<!-- wp:form-input --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Name</span><input class="wp-block-form-input__input" type="text" name="name" required aria-required="true"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"email","label":"Email","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Email</span><input class="wp-block-form-input" type="email" name="Email" required aria-required="true"/></label> +<!-- wp:form-input {"type":"email"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Email</span><input class="wp-block-form-input__input" type="email" name="email" required aria-required="true"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"url","label":"Website"} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Website</span><input class="wp-block-form-input" type="url" name="Website" aria-required="false"/></label> +<!-- wp:form-input {"type":"url"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Website</span><input class="wp-block-form-input__input" type="url" name="website" aria-required="false"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"textarea","label":"Comment","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Comment</span><textarea class="wp-block-form-input" name="Comment" required aria-required="true"></textarea></label> +<!-- wp:form-input {"type":"textarea"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Comment</span><textarea class="wp-block-form-input__input" name="comment" required aria-required="true"></textarea></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"submit","label":"Submit"} --> -<div class="wp-block-buttons"><div class="wp-block-button"><button class="wp-block-button__link wp-element-button">Submit</button></div></div> -<!-- /wp:form-input --></form> +<!-- wp:form-submit-button --> +<div class="wp-block-form-submit-button"><!-- wp:buttons --> +<div class="wp-block-buttons"><!-- wp:button {"tagName":"button","type":"submit"} --> +<div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Submit</button></div> +<!-- /wp:button --></div> +<!-- /wp:buttons --></div> +<!-- /wp:form-submit-button --> +</form> <!-- /wp:form --> diff --git a/test/integration/fixtures/blocks/core__form.json b/test/integration/fixtures/blocks/core__form.json index 6bad568b12c26..d1dd3738a5801 100644 --- a/test/integration/fixtures/blocks/core__form.json +++ b/test/integration/fixtures/blocks/core__form.json @@ -1,62 +1,87 @@ [ { - "name": "core/missing", + "name": "core/form", "isValid": true, "attributes": { - "originalName": "core/form", - "originalUndelimitedContent": "<form>\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>\n<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>\n</form>", - "originalContent": "<!-- wp:form -->\n<form>\n<!-- wp:form-input {\"label\":\"Name\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n<!-- /wp:form-input -->\n<!-- wp:form-input {\"type\":\"email\",\"label\":\"Email\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>\n<!-- /wp:form-input -->\n<!-- wp:form-input {\"type\":\"url\",\"label\":\"Website\"} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>\n<!-- /wp:form-input -->\n<!-- wp:form-input {\"type\":\"textarea\",\"label\":\"Comment\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>\n<!-- /wp:form-input -->\n<!-- wp:form-input {\"type\":\"submit\",\"label\":\"Submit\"} -->\n<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>\n<!-- /wp:form-input -->\n</form>\n<!-- /wp:form -->" + "submissionMethod": "email", + "method": "post" }, "innerBlocks": [ { - "name": "core/missing", + "name": "core/form-input", "isValid": true, "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>", - "originalContent": "<!-- wp:form-input {\"label\":\"Name\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n<!-- /wp:form-input -->" + "type": "text", + "label": "Name", + "inlineLabel": false, + "required": true, + "value": "", + "visibilityPermissions": "all" }, "innerBlocks": [] }, { - "name": "core/missing", + "name": "core/form-input", "isValid": true, "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>", - "originalContent": "<!-- wp:form-input {\"type\":\"email\",\"label\":\"Email\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>\n<!-- /wp:form-input -->" + "type": "email", + "label": "Email", + "inlineLabel": false, + "required": true, + "value": "", + "visibilityPermissions": "all" }, "innerBlocks": [] }, { - "name": "core/missing", + "name": "core/form-input", "isValid": true, "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>", - "originalContent": "<!-- wp:form-input {\"type\":\"url\",\"label\":\"Website\"} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>\n<!-- /wp:form-input -->" + "type": "url", + "label": "Website", + "inlineLabel": false, + "required": false, + "value": "", + "visibilityPermissions": "all" }, "innerBlocks": [] }, { - "name": "core/missing", + "name": "core/form-input", "isValid": true, "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>", - "originalContent": "<!-- wp:form-input {\"type\":\"textarea\",\"label\":\"Comment\",\"required\":true} -->\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>\n<!-- /wp:form-input -->" + "type": "textarea", + "label": "Comment", + "inlineLabel": false, + "required": true, + "value": "", + "visibilityPermissions": "all" }, "innerBlocks": [] }, { - "name": "core/missing", + "name": "core/form-submit-button", "isValid": true, - "attributes": { - "originalName": "core/form-input", - "originalUndelimitedContent": "<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>", - "originalContent": "<!-- wp:form-input {\"type\":\"submit\",\"label\":\"Submit\"} -->\n<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>\n<!-- /wp:form-input -->" - }, - "innerBlocks": [] + "attributes": {}, + "innerBlocks": [ + { + "name": "core/buttons", + "isValid": true, + "attributes": {}, + "innerBlocks": [ + { + "name": "core/button", + "isValid": true, + "attributes": { + "tagName": "button", + "type": "submit", + "text": "Submit" + }, + "innerBlocks": [] + } + ] + } + ] } ] } diff --git a/test/integration/fixtures/blocks/core__form.parsed.json b/test/integration/fixtures/blocks/core__form.parsed.json index 379bee84c84e1..e33849b5be504 100644 --- a/test/integration/fixtures/blocks/core__form.parsed.json +++ b/test/integration/fixtures/blocks/core__form.parsed.json @@ -5,70 +5,86 @@ "innerBlocks": [ { "blockName": "core/form-input", - "attrs": { - "label": "Name", - "required": true - }, + "attrs": {}, "innerBlocks": [], - "innerHTML": "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n", + "innerHTML": "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Name</span><input class=\"wp-block-form-input__input\" type=\"text\" name=\"name\" required aria-required=\"true\"/></label></div>\n", "innerContent": [ - "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Name</span><input class=\"wp-block-form-input\" type=\"text\" name=\"Name\" required aria-required=\"true\"/></label>\n" + "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Name</span><input class=\"wp-block-form-input__input\" type=\"text\" name=\"name\" required aria-required=\"true\"/></label></div>\n" ] }, { "blockName": "core/form-input", "attrs": { - "type": "email", - "label": "Email", - "required": true + "type": "email" }, "innerBlocks": [], - "innerHTML": "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>\n", + "innerHTML": "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Email</span><input class=\"wp-block-form-input__input\" type=\"email\" name=\"email\" required aria-required=\"true\"/></label></div>\n", "innerContent": [ - "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Email</span><input class=\"wp-block-form-input\" type=\"email\" name=\"Email\" required aria-required=\"true\"/></label>\n" + "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Email</span><input class=\"wp-block-form-input__input\" type=\"email\" name=\"email\" required aria-required=\"true\"/></label></div>\n" ] }, { "blockName": "core/form-input", "attrs": { - "type": "url", - "label": "Website" + "type": "url" }, "innerBlocks": [], - "innerHTML": "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>\n", + "innerHTML": "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Website</span><input class=\"wp-block-form-input__input\" type=\"url\" name=\"website\" aria-required=\"false\"/></label></div>\n", "innerContent": [ - "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Website</span><input class=\"wp-block-form-input\" type=\"url\" name=\"Website\" aria-required=\"false\"/></label>\n" + "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Website</span><input class=\"wp-block-form-input__input\" type=\"url\" name=\"website\" aria-required=\"false\"/></label></div>\n" ] }, { "blockName": "core/form-input", "attrs": { - "type": "textarea", - "label": "Comment", - "required": true + "type": "textarea" }, "innerBlocks": [], - "innerHTML": "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>\n", + "innerHTML": "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Comment</span><textarea class=\"wp-block-form-input__input\" name=\"comment\" required aria-required=\"true\"></textarea></label></div>\n", "innerContent": [ - "\n<label class=\"wp-block-form-input-label\"><span class=\"wp-block-form-input-label__content\">Comment</span><textarea class=\"wp-block-form-input\" name=\"Comment\" required aria-required=\"true\"></textarea></label>\n" + "\n<div class=\"wp-block-form-input\"><label class=\"wp-block-form-input__label\"><span class=\"wp-block-form-input__label-content\">Comment</span><textarea class=\"wp-block-form-input__input\" name=\"comment\" required aria-required=\"true\"></textarea></label></div>\n" ] }, { - "blockName": "core/form-input", - "attrs": { - "type": "submit", - "label": "Submit" - }, - "innerBlocks": [], - "innerHTML": "\n<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>\n", + "blockName": "core/form-submit-button", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/buttons", + "attrs": {}, + "innerBlocks": [ + { + "blockName": "core/button", + "attrs": { + "tagName": "button", + "type": "submit" + }, + "innerBlocks": [], + "innerHTML": "\n<div class=\"wp-block-button\"><button type=\"submit\" class=\"wp-block-button__link wp-element-button\">Submit</button></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-button\"><button type=\"submit\" class=\"wp-block-button__link wp-element-button\">Submit</button></div>\n" + ] + } + ], + "innerHTML": "\n<div class=\"wp-block-buttons\"></div>\n", + "innerContent": [ + "\n<div class=\"wp-block-buttons\">", + null, + "</div>\n" + ] + } + ], + "innerHTML": "\n<div class=\"wp-block-form-submit-button\"></div>\n", "innerContent": [ - "\n<div class=\"wp-block-buttons\"><div class=\"wp-block-button\"><button class=\"wp-block-button__link wp-element-button\">Submit</button></div></div>\n" + "\n<div class=\"wp-block-form-submit-button\">", + null, + "</div>\n" ] } ], - "innerHTML": "\n<form>\n\n\n\n\n\n\n\n</form>\n", + "innerHTML": "\n<form class=\"wp-block-form\" enctype=\"text/plain\">\n\n\n\n\n\n\n\n\n\n</form>\n", "innerContent": [ - "\n<form>", + "\n<form class=\"wp-block-form\" enctype=\"text/plain\">\n", null, "\n\n", null, @@ -78,7 +94,7 @@ null, "\n\n", null, - "</form>\n" + "\n</form>\n" ] } ] diff --git a/test/integration/fixtures/blocks/core__form.serialized.html b/test/integration/fixtures/blocks/core__form.serialized.html index 585a50868b85e..e3209102ce20b 100644 --- a/test/integration/fixtures/blocks/core__form.serialized.html +++ b/test/integration/fixtures/blocks/core__form.serialized.html @@ -1,19 +1,25 @@ <!-- wp:form --> -<form> -<!-- wp:form-input {"label":"Name","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Name</span><input class="wp-block-form-input" type="text" name="Name" required aria-required="true"/></label> +<form class="wp-block-form" enctype="text/plain"><!-- wp:form-input --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Name</span><input class="wp-block-form-input__input" type="text" name="name" required aria-required="true"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"email","label":"Email","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Email</span><input class="wp-block-form-input" type="email" name="Email" required aria-required="true"/></label> + +<!-- wp:form-input {"type":"email"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Email</span><input class="wp-block-form-input__input" type="email" name="email" required aria-required="true"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"url","label":"Website"} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Website</span><input class="wp-block-form-input" type="url" name="Website" aria-required="false"/></label> + +<!-- wp:form-input {"type":"url"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Website</span><input class="wp-block-form-input__input" type="url" name="website" aria-required="false"/></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"textarea","label":"Comment","required":true} --> -<label class="wp-block-form-input-label"><span class="wp-block-form-input-label__content">Comment</span><textarea class="wp-block-form-input" name="Comment" required aria-required="true"></textarea></label> + +<!-- wp:form-input {"type":"textarea"} --> +<div class="wp-block-form-input"><label class="wp-block-form-input__label"><span class="wp-block-form-input__label-content">Comment</span><textarea class="wp-block-form-input__input" name="comment" required aria-required="true"></textarea></label></div> <!-- /wp:form-input --> -<!-- wp:form-input {"type":"submit","label":"Submit"} --> -<div class="wp-block-buttons"><div class="wp-block-button"><button class="wp-block-button__link wp-element-button">Submit</button></div></div> -<!-- /wp:form-input --> -</form> + +<!-- wp:form-submit-button --> +<div class="wp-block-form-submit-button"><!-- wp:buttons --> +<div class="wp-block-buttons"><!-- wp:button {"tagName":"button","type":"submit"} --> +<div class="wp-block-button"><button type="submit" class="wp-block-button__link wp-element-button">Submit</button></div> +<!-- /wp:button --></div> +<!-- /wp:buttons --></div> +<!-- /wp:form-submit-button --></form> <!-- /wp:form --> diff --git a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json index 9e15ee7f1c714..bd6108a97230a 100644 --- a/test/integration/fixtures/blocks/core__gallery__deprecated-1.json +++ b/test/integration/fixtures/blocks/core__gallery__deprecated-1.json @@ -16,6 +16,7 @@ "attributes": { "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] @@ -26,6 +27,7 @@ "attributes": { "url": "data:image/jpeg;base64,/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQEAAD8A0s8g/9k=", "alt": "title", + "caption": "", "linkDestination": "none" }, "innerBlocks": [] diff --git a/test/integration/fixtures/blocks/core__social-link-gravatar.html b/test/integration/fixtures/blocks/core__social-link-gravatar.html new file mode 100644 index 0000000000000..c4137b8a08317 --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-gravatar.html @@ -0,0 +1 @@ +<!-- wp:social-link-gravatar {"url":"https://example.com/"} /--> diff --git a/test/integration/fixtures/blocks/core__social-link-gravatar.json b/test/integration/fixtures/blocks/core__social-link-gravatar.json new file mode 100644 index 0000000000000..2f4035d97640b --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-gravatar.json @@ -0,0 +1,11 @@ +[ + { + "name": "core/social-link", + "isValid": true, + "attributes": { + "url": "https://example.com/", + "service": "gravatar" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__social-link-gravatar.parsed.json b/test/integration/fixtures/blocks/core__social-link-gravatar.parsed.json new file mode 100644 index 0000000000000..b4c7a8c146e14 --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-gravatar.parsed.json @@ -0,0 +1,11 @@ +[ + { + "blockName": "core/social-link-gravatar", + "attrs": { + "url": "https://example.com/" + }, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__social-link-gravatar.serialized.html b/test/integration/fixtures/blocks/core__social-link-gravatar.serialized.html new file mode 100644 index 0000000000000..83a449d4e1f53 --- /dev/null +++ b/test/integration/fixtures/blocks/core__social-link-gravatar.serialized.html @@ -0,0 +1 @@ +<!-- wp:social-link {"url":"https://example.com/","service":"gravatar"} /--> diff --git a/test/integration/fixtures/documents/ms-word-online-out.html b/test/integration/fixtures/documents/ms-word-online-out.html index 398281520f254..8187b598f9a91 100644 --- a/test/integration/fixtures/documents/ms-word-online-out.html +++ b/test/integration/fixtures/documents/ms-word-online-out.html @@ -8,33 +8,33 @@ <!-- wp:list --> <ul><!-- wp:list-item --> -<li>A&nbsp;</li> +<li>A </li> <!-- /wp:list-item --> <!-- wp:list-item --> -<li>Bulleted&nbsp;</li> +<li>Bulleted </li> <!-- /wp:list-item --> <!-- wp:list-item --> -<li>Indented&nbsp;</li> +<li>Indented </li> <!-- /wp:list-item --> <!-- wp:list-item --> -<li>List&nbsp;</li> +<li>List </li> <!-- /wp:list-item --></ul> <!-- /wp:list --> <!-- wp:list {"ordered":true,"start":1} --> <ol start="1"><!-- wp:list-item --> -<li>One&nbsp;</li> +<li>One </li> <!-- /wp:list-item --> <!-- wp:list-item --> -<li>Two&nbsp;</li> +<li>Two </li> <!-- /wp:list-item --> <!-- wp:list-item --> -<li>Three&nbsp;</li> +<li>Three </li> <!-- /wp:list-item --></ol> <!-- /wp:list --> diff --git a/test/integration/full-content/full-content.test.js b/test/integration/full-content/full-content.test.js index fab2edd98942f..f825de0477144 100644 --- a/test/integration/full-content/full-content.test.js +++ b/test/integration/full-content/full-content.test.js @@ -35,6 +35,13 @@ import { writeBlockFixtureSerializedHTML, } from '../fixtures'; +/* eslint-disable no-restricted-syntax */ +import * as form from '@wordpress/block-library/src/form'; +import * as formInput from '@wordpress/block-library/src/form-input'; +import * as formSubmitButton from '@wordpress/block-library/src/form-submit-button'; +import * as formSubmissionNotification from '@wordpress/block-library/src/form-submission-notification'; +/* eslint-enable no-restricted-syntax */ + const blockBasenames = getAvailableBlockFixturesBasenames(); /** @@ -64,6 +71,17 @@ describe( 'full post content fixture', () => { ); unstable__bootstrapServerSideBlockDefinitions( blockDefinitions ); registerCoreBlocks(); + + // Form-related blocks will not be registered unless they are opted + // in on the experimental settings page. Therefore, these blocks + // must be explicitly registered. + registerCoreBlocks( [ + form, + formInput, + formSubmitButton, + formSubmissionNotification, + ] ); + if ( process.env.IS_GUTENBERG_PLUGIN ) { __experimentalRegisterExperimentalCoreBlocks( { enableFSEBlocks: true, diff --git a/test/integration/helpers/integration-test-editor.js b/test/integration/helpers/integration-test-editor.js index dc83c1bfbe6bd..1317dec7b9226 100644 --- a/test/integration/helpers/integration-test-editor.js +++ b/test/integration/helpers/integration-test-editor.js @@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event'; import { useState, useEffect } from '@wordpress/element'; import { BlockEditorProvider, - BlockTools, BlockInspector, privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; @@ -76,9 +75,7 @@ export function Editor( { testBlocks, settings = {} } ) { settings={ settings } > <BlockInspector /> - <BlockTools> - <BlockCanvas height="100%" shouldIframe={ false } /> - </BlockTools> + <BlockCanvas height="100%" shouldIframe={ false } /> </BlockEditorProvider> ); } diff --git a/test/integration/non-matched-tags-handling.test.js b/test/integration/non-matched-tags-handling.test.js index 67438192f1368..451a628c32977 100644 --- a/test/integration/non-matched-tags-handling.test.js +++ b/test/integration/non-matched-tags-handling.test.js @@ -19,9 +19,9 @@ describe( 'Handling of non matched tags in block transforms', () => { expect( simplePreformattedResult ).toHaveLength( 1 ); expect( simplePreformattedResult[ 0 ].name ).toBe( 'core/paragraph' ); - expect( simplePreformattedResult[ 0 ].attributes.content ).toBe( - 'Pre' - ); + expect( + simplePreformattedResult[ 0 ].attributes.content.valueOf() + ).toBe( 'Pre' ); const codeResult = pasteHandler( { HTML: '<pre><code>code</code></pre>', @@ -30,7 +30,7 @@ describe( 'Handling of non matched tags in block transforms', () => { expect( codeResult ).toHaveLength( 1 ); expect( codeResult[ 0 ].name ).toBe( 'core/code' ); - expect( codeResult[ 0 ].attributes.content ).toBe( 'code' ); + expect( codeResult[ 0 ].attributes.content.valueOf() ).toBe( 'code' ); expect( console ).toHaveLogged(); } ); } ); diff --git a/test/native/integration/__snapshots__/blocks-raw-handling.native.js.snap b/test/native/integration/__snapshots__/blocks-raw-handling.native.js.snap new file mode 100644 index 0000000000000..75d8caebbe31e --- /dev/null +++ b/test/native/integration/__snapshots__/blocks-raw-handling.native.js.snap @@ -0,0 +1,212 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +/* +exports[`Blocks raw handling pasteHandler apple 1`] = `"<strong>This is a </strong>title<br><br><br><strong>This is a <em>heading</em></strong><br><br><br>This is a <strong>paragraph</strong> with a <a href="https://w.org">link</a>.<br><br><br>A<br>Bulleted<br>Indented<br>List<br><br><br>One<br>Two<br>Three<br><br><br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III<br><br><br>An image:<br>"`; + +exports[`Blocks raw handling pasteHandler classic 1`] = `"First paragraph<br><br>Second paragraph<br>Third paragraph<br>Fourth paragraph<br>Fifth paragraph<br>Sixth paragraph"`; + +exports[`Blocks raw handling pasteHandler evernote 1`] = `"This is a <em>paragraph</em>.<br><br><br>This is a <a href="https://w.org">link</a>.<br><br><br>An<br>Unordered<br>Indented<br>List<br><br><br>One<br>Two<br>Indented<br>Three<br><br><br><br><br><br><br>One<br>Two<br>Three<br>Four<br>Five<br>Six<br><img src="data:image/jpeg;base64,###">"`; + +exports[`Blocks raw handling pasteHandler google-docs 1`] = `"This is a <strong>title</strong><br><br>This is a <em>heading</em><br><br>Formatting test: <strong>bold</strong>, <em>italic</em>, <a href="https://w.org/">link</a>, <s>strikethrough</s>, <sup>superscript</sup>, <sub>subscript</sub>, <strong><em>nested</em></strong>.<br><br>A<br>Bulleted<br>Indented<br>List<br><br>One<br>Two<br>Three<br><br><br><br><br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III<br><br><br><br><br><br>An image:<br><br><img src="https://lh4.googleusercontent.com/ID" width="544" height="184"><br>"`; + +exports[`Blocks raw handling pasteHandler google-docs-list-only 1`] = `"My first list item<br>A sub list item<br>A second sub list item<br>My second list item<br>My third list item"`; + +exports[`Blocks raw handling pasteHandler google-docs-table 1`] = `"<br><br><br><br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III"`; + +exports[`Blocks raw handling pasteHandler google-docs-table-with-colspan 1`] = `"<br><br>Test colspan<br><br>"`; + +exports[`Blocks raw handling pasteHandler google-docs-table-with-comments 1`] = `"<br><br><br><br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III"`; + +exports[`Blocks raw handling pasteHandler google-docs-table-with-rowspan 1`] = `"<br><br>Test rowspan<br><br>"`; + +exports[`Blocks raw handling pasteHandler google-docs-with-comments 1`] = `"This is a <strong>title</strong><br><br>This is a <em>heading</em><br><br>Formatting test: <strong>bold</strong>, <em>italic</em>, <a href="https://w.org/">link</a>, <s>strikethrough</s>, <sup>superscript</sup>, <sub>subscript</sub>, <strong><em>nested</em></strong>.<br><br>A<br>Bulleted<br>Indented<br>List<br><br>One<br>Two<br>Three<br><br><br><br><br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III<br><br><br><br><br><br>An image:<br><br><img src="https://lh4.googleusercontent.com/ID" width="544" height="184"><br><br>"`; +*/ + +exports[`Blocks raw handling pasteHandler gutenberg 1`] = `"Test"`; + +exports[`Blocks raw handling pasteHandler iframe-embed 1`] = `""`; + +/* +exports[`Blocks raw handling pasteHandler markdown 1`] = `"This is a heading with <em>italic</em><br>This is a paragraph with a <a href="https://w.org/">link</a>, <strong>bold</strong>, and <del>strikethrough</del>.<br>Preserve<br>line breaks please.<br>Lists<br>A<br>Bulleted Indented<br>List<br>One<br>Two<br>Three<br>Table<br>First Header<br>Second Header<br>Content from cell 1<br>Content from cell 2<br>Content in the first column<br>Content in the second column<br><br><br><br>Table with empty cells.<br>Quote<br>First<br>Second<br>Code<br>Inline <code>code</code> tags should work.<br><code>This is a code block.</code>"`; + +exports[`Blocks raw handling pasteHandler ms-word 1`] = `"This is a title<br>&nbsp;<br>This is a subtitle<br>&nbsp;<br>This is a heading level 1<br>&nbsp;<br>This is a heading level 2<br>&nbsp;<br>This is a <strong>paragraph</strong> with a <a href="https://w.org/">link</a>.<br>&nbsp;<br>A<br>Bulleted<br>Indented<br>List<br>&nbsp;<br>One<br>Two<br>Three<br>&nbsp;<br>One<br>Two<br>Three<br>1<br>2<br>3<br>I<br>II<br>III<br>&nbsp;<br>An image:<br>&nbsp;<br><img width="451" height="338" src="file:LOW-RES.png"><br><a href="#anchor">This is an anchor link</a> that leads to the next paragraph.<br><a id="anchor">This is the paragraph with the anchor.</a><br><a href="#nowhere">This is an anchor link</a> that leads nowhere.<br><a>This is a paragraph with an anchor with no link pointing to it.</a><br>This is a reference to a footnote<a href="#_ftn1" id="_ftnref1">[1]</a>.<br>This is a reference to an endnote<a href="#_edn1" id="_ednref1">[i]</a>.<br><br><br><a href="#_ftnref1" id="_ftn1">[1]</a> This is a footnote.<br><br><br><a href="#_ednref1" id="_edn1">[i]</a> This is an endnote."`; + +exports[`Blocks raw handling pasteHandler ms-word-list 1`] = `"<a>This is a headline?</a><br>This is a text:<br>One<br>Two<br>Three<br><a>Lorem Ipsum.</a><br>&nbsp;"`; + +exports[`Blocks raw handling pasteHandler ms-word-online 1`] = `"This is a <em>heading</em>&nbsp;<br>This is a <strong>paragraph </strong>with a <a href="https://w.org/" target="_blank" rel="noreferrer noopener">link</a>.&nbsp;<br>A&nbsp;<br>Bulleted&nbsp;<br>Indented&nbsp;<br>List&nbsp;<br>&nbsp;<br>One&nbsp;<br>Two&nbsp;<br>Three&nbsp;<br><br>One&nbsp;<br>Two&nbsp;<br>Three&nbsp;<br>1&nbsp;<br>2&nbsp;<br>3&nbsp;<br>I&nbsp;<br>II&nbsp;<br>III&nbsp;<br>&nbsp;<br>An image:&nbsp;<br><img src="data:image/jpeg;base64,###">&nbsp;"`; + +exports[`Blocks raw handling pasteHandler ms-word-styled 1`] = `"<br><strong>Lorem ipsum dolor sit amet, consectetur adipiscing elit&nbsp;</strong><br><br><br>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque aliquet hendrerit auctor. Nam lobortis, est vel lacinia tincidunt, purus tellus vehicula ex, nec pharetra justo dui sed lorem. Nam congue laoreet massa, quis varius est tincidunt ut."`; + +exports[`Blocks raw handling pasteHandler nested-divs 1`] = `"First paragraph<br><br>Second paragraph<br>Third paragraph<br>Fourth paragraph<br>Fifth paragraph<br>Sixth paragraph"`; + +exports[`Blocks raw handling pasteHandler one-image 1`] = `"<img src="http://localhost/wp-content/uploads/2018/01/Dec-08-2017-15-12-24-17-300x137.gif" alt="" width="300" height="137">"`; + +exports[`Blocks raw handling pasteHandler plain 1`] = `"test<br>test<br><br>test"`; + +exports[`Blocks raw handling pasteHandler shortcode-matching 1`] = `"[gallery ids="40,41,42"]<br>[gallery ids="1000"]<br>[gallery ids="42"]"`; +*/ + +exports[`Blocks raw handling pasteHandler should remove extra blank lines 1`] = ` +"<!-- wp:paragraph --> +<p>1</p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p>2</p> +<!-- /wp:paragraph -->" +`; + +exports[`Blocks raw handling pasteHandler should strip HTML formatting space from inline text 1`] = `"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent a elit eget tortor molestie egestas. Donec pretium urna vitae mattis imperdiet. Praesent et lorem iaculis, volutpat odio vitae, ornare lacus. Donec ut felis tristique, pharetra erat id, viverra justo. Integer sit amet elementum arcu, eget pharetra felis. In malesuada enim est, sed placerat nulla feugiat at. Vestibulum feugiat vitae elit sit amet tincidunt. Pellentesque finibus sed dolor non facilisis. Curabitur accumsan ante ac hendrerit vestibulum."`; + +exports[`Blocks raw handling pasteHandler should strip some text-level elements 1`] = ` +"<!-- wp:paragraph --> +<p>This is ncorect</p> +<!-- /wp:paragraph -->" +`; + +exports[`Blocks raw handling pasteHandler should strip windows data 1`] = ` +"<!-- wp:heading --> +<h2 class="wp-block-heading">Heading Win</h2> +<!-- /wp:heading --> + +<!-- wp:paragraph --> +<p>Paragraph Win</p> +<!-- /wp:paragraph -->" +`; + +/* +exports[`Blocks raw handling pasteHandler slack-paragraphs 1`] = `"test with&nbsp;<a target="_blank" href="http://w.org/" rel="noreferrer noopener">link</a><br>a new line<br><br>a new paragraph<br>another new line<br><br>another paragraph"`; + +exports[`Blocks raw handling pasteHandler slack-quote 1`] = `"Test with&nbsp;<a target="_blank" href="http://w.org/" rel="noreferrer noopener">link</a>."`; + +exports[`Blocks raw handling pasteHandler two-images 1`] = `"<img src="http://localhost/wp-content/uploads/2018/01/Dec-08-2017-15-12-24-17-300x137.gif" alt="" width="300" height="137"> <img src="http://localhost/wp-content/uploads/2018/01/Dec-05-2017-17-52-09-9-300x248.gif" alt="" width="300" height="248">"`; + +exports[`Blocks raw handling pasteHandler wordpress 1`] = `"Howdy<br>This is a paragraph.<br>More tag<br><br>Shortcode<br>[gallery ids="1"]<br><img src="block.png" alt=""><br><img src="aligned.png" alt=""> test<br><img src="not-aligned.png" alt=""> test"`; +*/ + +exports[`Blocks raw handling should correctly handle quotes with mixed content 1`] = ` +"<!-- wp:quote --> +<blockquote class="wp-block-quote"><!-- wp:heading {"level":1} --> +<h1 class="wp-block-heading">chicken</h1> +<!-- /wp:heading --> + +<!-- wp:paragraph --> +<p>ribs</p> +<!-- /wp:paragraph --></blockquote> +<!-- /wp:quote -->" +`; + +exports[`rawHandler should convert HTML post to blocks with minimal content changes 1`] = ` +"<!-- wp:heading --> +<h2 class="wp-block-heading">Howdy</h2> +<!-- /wp:heading --> + +<!-- wp:image --> +<figure class="wp-block-image"><img src="https://w.org" alt=""/></figure> +<!-- /wp:image --> + +<!-- wp:paragraph --> +<p>This is a paragraph.</p> +<!-- /wp:paragraph --> + +<!-- wp:paragraph --> +<p>Preserve <span style="color:red">me</span>!</p> +<!-- /wp:paragraph --> + +<!-- wp:heading {"level":3} --> +<h3 class="wp-block-heading">More tag</h3> +<!-- /wp:heading --> + +<!-- wp:more --> +<!--more--> +<!-- /wp:more --> + +<!-- wp:heading {"level":3} --> +<h3 class="wp-block-heading">Shortcode</h3> +<!-- /wp:heading --> + +<!-- wp:gallery {"columns":3,"linkTo":"none"} --> +<figure class="wp-block-gallery has-nested-images columns-3 is-cropped"><!-- wp:image {"id":1} --> +<figure class="wp-block-image"><img alt="" class="wp-image-1"/></figure> +<!-- /wp:image --></figure> +<!-- /wp:gallery --> + +<!-- wp:html --> +<dl> + <dt>Term</dt> + <dd> + Description. + </dd> +</dl> +<!-- /wp:html --> + +<!-- wp:list {"ordered":true} --> +<ol><!-- wp:list-item --> +<li>Item</li> +<!-- /wp:list-item --></ol> +<!-- /wp:list --> + +<!-- wp:quote --> +<blockquote class="wp-block-quote"><!-- wp:paragraph --> +<p>Text.</p> +<!-- /wp:paragraph --></blockquote> +<!-- /wp:quote --> + +<!-- wp:quote --> +<blockquote class="wp-block-quote"><!-- wp:heading {"level":1} --> +<h1 class="wp-block-heading">Heading</h1> +<!-- /wp:heading --> + +<!-- wp:paragraph --> +<p>Text.</p> +<!-- /wp:paragraph --></blockquote> +<!-- /wp:quote -->" +`; + +exports[`rawHandler should convert a caption shortcode 1`] = ` +"<!-- wp:image {"id":122,"align":"none","className":"size-medium wp-image-122"} --> +<figure class="wp-block-image alignnone size-medium wp-image-122"><img src="image.png" alt="" class="wp-image-122"/><figcaption class="wp-element-caption">test</figcaption></figure> +<!-- /wp:image -->" +`; + +exports[`rawHandler should convert a caption shortcode with caption 1`] = ` +"<!-- wp:image {"id":122,"align":"none","className":"size-medium wp-image-122"} --> +<figure class="wp-block-image alignnone size-medium wp-image-122"><img src="image.png" alt="" class="wp-image-122"/><figcaption class="wp-element-caption"><a href="https://w.org">test</a></figcaption></figure> +<!-- /wp:image -->" +`; + +exports[`rawHandler should convert a caption shortcode with link 1`] = ` +"<!-- wp:image {"id":754,"align":"none"} --> +<figure class="wp-block-image alignnone"><a href="http://build.wordpress-develop.test/wp-content/uploads/2011/07/100_5478.jpg"><img src="http://build.wordpress-develop.test/wp-content/uploads/2011/07/100_5478.jpg?w=604" alt="Bell on Wharf" class="wp-image-754"/></a><figcaption class="wp-element-caption">Bell on wharf in San Francisco</figcaption></figure> +<!-- /wp:image -->" +`; + +exports[`rawHandler should convert a list with attributes 1`] = ` +"<!-- wp:list {"ordered":true,"type":"lower-roman","start":2,"reversed":true} --> +<ol reversed start="2" style="list-style-type:lower-roman"><!-- wp:list-item --> +<li>1<!-- wp:list {"ordered":true,"type":"lower-roman","start":2,"reversed":true} --> +<ol reversed start="2" style="list-style-type:lower-roman"><!-- wp:list-item --> +<li>1</li> +<!-- /wp:list-item --></ol> +<!-- /wp:list --></li> +<!-- /wp:list-item --></ol> +<!-- /wp:list -->" +`; + +exports[`rawHandler should convert to unsupported HTML block when no transformation is available 1`] = ` +"<!-- wp:html --> +<div><p>Hello world!</p></div> +<!-- /wp:html -->" +`; + +exports[`rawHandler should not strip any text-level elements 1`] = ` +"<!-- wp:paragraph --> +<p>This is <u>ncorect</u></p> +<!-- /wp:paragraph -->" +`; + +exports[`rawHandler should preserve alignment 1`] = ` +"<!-- wp:paragraph {"align":"center"} --> +<p class="has-text-align-center">center</p> +<!-- /wp:paragraph -->" +`; diff --git a/test/native/integration/blocks-raw-handling.native.js b/test/native/integration/blocks-raw-handling.native.js new file mode 100644 index 0000000000000..5f21ca035fbf9 --- /dev/null +++ b/test/native/integration/blocks-raw-handling.native.js @@ -0,0 +1,587 @@ +/** + * External dependencies + */ +import fs from 'fs'; +import path from 'path'; + +/** + * WordPress dependencies + */ +import { + createBlock, + getBlockContent, + pasteHandler, + rawHandler, + registerBlockType, + serialize, +} from '@wordpress/blocks'; +import { registerCoreBlocks } from '@wordpress/block-library'; + +function readFile( filePath ) { + return fs.existsSync( filePath ) + ? fs.readFileSync( filePath, 'utf8' ).trim() + : ''; +} + +// Path to the fixtures provided in `gutenberg/test/integration`. +const fixturesPath = `${ __dirname }/../../integration`; + +// NOTE: This file is a clone of the same `blocks-raw-handling.js` file located in +// `gutenberg/test/integration`. The reason for the separation is that several of +// the test cases fail in the native version. For now, we are going to skip them, but +// we'd need to work on them in the future. +// +// Once all issues in tests are addressed, we'll remove this file in favor of the +// original one. +describe( 'Blocks raw handling', () => { + beforeAll( () => { + // Load all hooks that modify blocks. + require( '../../../packages/editor/src/hooks' ); + registerCoreBlocks(); + registerBlockType( 'test/gallery', { + title: 'Test Gallery', + category: 'text', + attributes: { + ids: { + type: 'array', + default: [], + }, + }, + transforms: { + from: [ + { + type: 'shortcode', + tag: 'gallery', + isMatch( { named: { ids } } ) { + return ids.indexOf( 42 ) > -1; + }, + attributes: { + ids: { + type: 'array', + shortcode: ( { named: { ids } } ) => + ids + .split( ',' ) + .map( ( id ) => parseInt( id, 10 ) ), + }, + }, + priority: 9, + }, + ], + }, + save: () => null, + } ); + + registerBlockType( 'test/non-inline-block', { + title: 'Test Non Inline Block', + category: 'text', + supports: { + pasteTextInline: false, + }, + transforms: { + from: [ + { + type: 'raw', + isMatch: ( node ) => { + return ( + 'words to live by' === node.textContent.trim() + ); + }, + transform: () => { + return createBlock( 'core/embed', { + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + } ); + }, + }, + ], + }, + save: () => null, + } ); + + registerBlockType( 'test/transform-to-multiple-blocks', { + title: 'Test Transform to Multiple Blocks', + category: 'text', + transforms: { + from: [ + { + type: 'raw', + isMatch: ( node ) => { + return node.textContent + .split( ' ' ) + .every( ( chunk ) => /^P\S+?/.test( chunk ) ); + }, + transform: ( node ) => { + return node.textContent + .split( ' ' ) + .map( ( chunk ) => + createBlock( 'core/paragraph', { + content: chunk.substring( 1 ), + } ) + ); + }, + }, + ], + }, + save: () => null, + } ); + } ); + + it( 'should filter inline content', () => { + const filtered = pasteHandler( { + HTML: '<h2><em>test</em></h2>', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( '<em>test</em>' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should ignore Google Docs UID tag', () => { + const filtered = pasteHandler( { + HTML: '<b id="docs-internal-guid-0"><em>test</em></b>', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( '<em>test</em>' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should ignore Google Docs UID tag in inline mode', () => { + const filtered = pasteHandler( { + HTML: '<b id="docs-internal-guid-0"><em>test</em></b>', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( '<em>test</em>' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should paste special whitespace', () => { + const filtered = pasteHandler( { + HTML: '<p>&thinsp;</p>', + plainText: ' ', + mode: 'AUTO', + } ); + + expect( console ).toHaveLogged(); + expect( filtered ).toBe( ' ' ); + } ); + + it( 'should paste special whitespace in plain text only', () => { + const filtered = pasteHandler( { + HTML: '', + plainText: ' ', + mode: 'AUTO', + } ); + + expect( console ).toHaveLogged(); + expect( filtered ).toBe( ' ' ); + } ); + + it( 'should parse Markdown', () => { + const filtered = pasteHandler( { + HTML: '* one<br>* two<br>* three', + plainText: '* one\n* two\n* three', + mode: 'AUTO', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( filtered ).toMatchInlineSnapshot( ` + "<ul><!-- wp:list-item --> + <li>one</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>two</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>three</li> + <!-- /wp:list-item --></ul>" + ` ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse bulleted list', () => { + const filtered = pasteHandler( { + HTML: '• one<br>• two<br>• three', + plainText: '• one\n• two\n• three', + mode: 'AUTO', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( filtered ).toMatchInlineSnapshot( ` + "<ul><!-- wp:list-item --> + <li>one</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>two</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>three</li> + <!-- /wp:list-item --></ul>" + ` ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse inline Markdown', () => { + const filtered = pasteHandler( { + HTML: 'Some **bold** text.', + plainText: 'Some **bold** text.', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some <strong>bold</strong> text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse HTML in plainText', () => { + const filtered = pasteHandler( { + HTML: '&lt;p&gt;Some &lt;strong&gt;bold&lt;/strong&gt; text.&lt;/p&gt;', + plainText: '<p>Some <strong>bold</strong> text.</p>', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some <strong>bold</strong> text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should parse Markdown with HTML', () => { + const filtered = pasteHandler( { + HTML: '', + plainText: '# Some <em>heading</em>\n\nA paragraph.', + mode: 'AUTO', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( filtered ).toBe( + '<h1 class="wp-block-heading">Some <em>heading</em></h1><p>A paragraph.</p>' + ); + expect( console ).toHaveLogged(); + } ); + + it.skip( 'should break up forced inline content', () => { + const filtered = pasteHandler( { + HTML: '<p>test</p><p>test</p>', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'test<br>test' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should normalize decomposed characters', () => { + const filtered = pasteHandler( { + HTML: 'schön', + mode: 'INLINE', + } ); + + expect( filtered ).toBe( 'schön' ); + expect( console ).toHaveLogged(); + } ); + + it.skip( 'should not treat single non-inlineable block as inline text', () => { + const filtered = pasteHandler( { + HTML: '<p>words to live by</p>', + plainText: 'words to live by\n', + mode: 'AUTO', + } ); + + expect( filtered ).toHaveLength( 1 ); + expect( filtered[ 0 ].name ).toBe( 'core/embed' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should treat single heading as inline text', () => { + const filtered = pasteHandler( { + HTML: '<h1>FOO</h1>', + plainText: 'FOO\n', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'FOO' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should treat single list item as inline text', () => { + const filtered = pasteHandler( { + HTML: '<ul><li>Some <strong>bold</strong> text.</li></ul>', + plainText: 'Some <strong>bold</strong> text.\n', + mode: 'AUTO', + } ); + + expect( filtered ).toBe( 'Some <strong>bold</strong> text.' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should treat multiple list items as a block', () => { + const filtered = pasteHandler( { + HTML: '<ul><li>One</li><li>Two</li><li>Three</li></ul>', + plainText: 'One\nTwo\nThree\n', + mode: 'AUTO', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( filtered ).toMatchInlineSnapshot( ` + "<ul><!-- wp:list-item --> + <li>One</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>Two</li> + <!-- /wp:list-item --> + + <!-- wp:list-item --> + <li>Three</li> + <!-- /wp:list-item --></ul>" + ` ); + expect( console ).toHaveLogged(); + } ); + + it( 'should correctly handle quotes with mixed content', () => { + const filtered = serialize( + pasteHandler( { + HTML: '<blockquote><h1 class="wp-block-heading">chicken</h1><p>ribs</p></blockquote>', + mode: 'AUTO', + } ) + ); + + expect( filtered ).toMatchSnapshot(); + expect( console ).toHaveLogged(); + } ); + + it( 'should paste gutenberg content from plain text', () => { + const block = '<!-- wp:latest-posts /-->'; + expect( + serialize( + pasteHandler( { + plainText: block, + mode: 'AUTO', + } ) + ) + ).toBe( block ); + } ); + + it.skip( 'should handle transforms that return an array of blocks', () => { + const transformed = pasteHandler( { + HTML: '<p>P1 P2</p>', + plainText: 'P1 P2\n', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( transformed ).toBe( '<p>1</p><p>2</p>' ); + expect( console ).toHaveLogged(); + } ); + + it( 'should convert pre', () => { + const transformed = pasteHandler( { + HTML: '<pre>1\n2</pre>', + plainText: '1\n2', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( transformed ).toBe( + '<pre class="wp-block-preformatted">1\n2</pre>' + ); + expect( console ).toHaveLogged(); + } ); + + it( 'should convert code', () => { + const transformed = pasteHandler( { + HTML: '<pre><code>1\n2</code></pre>', + plainText: '1\n2', + } ) + .map( getBlockContent ) + .join( '' ); + + expect( transformed ).toBe( + '<pre class="wp-block-code"><code>1\n2</code></pre>' + ); + expect( console ).toHaveLogged(); + } ); + + describe( 'pasteHandler', () => { + // TODO: The cases commented should be eventually addressed and restored. + [ + // 'plain', + // 'classic', + // 'nested-divs', + // 'apple', + // 'google-docs', + // 'google-docs-list-only', + // 'google-docs-table', + // 'google-docs-table-with-colspan', + // 'google-docs-table-with-rowspan', + // 'google-docs-table-with-comments', + // 'google-docs-with-comments', + // 'ms-word', + // 'ms-word-list', + // 'ms-word-styled', + // 'ms-word-online', + // 'evernote', + 'iframe-embed', + // 'one-image', + // 'two-images', + // 'markdown', + // 'wordpress', + 'gutenberg', + // 'shortcode-matching', + // 'slack-quote', + // 'slack-paragraphs', + ].forEach( ( type ) => { + // eslint-disable-next-line jest/valid-title + it( type, () => { + const HTML = readFile( + path.join( + fixturesPath, + `fixtures/documents/${ type }-in.html` + ) + ); + const plainText = readFile( + path.join( + fixturesPath, + `fixtures/documents/${ type }-in.txt` + ) + ); + const output = readFile( + path.join( + fixturesPath, + `fixtures/documents/${ type }-out.html` + ) + ); + + if ( ! ( HTML || plainText ) || ! output ) { + throw new Error( `Expected fixtures for type ${ type }` ); + } + + const converted = pasteHandler( { HTML, plainText } ); + const serialized = + typeof converted === 'string' + ? converted + : serialize( converted ); + + expect( serialized ).toBe( output ); + + const convertedInline = pasteHandler( { + HTML, + plainText, + mode: 'INLINE', + } ); + + expect( convertedInline ).toMatchSnapshot(); + expect( console ).toHaveLogged(); + } ); + } ); + + it( 'should strip some text-level elements', () => { + const HTML = '<p>This is <u>ncorect</u></p>'; + expect( serialize( pasteHandler( { HTML } ) ) ).toMatchSnapshot(); + expect( console ).toHaveLogged(); + } ); + + it( 'should remove extra blank lines', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/google-docs-blank-lines.html' + ) + ); + expect( serialize( pasteHandler( { HTML } ) ) ).toMatchSnapshot(); + expect( console ).toHaveLogged(); + } ); + + it( 'should strip windows data', () => { + const HTML = readFile( + path.join( fixturesPath, 'fixtures/documents/windows.html' ) + ); + expect( serialize( pasteHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should strip HTML formatting space from inline text', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/inline-with-html-formatting-space.html' + ) + ); + expect( pasteHandler( { HTML } ) ).toMatchSnapshot(); + expect( console ).toHaveLogged(); + } ); + } ); +} ); + +describe( 'rawHandler', () => { + it.skip( 'should convert HTML post to blocks with minimal content changes', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/wordpress-convert.html' + ) + ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should convert a caption shortcode', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/shortcode-caption.html' + ) + ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should convert a caption shortcode with link', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/shortcode-caption-with-link.html' + ) + ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should convert a caption shortcode with caption', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/shortcode-caption-with-caption-link.html' + ) + ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should convert a list with attributes', () => { + const HTML = readFile( + path.join( + fixturesPath, + 'fixtures/documents/list-with-attributes.html' + ) + ); + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should not strip any text-level elements', () => { + const HTML = '<p>This is <u>ncorect</u></p>'; + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + it.skip( 'should preserve alignment', () => { + const HTML = '<p style="text-align:center">center</p>'; + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); + + // This is an extra test added to cover the case fixed in: + // `rnmobile/fix/div-tag-convert-to-blocks`. + it( 'should convert to unsupported HTML block when no transformation is available', () => { + const HTML = '<div><p>Hello world!</p></div>'; + expect( serialize( rawHandler( { HTML } ) ) ).toMatchSnapshot(); + } ); +} ); diff --git a/test/native/jest.config.js b/test/native/jest.config.js index ad5c794ebbce8..4859ea597e0f6 100644 --- a/test/native/jest.config.js +++ b/test/native/jest.config.js @@ -24,7 +24,6 @@ const transpiledPackageNames = glob( 'packages/*/src/index.{js,ts}' ).map( const RAW_HANDLING_UNSUPPORTED_UNIT_TESTS = [ 'html-formatting-remover', 'phrasing-content-reducer', - 'ms-list-converter', 'figure-content-reducer', 'special-comment-converter', 'normalise-blocks', diff --git a/test/native/setup.js b/test/native/setup.js index 53ab28f861a1e..3770a4ce3efc6 100644 --- a/test/native/setup.js +++ b/test/native/setup.js @@ -117,6 +117,7 @@ jest.mock( '@wordpress/react-native-bridge', () => { requestImageUploadCancelDialog: jest.fn(), requestMediaEditor: jest.fn(), requestMediaPicker: jest.fn(), + requestMediaImport: jest.fn(), requestUnsupportedBlockFallback: jest.fn(), subscribeReplaceBlock: jest.fn(), mediaSources: { diff --git a/test/performance/config/performance-reporter.ts b/test/performance/config/performance-reporter.ts index fa7cc90825c22..7b1f171230c59 100644 --- a/test/performance/config/performance-reporter.ts +++ b/test/performance/config/performance-reporter.ts @@ -26,6 +26,7 @@ export interface WPRawPerformanceResults { firstContentfulPaint: number[]; firstBlock: number[]; type: number[]; + typeWithoutInspector: number[]; typeContainer: number[]; focus: number[]; inserterOpen: number[]; @@ -48,6 +49,7 @@ export interface WPPerformanceResults { type?: number; minType?: number; maxType?: number; + typeWithoutInspector?: number; typeContainer?: number; minTypeContainer?: number; maxTypeContainer?: number; @@ -92,6 +94,7 @@ export function curateResults( type: average( results.type ), minType: minimum( results.type ), maxType: maximum( results.type ), + typeWithoutInspector: average( results.typeWithoutInspector ), typeContainer: average( results.typeContainer ), minTypeContainer: minimum( results.typeContainer ), maxTypeContainer: maximum( results.typeContainer ), diff --git a/test/performance/specs/post-editor.spec.js b/test/performance/specs/post-editor.spec.js index d5ff40570afd7..cf2610baa1f9e 100644 --- a/test/performance/specs/post-editor.spec.js +++ b/test/performance/specs/post-editor.spec.js @@ -22,6 +22,7 @@ const results = { firstContentfulPaint: [], firstBlock: [], type: [], + typeWithoutInspector: [], typeContainer: [], focus: [], listViewOpen: [], @@ -91,6 +92,40 @@ test.describe( 'Post Editor Performance', () => { } } ); + async function type( target, metrics, key ) { + // The first character typed triggers a longer time (isTyping change). + // It can impact the stability of the metric, so we exclude it. It + // probably deserves a dedicated metric itself, though. + const samples = 10; + const throwaway = 1; + const iterations = samples + throwaway; + + // Start tracing. + await metrics.startTracing(); + + // Type the testing sequence into the empty paragraph. + await target.type( 'x'.repeat( iterations ), { + delay: BROWSER_IDLE_WAIT, + // The extended timeout is needed because the typing is very slow + // and the `delay` value itself does not extend it. + timeout: iterations * BROWSER_IDLE_WAIT * 2, // 2x the total time to be safe. + } ); + + // Stop tracing. + await metrics.stopTracing(); + + // Get the durations. + const [ keyDownEvents, keyPressEvents, keyUpEvents ] = + metrics.getTypingEventDurations(); + + // Save the results. + for ( let i = throwaway; i < iterations; i++ ) { + results[ key ].push( + keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] + ); + } + } + test.describe( 'Typing', () => { let draftId = null; @@ -110,37 +145,43 @@ test.describe( 'Post Editor Performance', () => { name: /Empty block/i, } ); - // The first character typed triggers a longer time (isTyping change). - // It can impact the stability of the metric, so we exclude it. It - // probably deserves a dedicated metric itself, though. - const samples = 10; - const throwaway = 1; - const iterations = samples + throwaway; + await type( paragraph, metrics, 'type' ); + } ); + } ); - // Start tracing. - await metrics.startTracing(); + test.describe( 'Typing (without inspector)', () => { + let draftId = null; - // Type the testing sequence into the empty paragraph. - await paragraph.type( 'x'.repeat( iterations ), { - delay: BROWSER_IDLE_WAIT, - // The extended timeout is needed because the typing is very slow - // and the `delay` value itself does not extend it. - timeout: iterations * BROWSER_IDLE_WAIT * 2, // 2x the total time to be safe. - } ); + test( 'Setup the test post', async ( { admin, perfUtils, editor } ) => { + await admin.createNewPost(); + await perfUtils.loadBlocksForLargePost(); + await editor.insertBlock( { name: 'core/paragraph' } ); + draftId = await perfUtils.saveDraft(); + } ); - // Stop tracing. - await metrics.stopTracing(); + test( 'Run the test', async ( { + admin, + perfUtils, + metrics, + page, + editor, + } ) => { + await admin.editPost( draftId ); + await perfUtils.disableAutosave(); + const toggleButton = page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Close Settings' } ); + await toggleButton.click(); + const canvas = await perfUtils.getCanvas(); - // Get the durations. - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - metrics.getTypingEventDurations(); + const paragraph = canvas.getByRole( 'document', { + name: /Empty block/i, + } ); - // Save the results. - for ( let i = throwaway; i < iterations; i++ ) { - results.type.push( - keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] - ); - } + await type( paragraph, metrics, 'typeWithoutInspector' ); + + // Open the inspector again. + await editor.openDocumentSettingsSidebar(); } ); } ); @@ -166,37 +207,7 @@ test.describe( 'Post Editor Performance', () => { .first(); await firstParagraph.click(); - // The first character typed triggers a longer time (isTyping change). - // It can impact the stability of the metric, so we exclude it. It - // probably deserves a dedicated metric itself, though. - const samples = 10; - const throwaway = 1; - const iterations = samples + throwaway; - - // Start tracing. - await metrics.startTracing(); - - // Start typing in the middle of the text. - await firstParagraph.type( 'x'.repeat( iterations ), { - delay: BROWSER_IDLE_WAIT, - // The extended timeout is needed because the typing is very slow - // and the `delay` value itself does not extend it. - timeout: iterations * BROWSER_IDLE_WAIT * 2, // 2x the total time to be safe. - } ); - - // Stop tracing. - await metrics.stopTracing(); - - // Get the durations. - const [ keyDownEvents, keyPressEvents, keyUpEvents ] = - metrics.getTypingEventDurations(); - - // Save the results. - for ( let i = throwaway; i < iterations; i++ ) { - results.typeContainer.push( - keyDownEvents[ i ] + keyPressEvents[ i ] + keyUpEvents[ i ] - ); - } + await type( firstParagraph, metrics, 'typeContainer' ); } ); } ); diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index 9d19a5c9feb2f..38459b631fea4 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -39,6 +39,10 @@ module.exports = { transform: { '^.+\\.[jt]sx?$': '<rootDir>/test/unit/scripts/babel-transformer.js', }, + transformIgnorePatterns: [ + '/node_modules/(?!(docker-compose|yaml)/)', + '\\.pnp\\.[^\\/]+$', + ], snapshotSerializers: [ '@emotion/jest/serializer', 'snapshot-diff/serializer', diff --git a/test/unit/scripts/resolver.js b/test/unit/scripts/resolver.js index 2c359145f0b3e..7672bb723e124 100644 --- a/test/unit/scripts/resolver.js +++ b/test/unit/scripts/resolver.js @@ -24,7 +24,8 @@ module.exports = ( path, options ) => { pkg.name === 'uuid' || pkg.name === 'react-colorful' || pkg.name === '@eslint/eslintrc' || - pkg.name === 'expect' + pkg.name === 'expect' || + pkg.name === 'nanoid' ) { delete pkg.exports; delete pkg.module; diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index a76889622b4a2..86554d5f13909 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -29,6 +29,7 @@ const BUNDLED_PACKAGES = [ '@wordpress/interface', '@wordpress/undo-manager', '@wordpress/sync', + '@wordpress/dataviews', ]; // PHP files in packages that have to be copied during build.