diff --git a/package-lock.json b/package-lock.json index 131dccdcd..4026480b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,16 @@ "@tiptap/extension-underline": "^3.22.3", "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", - "@wordpress/block-editor": "15.17.0", - "@wordpress/block-library": "9.44.0", - "@wordpress/blocks": "15.17.0", - "@wordpress/components": "32.6.0", + "@wordpress/block-editor": "15.16.0", + "@wordpress/block-library": "9.43.0", + "@wordpress/blocks": "15.16.0", + "@wordpress/components": "32.5.0", + "@wordpress/core-data": "7.43.0", + "@wordpress/format-library": "5.43.0", + "@wordpress/hooks": "^4.43.0", "@wordpress/i18n": "6.17.0", + "@wordpress/patterns": "2.43.0", + "@wordpress/reusable-blocks": "5.43.0", "react": "18.3.1", "react-dom": "18.3.1", "zustand": "^5.0.12" @@ -56,7 +61,13 @@ }, "peerDependencies": { "daisyui": "^5.0.0", - "tailwindcss": "^4.0.0" + "tailwindcss": "^4.0.0", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } } }, "node_modules/@adobe/css-tools": { @@ -3460,48 +3471,49 @@ } }, "node_modules/@wordpress/block-editor": { - "version": "15.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-15.17.0.tgz", - "integrity": "sha512-zOQIvhe73K+Q15pClUdWuxL8r4RFHe4QxIyFfsLZK37cXfGzPiQ8QJf68muA5A2rkDFfO5mcT0XFFbDddE5ZUA==", + "version": "15.16.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-15.16.0.tgz", + "integrity": "sha512-gTTsrN3F1uWKmzOldi4t+IbbSbF/oEd/mxyhjZvvZ0mJV6cQM2q8q3FuWEx9mlqh1jsnh+ycuc//L556KXdIHA==", "license": "GPL-2.0-or-later", "dependencies": { "@react-spring/web": "^9.4.5", - "@wordpress/a11y": "^4.44.0", - "@wordpress/base-styles": "^6.20.0", - "@wordpress/blob": "^4.44.0", - "@wordpress/block-serialization-default-parser": "^5.44.0", - "@wordpress/blocks": "^15.17.0", - "@wordpress/commands": "^1.44.0", - "@wordpress/components": "^32.6.0", - "@wordpress/compose": "^7.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/dataviews": "^14.1.0", - "@wordpress/date": "^5.44.0", - "@wordpress/deprecated": "^4.44.0", - "@wordpress/dom": "^4.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/escape-html": "^3.44.0", - "@wordpress/global-styles-engine": "^1.11.0", - "@wordpress/hooks": "^4.44.0", - "@wordpress/html-entities": "^4.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/icons": "^12.2.0", - "@wordpress/image-cropper": "^1.8.0", - "@wordpress/interactivity": "^6.44.0", - "@wordpress/is-shallow-equal": "^5.44.0", - "@wordpress/keyboard-shortcuts": "^5.44.0", - "@wordpress/keycodes": "^4.44.0", - "@wordpress/notices": "^5.44.0", - "@wordpress/preferences": "^4.44.0", - "@wordpress/priority-queue": "^3.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/rich-text": "^7.44.0", - "@wordpress/style-engine": "^2.44.0", - "@wordpress/token-list": "^3.44.0", - "@wordpress/upload-media": "^0.29.0", - "@wordpress/url": "^4.44.0", - "@wordpress/warning": "^3.44.0", - "@wordpress/wordcount": "^4.44.0", + "@wordpress/a11y": "^4.43.0", + "@wordpress/api-fetch": "^7.43.0", + "@wordpress/base-styles": "^6.19.0", + "@wordpress/blob": "^4.43.0", + "@wordpress/block-serialization-default-parser": "^5.43.0", + "@wordpress/blocks": "^15.16.0", + "@wordpress/commands": "^1.43.0", + "@wordpress/components": "^32.5.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/dataviews": "^14.0.0", + "@wordpress/date": "^5.43.0", + "@wordpress/deprecated": "^4.43.0", + "@wordpress/dom": "^4.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/escape-html": "^3.43.0", + "@wordpress/global-styles-engine": "^1.10.0", + "@wordpress/hooks": "^4.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/image-cropper": "^1.7.0", + "@wordpress/interactivity": "^6.43.0", + "@wordpress/is-shallow-equal": "^5.43.0", + "@wordpress/keyboard-shortcuts": "^5.43.0", + "@wordpress/keycodes": "^4.43.0", + "@wordpress/notices": "^5.43.0", + "@wordpress/preferences": "^4.43.0", + "@wordpress/priority-queue": "^3.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/style-engine": "^2.43.0", + "@wordpress/token-list": "^3.43.0", + "@wordpress/upload-media": "^0.28.0", + "@wordpress/url": "^4.43.0", + "@wordpress/warning": "^3.43.0", + "@wordpress/wordcount": "^4.43.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -3527,48 +3539,48 @@ } }, "node_modules/@wordpress/block-library": { - "version": "9.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-library/-/block-library-9.44.0.tgz", - "integrity": "sha512-cEjbVhY1E1CZIcH3+PcSPiNRPONj/cXQRF3Bj7GPjKkmGv2oiZWEt2gWGSKCSdVyeOZ2buxONWYYCa/NEKv02w==", + "version": "9.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-library/-/block-library-9.43.0.tgz", + "integrity": "sha512-q8T76ib8n4cstXgOu8qwxQsPCF8wrX1Bf1viz/etO41iqswZFb3fgGrCUOg0BG2hBmDakCpjIDBGgnjRSAPicw==", "license": "GPL-2.0-or-later", "dependencies": { "@arraypress/waveform-player": "1.2.1", - "@wordpress/a11y": "^4.44.0", - "@wordpress/api-fetch": "^7.44.0", - "@wordpress/autop": "^4.44.0", - "@wordpress/base-styles": "^6.20.0", - "@wordpress/blob": "^4.44.0", - "@wordpress/block-editor": "^15.17.0", - "@wordpress/blocks": "^15.17.0", - "@wordpress/components": "^32.6.0", - "@wordpress/compose": "^7.44.0", - "@wordpress/core-data": "^7.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/date": "^5.44.0", - "@wordpress/deprecated": "^4.44.0", - "@wordpress/dom": "^4.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/escape-html": "^3.44.0", - "@wordpress/hooks": "^4.44.0", - "@wordpress/html-entities": "^4.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/icons": "^12.2.0", - "@wordpress/interactivity": "^6.44.0", - "@wordpress/interactivity-router": "^2.44.0", - "@wordpress/keyboard-shortcuts": "^5.44.0", - "@wordpress/keycodes": "^4.44.0", - "@wordpress/latex-to-mathml": "^1.12.0", - "@wordpress/notices": "^5.44.0", - "@wordpress/patterns": "^2.44.0", - "@wordpress/primitives": "^4.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/reusable-blocks": "^5.44.0", - "@wordpress/rich-text": "^7.44.0", - "@wordpress/server-side-render": "^6.20.0", - "@wordpress/upload-media": "^0.29.0", - "@wordpress/url": "^4.44.0", - "@wordpress/viewport": "^6.44.0", - "@wordpress/wordcount": "^4.44.0", + "@wordpress/a11y": "^4.43.0", + "@wordpress/api-fetch": "^7.43.0", + "@wordpress/autop": "^4.43.0", + "@wordpress/base-styles": "^6.19.0", + "@wordpress/blob": "^4.43.0", + "@wordpress/block-editor": "^15.16.0", + "@wordpress/blocks": "^15.16.0", + "@wordpress/components": "^32.5.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/core-data": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/date": "^5.43.0", + "@wordpress/deprecated": "^4.43.0", + "@wordpress/dom": "^4.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/escape-html": "^3.43.0", + "@wordpress/hooks": "^4.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/interactivity": "^6.43.0", + "@wordpress/interactivity-router": "^2.43.0", + "@wordpress/keyboard-shortcuts": "^5.43.0", + "@wordpress/keycodes": "^4.43.0", + "@wordpress/latex-to-mathml": "^1.11.0", + "@wordpress/notices": "^5.43.0", + "@wordpress/patterns": "^2.43.0", + "@wordpress/primitives": "^4.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/reusable-blocks": "^5.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/server-side-render": "^6.19.0", + "@wordpress/upload-media": "^0.28.0", + "@wordpress/url": "^4.43.0", + "@wordpress/viewport": "^6.43.0", + "@wordpress/wordcount": "^4.43.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -3599,26 +3611,26 @@ } }, "node_modules/@wordpress/blocks": { - "version": "15.17.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.17.0.tgz", - "integrity": "sha512-KRhXMcH1Te83A9zsZRkB6NhG7PD9hnXYp0+Fb44mYvLGxYNGpKFQYuPSxgn2Tq8Ie2MxUf+f/4K8nR+r5hV/Gg==", + "version": "15.16.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.16.0.tgz", + "integrity": "sha512-vyr87vwJvDErB19jVsBscpTC/x4+VhNGXGel4zawY3CdbyfBIHZrrdiOTjwwUnijLVspkv0nWkiUtYIxWP/SMQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/autop": "^4.44.0", - "@wordpress/blob": "^4.44.0", - "@wordpress/block-serialization-default-parser": "^5.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/deprecated": "^4.44.0", - "@wordpress/dom": "^4.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/hooks": "^4.44.0", - "@wordpress/html-entities": "^4.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/is-shallow-equal": "^5.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/rich-text": "^7.44.0", - "@wordpress/shortcode": "^4.44.0", - "@wordpress/warning": "^3.44.0", + "@wordpress/autop": "^4.43.0", + "@wordpress/blob": "^4.43.0", + "@wordpress/block-serialization-default-parser": "^5.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/deprecated": "^4.43.0", + "@wordpress/dom": "^4.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/hooks": "^4.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/is-shallow-equal": "^5.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/shortcode": "^4.43.0", + "@wordpress/warning": "^3.43.0", "change-case": "^4.1.2", "colord": "^2.7.0", "fast-deep-equal": "^3.1.3", @@ -3673,7 +3685,7 @@ "react-dom": "^18.0.0" } }, - "node_modules/@wordpress/components": { + "node_modules/@wordpress/commands/node_modules/@wordpress/components": { "version": "32.6.0", "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", @@ -3738,6 +3750,81 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/commands/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/components": { + "version": "32.5.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.5.0.tgz", + "integrity": "sha512-UEBNEqxHfOvTVbVepoLPS2wSzIjcZoCHMv6P2iN0om819x3aLIKAZqpxjNF8x0nz2z/gnj5Bj9GpXTW0+Bvfcw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.43.0", + "@wordpress/base-styles": "^6.19.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/date": "^5.43.0", + "@wordpress/deprecated": "^4.43.0", + "@wordpress/dom": "^4.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/escape-html": "^3.43.0", + "@wordpress/hooks": "^4.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/is-shallow-equal": "^5.43.0", + "@wordpress/keycodes": "^4.43.0", + "@wordpress/primitives": "^4.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/warning": "^3.43.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@wordpress/compose": { "version": "7.44.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.44.0.tgz", @@ -3765,27 +3852,27 @@ } }, "node_modules/@wordpress/core-data": { - "version": "7.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/core-data/-/core-data-7.44.0.tgz", - "integrity": "sha512-SBT/wiprxlo15QUwxKWH0t9RMvPu1TPgdd7+kPqqg79uUbkebs2P70Q3vBbQ6OdfEC4Mz7MGFeLlAr0uGT6KJQ==", + "version": "7.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/core-data/-/core-data-7.43.0.tgz", + "integrity": "sha512-18YOgbce1nwWN5FFXZ/qMdKlejAAYJI7gyp6EdIbo+ivnrfHL1YT41C3rzbQWw4QsjDF/Zvludb8M81OpA/mUQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/api-fetch": "^7.44.0", - "@wordpress/block-editor": "^15.17.0", - "@wordpress/blocks": "^15.17.0", - "@wordpress/compose": "^7.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/deprecated": "^4.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/html-entities": "^4.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/is-shallow-equal": "^5.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/rich-text": "^7.44.0", - "@wordpress/sync": "^1.44.0", - "@wordpress/undo-manager": "^1.44.0", - "@wordpress/url": "^4.44.0", - "@wordpress/warning": "^3.44.0", + "@wordpress/api-fetch": "^7.43.0", + "@wordpress/block-editor": "^15.16.0", + "@wordpress/blocks": "^15.16.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/deprecated": "^4.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/is-shallow-equal": "^5.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/sync": "^1.43.0", + "@wordpress/undo-manager": "^1.43.0", + "@wordpress/url": "^4.43.0", + "@wordpress/warning": "^3.43.0", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", @@ -3867,6 +3954,91 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/dataviews/node_modules/@wordpress/components": { + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", + "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.44.0", + "@wordpress/base-styles": "^6.20.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/date": "^5.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/escape-html": "^3.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/icons": "^12.2.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/keycodes": "^4.44.0", + "@wordpress/primitives": "^4.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@wordpress/dataviews/node_modules/@wordpress/components/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/@wordpress/dataviews/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/dataviews/node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -3905,6 +4077,16 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/deprecated/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/dom": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.44.0.tgz", @@ -3957,6 +4139,36 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/format-library": { + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/format-library/-/format-library-5.43.0.tgz", + "integrity": "sha512-WAO3KTfUFX4ktLVMOi5QFgJGH88p2pJng4OEGoh+Wq6p5Sc/n1f9B9jTZZVRbfJegjEU9eDrthOJkGwVUsFP3g==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/a11y": "^4.43.0", + "@wordpress/base-styles": "^6.19.0", + "@wordpress/block-editor": "^15.16.0", + "@wordpress/components": "^32.5.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/latex-to-mathml": "^1.11.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/rich-text": "^7.43.0", + "@wordpress/url": "^4.43.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@wordpress/global-styles-engine": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-1.11.0.tgz", @@ -3978,15 +4190,72 @@ "npm": ">=8.19.2" } }, - "node_modules/@wordpress/hooks": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", - "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "node_modules/@wordpress/global-styles-engine/node_modules/@wordpress/blocks": { + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.17.0.tgz", + "integrity": "sha512-KRhXMcH1Te83A9zsZRkB6NhG7PD9hnXYp0+Fb44mYvLGxYNGpKFQYuPSxgn2Tq8Ie2MxUf+f/4K8nR+r5hV/Gg==", "license": "GPL-2.0-or-later", - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - } + "dependencies": { + "@wordpress/autop": "^4.44.0", + "@wordpress/blob": "^4.44.0", + "@wordpress/block-serialization-default-parser": "^5.44.0", + "@wordpress/data": "^10.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/shortcode": "^4.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "colord": "^2.7.0", + "fast-deep-equal": "^3.1.3", + "hpq": "^1.3.0", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "react-is": "^18.3.0", + "remove-accents": "^0.5.0", + "showdown": "^1.9.1", + "simple-html-tokenizer": "^0.5.7", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@wordpress/global-styles-engine/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/global-styles-engine/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/@wordpress/hooks": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.43.0.tgz", + "integrity": "sha512-BY7GPjEwhOlgkavVak40E3RtA8Z9ehydqTZckRoesMRjXYfxKSzr1C1FT4wAPS5uXM1pNlWivfofMaJjVNQu5w==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } }, "node_modules/@wordpress/html-entities": { "version": "4.44.0", @@ -4018,6 +4287,16 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/i18n/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/icons": { "version": "12.2.0", "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-12.2.0.tgz", @@ -4058,6 +4337,81 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/image-cropper/node_modules/@wordpress/components": { + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", + "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.44.0", + "@wordpress/base-styles": "^6.20.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/date": "^5.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/escape-html": "^3.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/icons": "^12.2.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/keycodes": "^4.44.0", + "@wordpress/primitives": "^4.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@wordpress/image-cropper/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/interactivity": { "version": "6.44.0", "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-6.44.0.tgz", @@ -4160,27 +4514,102 @@ "react": "^18.0.0" } }, - "node_modules/@wordpress/patterns": { - "version": "2.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/patterns/-/patterns-2.44.0.tgz", - "integrity": "sha512-uSuqad6hfCJ2vEj9OfGNy65zovgQaNBHCE8249pk8WJMW9Sg8FfCBcGZWpCs2AdRD9re8gWPGuq6DLpPv8RbBw==", + "node_modules/@wordpress/notices/node_modules/@wordpress/components": { + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", + "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", "license": "GPL-2.0-or-later", "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", "@wordpress/a11y": "^4.44.0", "@wordpress/base-styles": "^6.20.0", - "@wordpress/block-editor": "^15.17.0", - "@wordpress/blocks": "^15.17.0", - "@wordpress/components": "^32.6.0", "@wordpress/compose": "^7.44.0", - "@wordpress/core-data": "^7.44.0", - "@wordpress/data": "^10.44.0", + "@wordpress/date": "^5.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", "@wordpress/element": "^6.44.0", + "@wordpress/escape-html": "^3.44.0", + "@wordpress/hooks": "^4.44.0", "@wordpress/html-entities": "^4.44.0", "@wordpress/i18n": "^6.17.0", "@wordpress/icons": "^12.2.0", - "@wordpress/notices": "^5.44.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/keycodes": "^4.44.0", + "@wordpress/primitives": "^4.44.0", "@wordpress/private-apis": "^1.44.0", - "@wordpress/url": "^4.44.0" + "@wordpress/rich-text": "^7.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@wordpress/notices/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/patterns": { + "version": "2.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/patterns/-/patterns-2.43.0.tgz", + "integrity": "sha512-e0vU95ofY35CkDLIRBCbSatv04zNzPFOsD+uQ53YmR2z2C3KMetxi67Gl9jxOI4p1+3mSt/D3N4U15UKTvnH6g==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/a11y": "^4.43.0", + "@wordpress/base-styles": "^6.19.0", + "@wordpress/block-editor": "^15.16.0", + "@wordpress/blocks": "^15.16.0", + "@wordpress/components": "^32.5.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/core-data": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/html-entities": "^4.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/notices": "^5.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/url": "^4.43.0" }, "engines": { "node": ">=18.12.0", @@ -4218,6 +4647,81 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/preferences/node_modules/@wordpress/components": { + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", + "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.44.0", + "@wordpress/base-styles": "^6.20.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/date": "^5.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/escape-html": "^3.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/icons": "^12.2.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/keycodes": "^4.44.0", + "@wordpress/primitives": "^4.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@wordpress/preferences/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/primitives": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.44.0.tgz", @@ -4277,23 +4781,23 @@ } }, "node_modules/@wordpress/reusable-blocks": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/@wordpress/reusable-blocks/-/reusable-blocks-5.44.0.tgz", - "integrity": "sha512-9dpae0P0sZiCjsJz9Zk+MhjHIllURzD3e72XLzjnRh0r/tmoQt1OJKUyAfDZCM1YS7SgvMkTPlpCgiB+M9c3jw==", + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/reusable-blocks/-/reusable-blocks-5.43.0.tgz", + "integrity": "sha512-8hj9Br1PpCC0gz1TqUNHtxYuaog0YNWlgkrctJQ89PYUzMj7kDfiaXzf5XTjXEiRPM84AeJ19vUA/DCFUiM2SA==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^6.20.0", - "@wordpress/block-editor": "^15.17.0", - "@wordpress/blocks": "^15.17.0", - "@wordpress/components": "^32.6.0", - "@wordpress/core-data": "^7.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/icons": "^12.2.0", - "@wordpress/notices": "^5.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/url": "^4.44.0" + "@wordpress/base-styles": "^6.19.0", + "@wordpress/block-editor": "^15.16.0", + "@wordpress/blocks": "^15.16.0", + "@wordpress/components": "^32.5.0", + "@wordpress/core-data": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/icons": "^12.1.0", + "@wordpress/notices": "^5.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/url": "^4.43.0" }, "engines": { "node": ">=18.12.0", @@ -4356,6 +4860,128 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/server-side-render/node_modules/@wordpress/blocks": { + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-15.17.0.tgz", + "integrity": "sha512-KRhXMcH1Te83A9zsZRkB6NhG7PD9hnXYp0+Fb44mYvLGxYNGpKFQYuPSxgn2Tq8Ie2MxUf+f/4K8nR+r5hV/Gg==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/autop": "^4.44.0", + "@wordpress/blob": "^4.44.0", + "@wordpress/block-serialization-default-parser": "^5.44.0", + "@wordpress/data": "^10.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/shortcode": "^4.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "colord": "^2.7.0", + "fast-deep-equal": "^3.1.3", + "hpq": "^1.3.0", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "react-is": "^18.3.0", + "remove-accents": "^0.5.0", + "showdown": "^1.9.1", + "simple-html-tokenizer": "^0.5.7", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/@wordpress/server-side-render/node_modules/@wordpress/components": { + "version": "32.6.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-32.6.0.tgz", + "integrity": "sha512-MpOr0mGTkKDRjxK5LKm86Uoj9p9Z6KkrvhkNVi5zVKCftyHVMK+tun7wL2Qn/JZVLbxZpB1kW5sJ5aMf3T2ToA==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@ariakit/react": "^0.4.22", + "@date-fns/utc": "^2.1.1", + "@emotion/cache": "^11.14.0", + "@emotion/css": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/styled": "^11.14.1", + "@emotion/utils": "^1.4.2", + "@floating-ui/react-dom": "2.0.8", + "@types/gradient-parser": "1.1.0", + "@types/highlight-words-core": "1.2.1", + "@types/react": "^18.3.27", + "@use-gesture/react": "^10.3.1", + "@wordpress/a11y": "^4.44.0", + "@wordpress/base-styles": "^6.20.0", + "@wordpress/compose": "^7.44.0", + "@wordpress/date": "^5.44.0", + "@wordpress/deprecated": "^4.44.0", + "@wordpress/dom": "^4.44.0", + "@wordpress/element": "^6.44.0", + "@wordpress/escape-html": "^3.44.0", + "@wordpress/hooks": "^4.44.0", + "@wordpress/html-entities": "^4.44.0", + "@wordpress/i18n": "^6.17.0", + "@wordpress/icons": "^12.2.0", + "@wordpress/is-shallow-equal": "^5.44.0", + "@wordpress/keycodes": "^4.44.0", + "@wordpress/primitives": "^4.44.0", + "@wordpress/private-apis": "^1.44.0", + "@wordpress/rich-text": "^7.44.0", + "@wordpress/warning": "^3.44.0", + "change-case": "^4.1.2", + "clsx": "^2.1.1", + "colord": "^2.7.0", + "csstype": "^3.2.3", + "date-fns": "^3.6.0", + "deepmerge": "^4.3.0", + "fast-deep-equal": "^3.1.3", + "framer-motion": "^11.15.0", + "gradient-parser": "1.1.1", + "highlight-words-core": "^1.2.2", + "is-plain-object": "^5.0.0", + "memize": "^2.1.0", + "path-to-regexp": "^6.2.1", + "re-resizable": "^6.4.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^9.7.0", + "remove-accents": "^0.5.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@wordpress/server-side-render/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/server-side-render/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/@wordpress/shortcode": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.44.0.tgz", @@ -4403,6 +5029,16 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/sync/node_modules/@wordpress/hooks": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/sync/node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -4546,20 +5182,20 @@ } }, "node_modules/@wordpress/upload-media": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.29.0.tgz", - "integrity": "sha512-ruMjLJGYWC5uSzzYKM+xkmXwpB1C6Ud69VNoupblpUmoG5amcI7I9e7gnQa8oJ0zHIkxFA50/9aHs4C0rsSQPA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.28.0.tgz", + "integrity": "sha512-t4iGWFVEObjkozYvRQ/y0j/BKpQCo3kqv2TUHkhq0vd26rse3vuFQPCxhgfJJfA1YIM8Wl726WApnhcvvj7/5Q==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blob": "^4.44.0", - "@wordpress/compose": "^7.44.0", - "@wordpress/data": "^10.44.0", - "@wordpress/element": "^6.44.0", - "@wordpress/i18n": "^6.17.0", - "@wordpress/preferences": "^4.44.0", - "@wordpress/private-apis": "^1.44.0", - "@wordpress/url": "^4.44.0", - "@wordpress/vips": "^1.4.0", + "@wordpress/blob": "^4.43.0", + "@wordpress/compose": "^7.43.0", + "@wordpress/data": "^10.43.0", + "@wordpress/element": "^6.43.0", + "@wordpress/i18n": "^6.16.0", + "@wordpress/preferences": "^4.43.0", + "@wordpress/private-apis": "^1.43.0", + "@wordpress/url": "^4.43.0", + "@wordpress/vips": "^1.3.0", "uuid": "^9.0.1" }, "engines": { diff --git a/package.json b/package.json index 5a6acf5ee..880b13075 100644 --- a/package.json +++ b/package.json @@ -70,11 +70,16 @@ "@tiptap/extension-underline": "^3.22.3", "@tiptap/react": "^3.22.3", "@tiptap/starter-kit": "^3.22.3", - "@wordpress/block-editor": "15.17.0", - "@wordpress/block-library": "9.44.0", - "@wordpress/blocks": "15.17.0", - "@wordpress/components": "32.6.0", + "@wordpress/block-editor": "15.16.0", + "@wordpress/block-library": "9.43.0", + "@wordpress/blocks": "15.16.0", + "@wordpress/components": "32.5.0", + "@wordpress/core-data": "7.43.0", + "@wordpress/format-library": "5.43.0", + "@wordpress/hooks": "4.43.0", "@wordpress/i18n": "6.17.0", + "@wordpress/patterns": "2.43.0", + "@wordpress/reusable-blocks": "5.43.0", "react": "18.3.1", "react-dom": "18.3.1", "zustand": "^5.0.12" diff --git a/resources/js/visual-editor/_legacy/editor/test-setup.ts b/resources/js/visual-editor/_legacy/editor/test-setup.ts index b16307202..f3c667e6f 100644 --- a/resources/js/visual-editor/_legacy/editor/test-setup.ts +++ b/resources/js/visual-editor/_legacy/editor/test-setup.ts @@ -1,5 +1,26 @@ import '@testing-library/jest-dom/vitest'; +// `@wordpress/components` calls `window.matchMedia` through its responsive +// helpers (e.g. `PanelBody`, `SelectControl`). jsdom doesn't ship a +// `matchMedia` implementation so the component import throws. Install a +// no-op stub that satisfies the `MediaQueryList` shape. +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + }); +} + // jsdom does not implement Range.getBoundingClientRect or // Document.elementFromPoint, which ProseMirror needs for mouse/selection // handling. Stub just enough for Tiptap-driven tests to run. diff --git a/resources/js/visual-editor/editor/__tests__/document-panels.test.tsx b/resources/js/visual-editor/editor/__tests__/document-panels.test.tsx new file mode 100644 index 000000000..39dfc221b --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/document-panels.test.tsx @@ -0,0 +1,307 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { addFilter, removeAllFilters } from '@wordpress/hooks'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// MediaUpload + MediaUploadCheck pull in the full block-editor store at +// import time. We're testing the panel wiring, not the bridge itself, so +// stub both components. Callers of `onSelect` are still exercised through +// the bridge tests (`media-bridge/__tests__/`). +vi.mock('@wordpress/block-editor', () => { + type MediaUploadRenderProp = (props: { open: () => void }) => JSX.Element; + type MediaUploadProps = { + onSelect: (media: unknown) => void; + render: MediaUploadRenderProp; + }; + + return { + MediaUploadCheck: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + MediaUpload: ({ onSelect, render: renderProp }: MediaUploadProps) => ( +
+ {renderProp({ + open: () => + onSelect({ + id: 42, + url: 'https://example.test/cover.jpg', + alt: 'Cover image', + }), + })} +
+ ), + }; +}); + +import { DocumentPanels } from '../document-panels'; +import { DOCUMENT_PANELS_FILTER } from '../plugin-document-setting-panel'; + +afterEach(() => { + // See plugin-document-setting-panel.test.tsx: `removeAllFilters` + // ignores the namespace arg at runtime but its TS type requires one. + removeAllFilters(DOCUMENT_PANELS_FILTER, ''); +}); + +function baseProps(overrides = {}) { + return { + status: 'draft' as const, + slug: '', + onStatusChange: vi.fn(), + onSlugChange: vi.fn(), + ...overrides, + }; +} + +describe('', () => { + it('renders the Status & visibility panel with status and slug', () => { + render( + + ); + + expect( + screen.getByTestId('ap-visual-editor-document-status') + ).toHaveValue('draft'); + expect( + screen.getByTestId('ap-visual-editor-document-slug') + ).toHaveValue('my-post'); + }); + + it('fires onStatusChange with a typed status value', () => { + const onStatusChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByTestId('ap-visual-editor-document-status'), { + target: { value: 'published' }, + }); + + expect(onStatusChange).toHaveBeenCalledWith('published'); + }); + + it('fires onSlugChange when the slug field changes', () => { + const onSlugChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByTestId('ap-visual-editor-document-slug'), { + target: { value: 'updated-slug' }, + }); + + expect(onSlugChange).toHaveBeenCalledWith('updated-slug'); + }); + + it('renders the Author select only when author options are provided', () => { + const { rerender } = render( + + ); + + expect( + screen.queryByTestId('ap-visual-editor-document-author') + ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.getByTestId('ap-visual-editor-document-author') + ).toHaveValue('1'); + }); + + it('resolves author option value back to its original type on change', () => { + const onAuthorChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByTestId('ap-visual-editor-document-author'), { + target: { value: '2' }, + }); + + expect(onAuthorChange).toHaveBeenCalledWith(2); + }); + + it('clears the author when the empty option is picked', () => { + const onAuthorChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByTestId('ap-visual-editor-document-author'), { + target: { value: '' }, + }); + + expect(onAuthorChange).toHaveBeenCalledWith(null); + }); + + it('renders the Featured image panel when supports.featuredImage is not false', async () => { + const user = userEvent.setup(); + const onFeaturedImageChange = vi.fn(); + + render( + + ); + + // The PanelBody renders a collapsed panel by default; open it first. + await user.click(screen.getByRole('button', { name: /Featured image/i })); + + // The stubbed MediaUpload immediately calls onSelect when the "Set + // featured image" button is clicked. + await user.click( + screen.getByRole('button', { name: /Set featured image/i }) + ); + + expect(onFeaturedImageChange).toHaveBeenCalledWith({ + id: 42, + url: 'https://example.test/cover.jpg', + alt: 'Cover image', + }); + }); + + it('hides the Featured image panel when supports.featuredImage is false', () => { + render( + + ); + + expect( + screen.queryByRole('button', { name: /Featured image/i }) + ).not.toBeInTheDocument(); + }); + + it('renders the Excerpt panel and forwards changes', async () => { + const user = userEvent.setup(); + const onExcerptChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /Excerpt/i })); + + fireEvent.change( + screen.getByTestId('ap-visual-editor-document-excerpt'), + { target: { value: 'A short summary.' } } + ); + + expect(onExcerptChange).toHaveBeenCalledWith('A short summary.'); + }); + + it('hides the Excerpt panel when supports.excerpt is false', () => { + render( + + ); + + expect( + screen.queryByRole('button', { name: /Excerpt/i }) + ).not.toBeInTheDocument(); + }); + + it('hides the Discussion panel by default', () => { + render( + + ); + + expect( + screen.queryByRole('button', { name: /Discussion/i }) + ).not.toBeInTheDocument(); + }); + + it('renders the Discussion panel when supports.comments is true', async () => { + const user = userEvent.setup(); + const onCommentsOpenChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /Discussion/i })); + + const toggle = screen.getByTestId( + 'ap-visual-editor-document-comments-open' + ); + + await user.click(toggle); + + expect(onCommentsOpenChange).toHaveBeenCalledWith(false); + }); + + it('renders filter-registered panels alongside the built-ins', () => { + addFilter(DOCUMENT_PANELS_FILTER, 'test/seo', (panels) => [ + ...panels, + { + id: 'test/seo', + title: 'SEO', + render: () => ( + + seo content + + ), + }, + ]); + + render(); + + expect( + screen.getByRole('button', { name: /SEO/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/resources/js/visual-editor/editor/__tests__/inspector-sidebar.test.tsx b/resources/js/visual-editor/editor/__tests__/inspector-sidebar.test.tsx new file mode 100644 index 000000000..3a7a14be7 --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/inspector-sidebar.test.tsx @@ -0,0 +1,227 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +// Stub `BlockInspector` — it pulls in the full block-editor store, which +// isn't what we're testing. The shell behaviour (tab switching, empty +// state, focus) is what this test owns. +vi.mock('@wordpress/block-editor', () => ({ + BlockInspector: () => ( +
+ Block inspector stub +
+ ), +})); + +import { InspectorSidebar } from '../inspector-sidebar'; + +function renderSidebar(props: { + hasSelectedBlockOverride: boolean; + documentContent?: React.ReactNode; +}) { + return render( + + Document panels stub + + ) + } + /> + ); +} + +describe('', () => { + it('exposes an accessible aside with a tablist', () => { + renderSidebar({ hasSelectedBlockOverride: false }); + + expect( + screen.getByRole('complementary', { name: 'Inspector' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('tablist', { name: 'Inspector tabs' }) + ).toBeInTheDocument(); + }); + + it('always renders both the Block and Document tabs', () => { + renderSidebar({ hasSelectedBlockOverride: false }); + + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ).toBeInTheDocument(); + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ).toBeInTheDocument(); + }); + + it('lands on the Document tab when nothing is selected', () => { + renderSidebar({ hasSelectedBlockOverride: false }); + + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ).toHaveAttribute('aria-selected', 'false'); + }); + + it('keeps both tabpanels mounted and toggles `hidden` on the inactive one', async () => { + const user = userEvent.setup(); + + renderSidebar({ hasSelectedBlockOverride: true }); + + const blockPanel = screen.getByTestId( + 'ap-visual-editor-inspector-block-panel' + ); + const documentPanel = screen.getByTestId( + 'ap-visual-editor-inspector-document-panel' + ); + + // Block tab is active; document panel mounted but hidden. + expect(blockPanel).not.toHaveAttribute('hidden'); + expect(documentPanel).toHaveAttribute('hidden'); + + await user.click( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ); + + // Flipped: document shown, block hidden. Both still in the DOM. + expect(documentPanel).not.toHaveAttribute('hidden'); + expect(blockPanel).toHaveAttribute('hidden'); + }); + + it('shows an empty-state message on the Block tab when nothing is selected', async () => { + const user = userEvent.setup(); + + renderSidebar({ hasSelectedBlockOverride: false }); + + await user.click( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ); + + expect( + screen.getByTestId('ap-visual-editor-inspector-block-empty') + ).toHaveTextContent(/click on a block/i); + expect( + screen.queryByTestId('ap-visual-editor-block-inspector-stub') + ).not.toBeInTheDocument(); + }); + + it('auto-switches to the Block tab when a block becomes selected', () => { + const { rerender } = render( + docs} + /> + ); + + rerender( + docs} + /> + ); + + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByTestId('ap-visual-editor-inspector-block-panel') + ).toBeInTheDocument(); + expect( + screen.getByTestId('ap-visual-editor-block-inspector-stub') + ).toBeInTheDocument(); + }); + + it('keeps the active tab when selection clears', () => { + const { rerender } = render( + docs} + /> + ); + + rerender( + docs} + /> + ); + + // Block tab stays selected but flips to the empty-state panel. + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByTestId('ap-visual-editor-inspector-block-empty') + ).toBeInTheDocument(); + }); + + it('switches tabs when the user clicks them', async () => { + const user = userEvent.setup(); + + renderSidebar({ hasSelectedBlockOverride: true }); + + // Auto-activated to Block; click Document to switch. + await user.click( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ); + + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-block') + ).toHaveAttribute('aria-selected', 'false'); + expect( + screen.getByTestId('ap-visual-editor-inspector-document-panel') + ).toBeInTheDocument(); + }); + + it('moves focus with ArrowLeft/ArrowRight and activates the focused tab', async () => { + const user = userEvent.setup(); + + renderSidebar({ hasSelectedBlockOverride: true }); + + const blockTab = screen.getByTestId( + 'ap-visual-editor-inspector-tab-block' + ); + const documentTab = screen.getByTestId( + 'ap-visual-editor-inspector-tab-document' + ); + + blockTab.focus(); + await user.keyboard('{ArrowRight}'); + + expect(documentTab).toHaveFocus(); + expect(documentTab).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowLeft}'); + + expect(blockTab).toHaveFocus(); + expect(blockTab).toHaveAttribute('aria-selected', 'true'); + }); + + it('focuses the active tab on first render', () => { + renderSidebar({ hasSelectedBlockOverride: false }); + + expect( + screen.getByTestId('ap-visual-editor-inspector-tab-document') + ).toHaveFocus(); + }); + + it('renders the provided document content in the Document tab panel', () => { + renderSidebar({ + hasSelectedBlockOverride: false, + documentContent: ( + Custom + ), + }); + + expect( + screen.getByTestId('ap-visual-editor-document-custom') + ).toBeInTheDocument(); + }); +}); diff --git a/resources/js/visual-editor/editor/__tests__/main.test.ts b/resources/js/visual-editor/editor/__tests__/main.test.ts new file mode 100644 index 000000000..86a9cc91d --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/main.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeAuthorId } from '../main'; + +describe('normalizeAuthorId', () => { + it('returns undefined for empty or missing values', () => { + expect(normalizeAuthorId(undefined, null)).toBeUndefined(); + expect(normalizeAuthorId('', null)).toBeUndefined(); + }); + + it('preserves the original type from authorOptions when it matches by string', () => { + const result = normalizeAuthorId('42', [ + { value: 42, label: 'Alice' }, + { value: 7, label: 'Bob' }, + ]); + + expect(result).toBe(42); + expect(typeof result).toBe('number'); + }); + + it('preserves a string value when authorOptions uses string ids', () => { + const result = normalizeAuthorId('alice-01', [ + { value: 'alice-01', label: 'Alice' }, + ]); + + expect(result).toBe('alice-01'); + expect(typeof result).toBe('string'); + }); + + it('coerces numeric-looking strings to numbers when no options provided', () => { + expect(normalizeAuthorId('42', null)).toBe(42); + expect(normalizeAuthorId('0', null)).toBe(0); + expect(normalizeAuthorId('-3', null)).toBe(-3); + }); + + it('leaves non-numeric strings as strings when no options provided', () => { + expect(normalizeAuthorId('alice', null)).toBe('alice'); + expect(normalizeAuthorId('user_1', null)).toBe('user_1'); + }); + + it('falls back to numeric coercion when options exist but do not match', () => { + const result = normalizeAuthorId('99', [ + { value: 1, label: 'Alice' }, + { value: 2, label: 'Bob' }, + ]); + + expect(result).toBe(99); + }); + + it('tolerates a non-array `authorOptions` without throwing', () => { + // Guards the readMountConfig call site: parseJsonDataset returns + // whatever the JSON resolves to, so a malformed data-author-options + // could be `null`, an object, or a primitive. The Array.isArray + // check up there funnels non-arrays into `null`; make sure + // normalizeAuthorId also doesn't trip on the nullish case. + expect(normalizeAuthorId('42', null)).toBe(42); + expect(normalizeAuthorId('', null)).toBeUndefined(); + }); +}); diff --git a/resources/js/visual-editor/editor/__tests__/plugin-document-setting-panel.test.tsx b/resources/js/visual-editor/editor/__tests__/plugin-document-setting-panel.test.tsx new file mode 100644 index 000000000..40625150f --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/plugin-document-setting-panel.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from '@testing-library/react'; +import { SlotFillProvider } from '@wordpress/components'; +import { addFilter, removeAllFilters } from '@wordpress/hooks'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + DOCUMENT_PANELS_FILTER, + DocumentPanelSlot, + PluginDocumentSettingPanel, + getFilteredDocumentPanels, +} from '../plugin-document-setting-panel'; + +afterEach(() => { + // `removeAllFilters` ignores the namespace argument at runtime when + // called through the `removeAll` constructor, but the TS type still + // requires one. Pass the wildcard namespace Gutenberg itself uses in + // its own tests. + removeAllFilters(DOCUMENT_PANELS_FILTER, ''); +}); + +describe('getFilteredDocumentPanels', () => { + it('returns an empty list when no filters are registered', () => { + expect(getFilteredDocumentPanels()).toEqual([]); + }); + + it('includes panels registered via @wordpress/hooks addFilter', () => { + addFilter(DOCUMENT_PANELS_FILTER, 'test/seo', (panels) => [ + ...panels, + { + id: 'test/seo', + title: 'SEO', + render: () => null, + }, + ]); + + const panels = getFilteredDocumentPanels(); + + expect(panels).toHaveLength(1); + expect(panels[0]).toMatchObject({ id: 'test/seo', title: 'SEO' }); + }); + + it('orders panels by their optional order field, stable within a tie', () => { + addFilter(DOCUMENT_PANELS_FILTER, 'test/a', (panels) => [ + ...panels, + { id: 'test/a', title: 'A', order: 50, render: () => null }, + { id: 'test/b', title: 'B', order: 10, render: () => null }, + { id: 'test/c', title: 'C', render: () => null }, + { id: 'test/d', title: 'D', order: 10, render: () => null }, + ]); + + const panels = getFilteredDocumentPanels(); + + expect(panels.map((panel) => panel.id)).toEqual([ + 'test/b', + 'test/d', + 'test/a', + 'test/c', + ]); + }); + + it('ignores malformed panel entries returned by filters', () => { + addFilter(DOCUMENT_PANELS_FILTER, 'test/bad', (panels) => [ + ...panels, + null, + { id: 'only-id' }, + { id: 'test/good', title: 'Good', render: () => null }, + { title: 'no id', render: () => null }, + ]); + + const panels = getFilteredDocumentPanels(); + + expect(panels).toHaveLength(1); + expect(panels[0].id).toBe('test/good'); + }); + + it('deduplicates panels by id with last-wins policy, including position', () => { + addFilter(DOCUMENT_PANELS_FILTER, 'test/dup', (panels) => [ + ...panels, + { id: 'shared', title: 'First', render: () => 'first' }, + { id: 'unique', title: 'Unique', render: () => 'unique' }, + { id: 'shared', title: 'Second', render: () => 'second' }, + ]); + + const panels = getFilteredDocumentPanels(); + + expect(panels).toHaveLength(2); + + // The later 'shared' wins AND takes the later position — the + // earlier instance is completely replaced rather than just + // having its value overwritten in place. + expect(panels.map((panel) => panel.id)).toEqual(['unique', 'shared']); + expect(panels.find((panel) => panel.id === 'shared')?.title).toBe( + 'Second' + ); + }); +}); + +describe('', () => { + it('renders its children through the DocumentPanelSlot', () => { + render( + + +

Demo panel contents

+
+ +
+ ); + + expect(screen.getByText('Demo panel')).toBeInTheDocument(); + expect(screen.getByText('Demo panel contents')).toBeInTheDocument(); + expect( + document.querySelector('[data-panel-name="acme/demo"]') + ).not.toBeNull(); + }); +}); diff --git a/resources/js/visual-editor/editor/__tests__/post-title.test.tsx b/resources/js/visual-editor/editor/__tests__/post-title.test.tsx new file mode 100644 index 000000000..5b4fd8f62 --- /dev/null +++ b/resources/js/visual-editor/editor/__tests__/post-title.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { PostTitle } from '../post-title'; + +describe('PostTitle', () => { + it('renders a label-wrapped textarea with the current value', () => { + render(); + + const input = screen.getByTestId( + 'ap-visual-editor-post-title-input' + ) as HTMLTextAreaElement; + + expect(input.tagName).toBe('TEXTAREA'); + expect(input).toHaveValue('Hello world'); + }); + + it('uses the default placeholder when none is provided', () => { + render(); + + expect( + screen.getByTestId('ap-visual-editor-post-title-input') + ).toHaveAttribute('placeholder', 'Add title'); + }); + + it('respects a custom placeholder', () => { + render( + + ); + + expect( + screen.getByTestId('ap-visual-editor-post-title-input') + ).toHaveAttribute('placeholder', 'Name your recipe'); + }); + + it('fires onChange with the new value on input', () => { + const onChange = vi.fn(); + + render(); + + const input = screen.getByTestId( + 'ap-visual-editor-post-title-input' + ) as HTMLTextAreaElement; + + fireEvent.change(input, { target: { value: 'A fresh draft' } }); + + expect(onChange).toHaveBeenCalledWith('A fresh draft'); + }); +}); diff --git a/resources/js/visual-editor/editor/__tests__/top-bar.test.tsx b/resources/js/visual-editor/editor/__tests__/top-bar.test.tsx index 6f017f337..e753c1cb9 100644 --- a/resources/js/visual-editor/editor/__tests__/top-bar.test.tsx +++ b/resources/js/visual-editor/editor/__tests__/top-bar.test.tsx @@ -2,16 +2,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { TopBar, type PostStatus, type TopBarProps } from '../top-bar'; +import { TopBar, type TopBarProps } from '../top-bar'; function defaultProps(overrides: Partial = {}): TopBarProps { return { - title: '', - slug: '', - status: 'draft', - onTitleChange: vi.fn(), - onSlugChange: vi.fn(), - onStatusChange: vi.fn(), saveStatus: 'idle', lastSavedAt: null, saveErrorMessage: null, @@ -45,32 +39,18 @@ describe('TopBar', () => { expect(toolbar).toBeInTheDocument(); }); - it('fires onTitleChange, onSlugChange, and onStatusChange with the new value', () => { - const onTitleChange = vi.fn(); - const onSlugChange = vi.fn(); - const onStatusChange = vi.fn(); - - render( - - ); - - const titleInput = screen.getByTestId('ap-visual-editor-top-bar-title') as HTMLInputElement; - fireEvent.change(titleInput, { target: { value: 'Hello world' } }); - expect(onTitleChange).toHaveBeenCalledWith('Hello world'); - - const slugInput = screen.getByTestId('ap-visual-editor-top-bar-slug') as HTMLInputElement; - fireEvent.change(slugInput, { target: { value: 'hello-world' } }); - expect(onSlugChange).toHaveBeenCalledWith('hello-world'); + it('does not render title, slug, or status inputs (moved to canvas/sidebar)', () => { + render(); - const statusSelect = screen.getByTestId('ap-visual-editor-top-bar-status') as HTMLSelectElement; - fireEvent.change(statusSelect, { target: { value: 'published' } }); - expect(onStatusChange).toHaveBeenCalledWith('published' satisfies PostStatus); + expect( + screen.queryByTestId('ap-visual-editor-top-bar-title') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ap-visual-editor-top-bar-slug') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('ap-visual-editor-top-bar-status') + ).not.toBeInTheDocument(); }); it('disables undo/redo when history has no entries', () => { diff --git a/resources/js/visual-editor/editor/document-panels.tsx b/resources/js/visual-editor/editor/document-panels.tsx new file mode 100644 index 000000000..61735c841 --- /dev/null +++ b/resources/js/visual-editor/editor/document-panels.tsx @@ -0,0 +1,442 @@ +/** + * Built-in Document tab panels. + * + * Together with `` and the + * `ap.visual-editor.document-panels` filter, these form the default + * contents of the inspector sidebar's Document tab. Each panel is + * independently gated so a host that doesn't support comments, doesn't + * pass an author list, etc. simply hides that panel — no host config + * means no empty rows. + * + * All panels use `@wordpress/components` primitives so they inherit the + * same look, keyboard behaviour, and focus outlines as the block + * inspector. DaisyUI theming is applied at the shell level through the + * `--ap-visual-editor-*` CSS custom properties. + */ + +import { + MediaUpload, + MediaUploadCheck, +} from '@wordpress/block-editor'; +import { + Button, + PanelBody, + PanelRow, + SelectControl, + TextareaControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useCallback } from 'react'; +import type { ReactNode } from 'react'; + +import { TEXT_DOMAIN } from '../vendor/i18n'; + +import { + DocumentPanelSlot, + getFilteredDocumentPanels, +} from './plugin-document-setting-panel'; + +export type PostStatus = + | 'draft' + | 'pending' + | 'scheduled' + | 'published' + | 'private'; + +export interface FeaturedImageValue { + id: number; + url: string; + alt?: string; +} + +export interface AuthorOption { + value: number | string; + label: string; +} + +export interface DocumentSupports { + /** Show the Excerpt panel. Default `true`. */ + excerpt?: boolean; + /** Show the Featured Image panel. Default `true`. */ + featuredImage?: boolean; + /** + * Show the Discussion panel. Default `false` because comments are a + * host-model concern and not every resource exposes them. + */ + comments?: boolean; +} + +export interface DocumentPanelsProps { + status: PostStatus; + slug: string; + onStatusChange: (status: PostStatus) => void; + onSlugChange: (slug: string) => void; + + authorId?: number | string | null; + authorOptions?: ReadonlyArray; + onAuthorChange?: (authorId: number | string | null) => void; + + excerpt?: string; + onExcerptChange?: (value: string) => void; + + featuredImage?: FeaturedImageValue | null; + onFeaturedImageChange?: (value: FeaturedImageValue | null) => void; + + commentsOpen?: boolean; + onCommentsOpenChange?: (value: boolean) => void; + + supports?: DocumentSupports; +} + +const STATUS_OPTIONS: ReadonlyArray<{ value: PostStatus; label: string }> = [ + { value: 'draft', label: 'Draft' }, + { value: 'pending', label: 'Pending review' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'published', label: 'Published' }, + { value: 'private', label: 'Private' }, +]; + +function statusOptions(): Array<{ value: string; label: string }> { + return STATUS_OPTIONS.map((option) => ({ + value: option.value, + label: translateStatus(option.value), + })); +} + +function translateStatus(status: PostStatus): string { + switch (status) { + case 'draft': + return __('Draft', TEXT_DOMAIN); + case 'pending': + return __('Pending review', TEXT_DOMAIN); + case 'scheduled': + return __('Scheduled', TEXT_DOMAIN); + case 'published': + return __('Published', TEXT_DOMAIN); + case 'private': + return __('Private', TEXT_DOMAIN); + } +} + +export function DocumentPanels(props: DocumentPanelsProps): JSX.Element { + const { + status, + slug, + onStatusChange, + onSlugChange, + authorId, + authorOptions, + onAuthorChange, + excerpt, + onExcerptChange, + featuredImage, + onFeaturedImageChange, + commentsOpen, + onCommentsOpenChange, + supports, + } = props; + + const showExcerpt = supports?.excerpt !== false && onExcerptChange !== undefined; + const showFeaturedImage = supports?.featuredImage !== false; + const showComments = supports?.comments === true && onCommentsOpenChange !== undefined; + + // Recompute on every render — `@wordpress/hooks` filters are global + // module state with no React-level subscription, so memoizing here + // would freeze the panel list at mount time and hide any panels a + // host registers after that point. The cost is one `applyFilters` + // call per render, which is negligible. + const filteredPanels = getFilteredDocumentPanels(); + + return ( +
+ + {showFeaturedImage ? ( + + ) : null} + {showExcerpt ? ( + + ) : null} + {showComments ? ( + + ) : null} + {filteredPanels.map((panel) => ( + + ))} + +
+ ); +} + +interface StatusVisibilityPanelProps { + status: PostStatus; + slug: string; + onStatusChange: (status: PostStatus) => void; + onSlugChange: (slug: string) => void; + authorId?: number | string | null; + authorOptions?: ReadonlyArray; + onAuthorChange?: (authorId: number | string | null) => void; +} + +function StatusVisibilityPanel(props: StatusVisibilityPanelProps): JSX.Element { + const { + status, + slug, + onStatusChange, + onSlugChange, + authorId, + authorOptions, + onAuthorChange, + } = props; + + const hasAuthorPicker = + authorOptions !== undefined && + authorOptions.length > 0 && + onAuthorChange !== undefined; + + const handleStatusChange = useCallback( + (value: string): void => { + onStatusChange(value as PostStatus); + }, + [onStatusChange] + ); + + const handleAuthorChange = useCallback( + (value: string): void => { + if (onAuthorChange === undefined) { + return; + } + + if (value === '') { + onAuthorChange(null); + return; + } + + const option = authorOptions?.find( + (opt) => String(opt.value) === value + ); + + onAuthorChange(option?.value ?? value); + }, + [authorOptions, onAuthorChange] + ); + + const authorSelectValue = + authorId === null || authorId === undefined ? '' : String(authorId); + + return ( + + + + + + + + {hasAuthorPicker ? ( + + ({ + value: String(option.value), + label: option.label, + })), + ]} + onChange={handleAuthorChange} + __nextHasNoMarginBottom={true} + __next40pxDefaultSize={true} + data-testid="ap-visual-editor-document-author" + /> + + ) : null} + + ); +} + +interface FeaturedImagePanelProps { + value: FeaturedImageValue | null; + onChange?: (value: FeaturedImageValue | null) => void; +} + +function FeaturedImagePanel(props: FeaturedImagePanelProps): JSX.Element { + const { value, onChange } = props; + + const handleSelect = useCallback( + (media: unknown): void => { + if (onChange === undefined) { + return; + } + + if (media === null || typeof media !== 'object') { + return; + } + + const candidate = media as Partial; + + if (typeof candidate.id !== 'number' || typeof candidate.url !== 'string') { + return; + } + + onChange({ + id: candidate.id, + url: candidate.url, + alt: typeof candidate.alt === 'string' ? candidate.alt : undefined, + }); + }, + [onChange] + ); + + const handleRemove = useCallback((): void => { + onChange?.(null); + }, [onChange]); + + return ( + + + void }) => ( +
+ {value !== null ? ( + {value.alt + ) : null} +
+ + {value !== null ? ( + + ) : null} +
+
+ )} + /> +
+
+ ); +} + +interface ExcerptPanelProps { + value: string; + onChange: (value: string) => void; +} + +function ExcerptPanel(props: ExcerptPanelProps): JSX.Element { + const { value, onChange } = props; + + return ( + + + + ); +} + +interface DiscussionPanelProps { + commentsOpen: boolean; + onCommentsOpenChange: (value: boolean) => void; +} + +function DiscussionPanel(props: DiscussionPanelProps): JSX.Element { + const { commentsOpen, onCommentsOpenChange } = props; + + return ( + + + + ); +} + +function FilteredPanel({ + panel, +}: { + panel: { id: string; title: string; initialOpen?: boolean; render: () => ReactNode }; +}): JSX.Element { + return ( + +
{panel.render()}
+
+ ); +} diff --git a/resources/js/visual-editor/editor/editor-app.css b/resources/js/visual-editor/editor/editor-app.css index 99e991f83..7154d8723 100644 --- a/resources/js/visual-editor/editor/editor-app.css +++ b/resources/js/visual-editor/editor/editor-app.css @@ -37,16 +37,23 @@ .ap-visual-editor__sidebar { flex: 0 0 var(--ap-visual-editor-shell-sidebar-width); - padding: 16px; background: var(--ap-visual-editor-shell-sidebar-background); border-right: 1px solid var(--ap-visual-editor-shell-border); box-sizing: border-box; +} + +.ap-visual-editor__sidebar--inserter { + padding: 16px; overflow-y: auto; } .ap-visual-editor__sidebar--inspector { + flex: 0 0 var(--ap-visual-editor-shell-sidebar-width); border-right: 0; border-left: 1px solid var(--ap-visual-editor-shell-border); + min-height: 0; + display: flex; + flex-direction: column; } .ap-visual-editor__sidebar-title { diff --git a/resources/js/visual-editor/editor/editor-app.tsx b/resources/js/visual-editor/editor/editor-app.tsx index f5c97257e..878b6aea0 100644 --- a/resources/js/visual-editor/editor/editor-app.tsx +++ b/resources/js/visual-editor/editor/editor-app.tsx @@ -3,9 +3,9 @@ * * Mounts a `BlockEditorProvider` for a single resource+id pair and wires the * persistence loop so keystrokes hit Laravel via debounced PUTs. M7 (#317) - * adds the top bar chrome — title/slug/status inputs, save indicator, - * undo/redo, and the inserter/inspector toggles — all controlled by this - * component so the shell feels like a Laravel admin page, not `edit-post`. + * added the top bar chrome; A1 (#343) moved the title input into the canvas + * and introduced the real inspector sidebar (block + document tabs) in place + * of the M7 placeholder. */ import { @@ -19,22 +19,40 @@ import { BlockEditorProvider, BlockList, BlockTools, + ObserveTyping, WritingFlow, } from '@wordpress/block-editor'; import { registerCoreBlocks } from '@wordpress/block-library'; import { Popover, SlotFillProvider } from '@wordpress/components'; +// Importing `@wordpress/format-library` is a side-effect — it registers +// the core rich-text formats (bold, italic, link, inline code, etc.) so +// the block toolbar's inline formatting controls render inside RichText +// blocks. Without this import the toolbar renders but the formats are +// empty (#343). +import '@wordpress/format-library'; import { __ } from '@wordpress/i18n'; import type { BlockInstance } from '@wordpress/blocks'; +import { addFilter } from '@wordpress/hooks'; + import { bootI18n, TEXT_DOMAIN } from '../vendor/i18n'; import { ensureMediaBridgeFilter, mediaUploadSetting, } from '../media-bridge'; +import { + DocumentPanels, + type AuthorOption, + type DocumentSupports, + type FeaturedImageValue, + type PostStatus, +} from './document-panels'; +import { InspectorSidebar } from './inspector-sidebar'; import { KeyboardShortcutsModal } from './keyboard-shortcuts-modal'; +import { PostTitle } from './post-title'; import { useSaveNotifications } from './save-notifications'; -import { TopBar, type PostStatus } from './top-bar'; +import { TopBar } from './top-bar'; import { usePersistence } from './use-persistence'; import '@wordpress/components/build-style/style.css'; @@ -48,8 +66,76 @@ import './editor-app.css'; // the `@wordpress/*` stylesheets above (M8 · #318). import './visual-editor-theme.css'; +export type { PostStatus, FeaturedImageValue, AuthorOption, DocumentSupports }; + let blocksRegistered = false; +/** + * Filter namespace used by `disableContrastCheckerOnBlocks` below so the + * registration is idempotent across HMR reloads. + */ +const CONTRAST_CHECKER_FILTER_NAMESPACE = + 'artisanpack-ui/visual-editor/disable-contrast-checker'; + +/** + * Turn off `BlockColorContrastChecker` for every block that opts into + * color support. The checker (block-editor v15.x) has a deps-less + * `useLayoutEffect` that schedules a double-`requestAnimationFrame` + * setColors chain on every render. During a color-picker drag those + * RAF callbacks pile up faster than React can settle, tripping its + * "Maximum update depth exceeded" guard and crashing the block via + * `BlockCrashBoundary`. Disabling the checker via block supports stops + * the component from mounting at all, which sidesteps the bug until + * Gutenberg lands the upstream fix. + */ +function disableContrastCheckerOnBlocks(): void { + addFilter( + 'blocks.registerBlockType', + CONTRAST_CHECKER_FILTER_NAMESPACE, + (settings: { supports?: Record } | null | undefined) => { + if ( + settings === null || + settings === undefined || + typeof settings !== 'object' + ) { + return settings; + } + + const supports = settings.supports; + + if ( + supports === null || + supports === undefined || + typeof supports !== 'object' || + !('color' in supports) + ) { + return settings; + } + + const color = (supports as { color: unknown }).color; + const normalizedColor = + color === true ? {} : color === null || color === undefined + ? null + : typeof color === 'object' ? { ...color } : null; + + if (normalizedColor === null) { + return settings; + } + + return { + ...settings, + supports: { + ...supports, + color: { + ...normalizedColor, + enableContrastChecker: false, + }, + }, + }; + } + ); +} + function registerOnce(): void { if (blocksRegistered) { return; @@ -57,12 +143,109 @@ function registerOnce(): void { bootI18n(); ensureMediaBridgeFilter(); + // Install the filter *before* `registerCoreBlocks` so the override + // reaches every block as it registers. Doing it after would leave + // already-registered blocks with the default (buggy) checker on. + disableContrastCheckerOnBlocks(); registerCoreBlocks(); blocksRegistered = true; } +/** + * DaisyUI-aligned color palette shared between the editor settings and + * block-editor's `__experimentalFeatures.color.palette`. Hosts can + * override by publishing their own settings once theme.json integration + * (B3) lands; for V1 we seed a coherent default so new installs aren't + * staring at an empty color picker. + */ +// Preset labels are wrapped with `__()` so the pot-extraction command +// (`composer visual-editor:pot`) picks them up and host apps that load +// translations before the editor bundle executes get the translated +// strings. When translations aren't loaded, `__()` returns the English +// source unchanged — same behaviour as the raw strings had before. +const DEFAULT_PALETTE = [ + { name: __('Base content', TEXT_DOMAIN), slug: 'base-content', color: '#1f2937' }, + { name: __('Base muted', TEXT_DOMAIN), slug: 'base-muted', color: '#6b7280' }, + { name: __('Primary', TEXT_DOMAIN), slug: 'primary', color: '#2563eb' }, + { name: __('Secondary', TEXT_DOMAIN), slug: 'secondary', color: '#64748b' }, + { name: __('Accent', TEXT_DOMAIN), slug: 'accent', color: '#9333ea' }, + { name: __('Success', TEXT_DOMAIN), slug: 'success', color: '#16a34a' }, + { name: __('Warning', TEXT_DOMAIN), slug: 'warning', color: '#d97706' }, + { name: __('Error', TEXT_DOMAIN), slug: 'error', color: '#dc2626' }, +]; + +const DEFAULT_FONT_SIZES = [ + { name: __('Small', TEXT_DOMAIN), slug: 'small', size: '13px' }, + { name: __('Regular', TEXT_DOMAIN), slug: 'regular', size: '16px' }, + { name: __('Medium', TEXT_DOMAIN), slug: 'medium', size: '20px' }, + { name: __('Large', TEXT_DOMAIN), slug: 'large', size: '28px' }, + { name: __('Huge', TEXT_DOMAIN), slug: 'huge', size: '36px' }, +]; + +const DEFAULT_FONT_FAMILIES = [ + { + name: __('System', TEXT_DOMAIN), + slug: 'system', + fontFamily: + 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + }, + { + name: __('Serif', TEXT_DOMAIN), + slug: 'serif', + fontFamily: + 'Georgia, Cambria, "Times New Roman", Times, serif', + }, + { + name: __('Monospaced', TEXT_DOMAIN), + slug: 'mono', + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace', + }, +]; + +/** + * Block editor settings seeded with the block-support features needed to + * light up the inspector panels (Color, Typography, Dimensions, Border, + * Layout) and the toolbar text-align control. + * + * `__experimentalFeatures` is intentionally minimal: options like + * `link`, `defaultPalette`, `defaultGradients`, and `defaultDuotone` + * assume WordPress-core preset data that we don't ship, and turning + * them on without that data triggers infinite render loops inside the + * color picker (observed with the ColorGradientControl during drag). + * Only enable a feature once the data backing it is also wired. + */ const editorSettings = { mediaUpload: mediaUploadSetting, + alignWide: true, + // Top-level legacy keys are still read by some core blocks that + // haven't migrated to `__experimentalFeatures`. + colors: DEFAULT_PALETTE, + fontSizes: DEFAULT_FONT_SIZES, + // Minimal `__experimentalFeatures` — only the keys needed for the + // Typography panel's font-family picker and the text-alignment + // toolbar. Turning on more keys (border, spacing, layout, duotone, + // default palettes) re-introduces the color-picker drag loop we're + // hunting; keep this config narrow until the upstream fix lands. + __experimentalFeatures: { + color: { + custom: true, + text: true, + background: true, + }, + typography: { + customFontSize: true, + fontStyle: true, + fontWeight: true, + letterSpacing: true, + lineHeight: true, + textAlign: true, + textDecoration: true, + textTransform: true, + fontSizes: { custom: DEFAULT_FONT_SIZES }, + fontFamilies: { custom: DEFAULT_FONT_FAMILIES }, + }, + }, }; const VALID_STATUSES: readonly PostStatus[] = [ @@ -89,6 +272,10 @@ export interface MetadataChange { title: string; slug: string; status: PostStatus; + excerpt: string; + featuredImage: FeaturedImageValue | null; + authorId: number | string | null; + commentsOpen: boolean; } export interface EditorAppProps { @@ -98,6 +285,12 @@ export interface EditorAppProps { initialTitle?: string; initialSlug?: string; initialStatus?: PostStatus | string; + initialExcerpt?: string; + initialFeaturedImage?: FeaturedImageValue | null; + initialAuthorId?: number | string | null; + initialCommentsOpen?: boolean; + authorOptions?: ReadonlyArray; + supports?: DocumentSupports; previewUrl?: string | null; onMetadataChange?: (change: MetadataChange) => void; } @@ -124,6 +317,12 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { initialTitle = '', initialSlug = '', initialStatus, + initialExcerpt = '', + initialFeaturedImage = null, + initialAuthorId = null, + initialCommentsOpen = true, + authorOptions, + supports, previewUrl = null, onMetadataChange, } = props; @@ -136,6 +335,7 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { saveError, lastSavedAt, onBlocksChange, + queueBlocksForSave, flush, } = usePersistence(props); @@ -149,8 +349,14 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { const [status, setStatus] = useState( normalizeStatus(typeof initialStatus === 'string' ? initialStatus : undefined) ); + const [excerpt, setExcerpt] = useState(initialExcerpt); + const [featuredImage, setFeaturedImage] = useState( + initialFeaturedImage + ); + const [authorId, setAuthorId] = useState(initialAuthorId); + const [commentsOpen, setCommentsOpen] = useState(initialCommentsOpen); const [inserterOpen, setInserterOpen] = useState(false); - const [inspectorOpen, setInspectorOpen] = useState(false); + const [inspectorOpen, setInspectorOpen] = useState(true); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [history, setHistory] = useState(EMPTY_HISTORY); // Mirror history in a ref so undo/redo handlers can read the latest @@ -165,6 +371,19 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { // Track the last committed tree so history only records meaningful edits. const lastCommittedRef = useRef(null); + const currentMetadata = useMemo( + (): MetadataChange => ({ + title, + slug, + status, + excerpt, + featuredImage, + authorId, + commentsOpen, + }), + [authorId, commentsOpen, excerpt, featuredImage, slug, status, title] + ); + const emitMetadata = useCallback( (next: Partial): void => { if (onMetadataChange === undefined) { @@ -172,12 +391,11 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { } onMetadataChange({ - title: next.title ?? title, - slug: next.slug ?? slug, - status: next.status ?? status, + ...currentMetadata, + ...next, }); }, - [onMetadataChange, slug, status, title] + [currentMetadata, onMetadataChange] ); const handleTitleChange = useCallback( @@ -204,11 +422,68 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { [emitMetadata] ); + const handleExcerptChange = useCallback( + (value: string): void => { + setExcerpt(value); + emitMetadata({ excerpt: value }); + }, + [emitMetadata] + ); + + const handleFeaturedImageChange = useCallback( + (value: FeaturedImageValue | null): void => { + setFeaturedImage(value); + emitMetadata({ featuredImage: value }); + }, + [emitMetadata] + ); + + const handleAuthorChange = useCallback( + (value: number | string | null): void => { + setAuthorId(value); + emitMetadata({ authorId: value }); + }, + [emitMetadata] + ); + + const handleCommentsOpenChange = useCallback( + (value: boolean): void => { + setCommentsOpen(value); + emitMetadata({ commentsOpen: value }); + }, + [emitMetadata] + ); + const handleInput = useCallback( (next: BlockInstance[]): void => { - onBlocksChange(next); + // `onInput` fires on intermediate, non-persistent changes — + // color-picker drag frames, mid-typing rich-text updates, etc. + // Calling `setBlocks(next)` here re-renders the entire editor + // tree (TopBar, InspectorSidebar, BlockInspector panels) on + // every frame, which triggers a runaway layout-effect cascade + // inside Gutenberg's block-support hooks and crashes the + // block with "Maximum update depth exceeded" (#343 A1, #347). + // + // The block-editor store already has the latest tree from + // the dispatch that triggered this callback, so the canvas + // stays in sync without our mirror. We only need to queue a + // debounced save — undo/redo still works because `onChange` + // (fired on the persistent commit at the end of the drag) + // does the React state update and history push. + // + // Invariant: letting the `value` prop go stale between + // commits is SAFE. `use-block-sync` only reacts to + // reference-changes of `controlledBlocks` (its effect deps + // are `[controlledBlocks, clientId]`); when `value` doesn't + // change, the sync effect is a no-op and the internal + // store keeps itself consistent. Any attempt to "mirror" + // per-frame into a transient state reintroduces the + // cascade we're explicitly avoiding — there's no React + // pattern that feeds a reactive `value` prop without + // triggering a render. + queueBlocksForSave(next); }, - [onBlocksChange] + [queueBlocksForSave] ); const handleChange = useCallback( @@ -303,12 +578,6 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { const topBar = useMemo( () => ( ); + const documentPanels = ( + + ); + if (loadStatus === 'loading') { return (
@@ -419,31 +701,25 @@ function EditorAppShell(props: EditorAppProps): JSX.Element { onChange={handleChange} >
+ - + + +
+ {inspectorOpen ? ( +
+ +
+ ) : null} - {inspectorOpen ? ( - - ) : null}
{shortcutsModal} diff --git a/resources/js/visual-editor/editor/inspector-sidebar.css b/resources/js/visual-editor/editor/inspector-sidebar.css new file mode 100644 index 000000000..7b9492613 --- /dev/null +++ b/resources/js/visual-editor/editor/inspector-sidebar.css @@ -0,0 +1,122 @@ +/** + * Inspector sidebar styles. + * + * All color, spacing, and radius tokens are expressed as CSS custom + * properties under the `--ap-visual-editor-inspector-sidebar-*` + * namespace so M8 theming (#318) and host apps can override without + * touching this file. Defaults read DaisyUI tokens first with hex + * fallbacks so the sidebar still renders outside a DaisyUI host. + */ + +.ap-visual-editor-inspector-sidebar { + --ap-visual-editor-inspector-sidebar-background: var( + --color-base-100, + #ffffff + ); + --ap-visual-editor-inspector-sidebar-foreground: var( + --color-base-content, + #1f2937 + ); + --ap-visual-editor-inspector-sidebar-border: var( + --color-base-300, + #e5e7eb + ); + --ap-visual-editor-inspector-sidebar-accent: var( + --color-primary, + #2563eb + ); + --ap-visual-editor-inspector-sidebar-muted: color-mix( + in oklch, + var(--color-base-content, #6b7280) 65%, + transparent + ); + --ap-visual-editor-inspector-sidebar-tab-inactive: color-mix( + in oklch, + var(--color-base-content, #6b7280) 70%, + transparent + ); + + display: flex; + flex-direction: column; + height: 100%; + background: var(--ap-visual-editor-inspector-sidebar-background); + color: var(--ap-visual-editor-inspector-sidebar-foreground); + box-sizing: border-box; +} + +.ap-visual-editor-inspector-sidebar__tablist { + display: flex; + padding: 0; + margin: 0; + border-bottom: 1px solid + var(--ap-visual-editor-inspector-sidebar-border); + background: var(--ap-visual-editor-inspector-sidebar-background); +} + +.ap-visual-editor-inspector-sidebar__tab { + flex: 1 1 0; + padding: 12px 16px; + background: transparent; + color: var(--ap-visual-editor-inspector-sidebar-tab-inactive); + border: 0; + border-bottom: 2px solid transparent; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + font-family: inherit; +} + +.ap-visual-editor-inspector-sidebar__tab[aria-selected='true'] { + color: var(--ap-visual-editor-inspector-sidebar-accent); + border-bottom-color: var( + --ap-visual-editor-inspector-sidebar-accent + ); +} + +.ap-visual-editor-inspector-sidebar__tab:focus-visible { + outline: 2px solid var(--ap-visual-editor-inspector-sidebar-accent); + outline-offset: -2px; +} + +.ap-visual-editor-inspector-sidebar__panel { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0; +} + +.ap-visual-editor-inspector-sidebar__document-panels { + display: flex; + flex-direction: column; +} + +.ap-visual-editor-inspector-sidebar__empty { + margin: 0; + padding: 24px 16px; + color: var(--ap-visual-editor-inspector-sidebar-muted); + font-size: 13px; + line-height: 1.5; + text-align: center; +} + +.ap-visual-editor-inspector-sidebar__featured-image { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ap-visual-editor-inspector-sidebar__featured-image-preview { + display: block; + width: 100%; + max-height: 220px; + object-fit: contain; + border-radius: var(--rounded-box, 0.5rem); + background: var(--color-base-200, #f3f4f6); +} + +.ap-visual-editor-inspector-sidebar__featured-image-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} diff --git a/resources/js/visual-editor/editor/inspector-sidebar.tsx b/resources/js/visual-editor/editor/inspector-sidebar.tsx new file mode 100644 index 000000000..45e01f81e --- /dev/null +++ b/resources/js/visual-editor/editor/inspector-sidebar.tsx @@ -0,0 +1,257 @@ +/** + * Inspector sidebar shell. + * + * Right-hand sidebar that tabs between per-block settings (rendered by + * `@wordpress/block-editor`'s ``) and document-level + * settings (status, featured image, excerpt, discussion, plus host + * extensions). Replaces the literal placeholder that shipped with M7 + * (#317) and wires up the sidebar toggle that was already plumbed + * through the top bar. + * + * ARIA tabs pattern: + * - The tablist has `role="tablist"` and each button has `role="tab"`. + * - Arrow-left/right move focus between tabs and activate on focus + * (automatic activation — matches WP's inspector). + * - Each `tabpanel` is labelled by its tab's id. + * + * Auto-switching: when a block is selected while the sidebar is open, + * the Block tab auto-activates. When the selection clears we leave the + * user on whichever tab they're on — the Block tab stays visible at all + * times and shows a "select a block" empty state when nothing is + * selected. Mirrors the WordPress inspector and means the tablist never + * surprises the user by shrinking. + * + * Focus management: when the sidebar becomes visible, focus moves to + * the active tab. The parent controls visibility; this component just + * runs the effect on mount. + */ + +import { BlockInspector } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from 'react'; + +import { TEXT_DOMAIN } from '../vendor/i18n'; + +import './inspector-sidebar.css'; + +export type InspectorTab = 'block' | 'document'; + +export interface InspectorSidebarProps { + documentContent: ReactNode; + /** + * Override for the `@wordpress/data` selection lookup. Tests pass a + * synchronous value so they don't need to stand up a block-editor + * store; production code always omits this so the sidebar reflects + * the live block selection. + */ + hasSelectedBlockOverride?: boolean; +} + +export function InspectorSidebar(props: InspectorSidebarProps): JSX.Element { + const { documentContent, hasSelectedBlockOverride } = props; + + const liveHasSelectedBlock = useSelect( + (select) => { + if (hasSelectedBlockOverride !== undefined) { + return hasSelectedBlockOverride; + } + + const store = select('core/block-editor') as + | { hasSelectedBlock?: () => boolean } + | undefined; + + return store?.hasSelectedBlock?.() ?? false; + }, + [hasSelectedBlockOverride] + ); + + const hasSelectedBlock = + hasSelectedBlockOverride !== undefined + ? hasSelectedBlockOverride + : liveHasSelectedBlock; + + // Both tabs are always mounted; `activeTab` just tracks which panel + // is visible. On load we land on Document unless a block is already + // selected (which is rare but possible when the host reopens a + // previously-focused canvas). + const [activeTab, setActiveTab] = useState( + hasSelectedBlock ? 'block' : 'document' + ); + + const blockTabId = useId(); + const documentTabId = useId(); + const blockPanelId = useId(); + const documentPanelId = useId(); + + const blockTabRef = useRef(null); + const documentTabRef = useRef(null); + const initialFocusRan = useRef(false); + + // When a user selects a block while the sidebar is open, flip to + // the Block tab so they see the settings for what they just clicked. + // We deliberately don't auto-return to Document when the selection + // clears — leaving the user on the Block tab with the empty-state + // message preserves their place, and the tablist stays stable. + const previousHasSelection = useRef(hasSelectedBlock); + + useEffect(() => { + if (previousHasSelection.current === hasSelectedBlock) { + return; + } + + previousHasSelection.current = hasSelectedBlock; + + if (hasSelectedBlock) { + setActiveTab('block'); + } + }, [hasSelectedBlock]); + + // Move focus to the active tab on first render so keyboard users who + // just toggled the sidebar open land somewhere useful. Skip on + // subsequent re-renders so typing inside a document control doesn't + // steal focus back to the tablist. + useEffect(() => { + if (initialFocusRan.current) { + return; + } + + initialFocusRan.current = true; + + const target = + activeTab === 'block' ? blockTabRef.current : documentTabRef.current; + + target?.focus({ preventScroll: true }); + }, [activeTab]); + + const handleSelectTab = useCallback((tab: InspectorTab): void => { + setActiveTab(tab); + }, []); + + const handleTabKey = useCallback( + (event: ReactKeyboardEvent): void => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + + // Flip based on which tab is currently focused so the user can + // arrow-navigate even if the selection/activation state is mid- + // transition. Falling back to `activeTab` when focus is somewhere + // else covers the keyboard-shortcut case where an outer handler + // dispatches the event without focusing a tab first. + const focusedTab: InspectorTab = + event.currentTarget === blockTabRef.current + ? 'block' + : event.currentTarget === documentTabRef.current + ? 'document' + : activeTab; + const next: InspectorTab = + focusedTab === 'block' ? 'document' : 'block'; + + setActiveTab(next); + + const target = + next === 'block' ? blockTabRef.current : documentTabRef.current; + + target?.focus({ preventScroll: true }); + }, + [activeTab] + ); + + return ( + + ); +} diff --git a/resources/js/visual-editor/editor/main.tsx b/resources/js/visual-editor/editor/main.tsx index 342ca4aca..e309155a6 100644 --- a/resources/js/visual-editor/editor/main.tsx +++ b/resources/js/visual-editor/editor/main.tsx @@ -8,11 +8,16 @@ * at least one editor is present on the page. */ -import { StrictMode, createElement } from 'react'; +import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import type { + AuthorOption, + DocumentSupports, + FeaturedImageValue, +} from './document-panels'; + export { registerMediaBridge } from '../media-bridge'; -export type { MountConfig, MountedEditor }; export type { BridgeMedia, BridgeMediaType, @@ -37,6 +42,23 @@ export type { VeEditorSaveEvent, } from './editor-events'; +export { + DocumentPanelSlot, + DOCUMENT_PANELS_FILTER, + PluginDocumentSettingPanel, + getFilteredDocumentPanels, +} from './plugin-document-setting-panel'; +export type { + DocumentPanelSpec, + PluginDocumentSettingPanelProps, +} from './plugin-document-setting-panel'; +export type { + AuthorOption, + DocumentSupports, + FeaturedImageValue, + PostStatus, +} from './document-panels'; + const MOUNT_SELECTOR = '[data-ap-visual-editor]'; const ROOT_SYMBOL: unique symbol = Symbol('ap-visual-editor-root'); @@ -51,6 +73,12 @@ export interface MountConfig { initialTitle?: string; initialSlug?: string; initialStatus?: string; + initialExcerpt?: string; + initialFeaturedImage?: FeaturedImageValue | null; + initialAuthorId?: number | string | null; + initialCommentsOpen?: boolean; + authorOptions?: ReadonlyArray; + supports?: DocumentSupports; previewUrl?: string | null; } @@ -68,6 +96,28 @@ export interface MountedEditor { ready: Promise; } +function parseJsonDataset(raw: string | undefined, context: string): T | null { + if (raw === undefined) { + return null; + } + + const trimmed = raw.trim(); + + if (trimmed === '') { + return null; + } + + try { + return JSON.parse(trimmed) as T; + } catch (error) { + console.warn( + `visual-editor: could not parse ${context} dataset attribute as JSON.`, + error + ); + return null; + } +} + function readMountConfig(element: HTMLElement): MountConfig | null { const apiBase = element.dataset.apiBase?.trim(); const resource = element.dataset.resource?.trim(); @@ -80,8 +130,42 @@ function readMountConfig(element: HTMLElement): MountConfig | null { const initialTitle = element.dataset.title?.trim(); const initialSlug = element.dataset.slug?.trim(); const initialStatus = element.dataset.status?.trim(); + const initialExcerpt = element.dataset.excerpt; + const rawAuthorId = element.dataset.authorId?.trim(); + const commentsOpenRaw = element.dataset.commentsOpen?.trim(); const previewUrl = element.dataset.previewUrl?.trim(); + const featuredImage = parseJsonDataset( + element.dataset.featuredImage, + 'data-featured-image' + ); + // `parseJsonDataset` returns whatever the JSON resolves to, so a host + // mis-emitting an object (or a primitive) as `data-author-options` + // would otherwise crash `normalizeAuthorId`'s `.find` / the + // SelectControl props builder. Fall back to null when the parsed + // value isn't an array. + const parsedAuthorOptions = parseJsonDataset( + element.dataset.authorOptions, + 'data-author-options' + ); + const authorOptions: ReadonlyArray | null = Array.isArray( + parsedAuthorOptions + ) + ? (parsedAuthorOptions as ReadonlyArray) + : null; + const supports = parseJsonDataset( + element.dataset.supports, + 'data-supports' + ); + + // Dataset attributes are always strings, but most Laravel hosts store + // author IDs as integers. Normalize back to the original type so + // `onMetadataChange` emits values that round-trip cleanly into the + // host's model. Preference order: exact match against an author + // option (preserves whatever type the host declared), then numeric + // coercion when the string looks numeric, then leave-as-string. + const initialAuthorId = normalizeAuthorId(rawAuthorId, authorOptions); + return { apiBase, resource, @@ -89,10 +173,50 @@ function readMountConfig(element: HTMLElement): MountConfig | null { ...(initialTitle ? { initialTitle } : {}), ...(initialSlug ? { initialSlug } : {}), ...(initialStatus ? { initialStatus } : {}), + ...(initialExcerpt !== undefined ? { initialExcerpt } : {}), + ...(initialAuthorId !== undefined ? { initialAuthorId } : {}), + ...(commentsOpenRaw !== undefined + ? { initialCommentsOpen: commentsOpenRaw === 'true' } + : {}), + ...(featuredImage !== null ? { initialFeaturedImage: featuredImage } : {}), + ...(authorOptions !== null ? { authorOptions } : {}), + ...(supports !== null ? { supports } : {}), previewUrl: previewUrl ?? null, }; } +/** + * Exported for tests. Not part of the public package surface. + * @internal + */ +export function normalizeAuthorId( + raw: string | undefined, + authorOptions: ReadonlyArray | null +): number | string | undefined { + if (raw === undefined || raw === '') { + return undefined; + } + + if (authorOptions !== null && authorOptions.length > 0) { + const match = authorOptions.find( + (option) => + String(option.value) === raw || option.value === Number(raw) + ); + + if (match !== undefined) { + return match.value; + } + } + + const asNumber = Number(raw); + + if (!Number.isNaN(asNumber) && /^-?\d+(\.\d+)?$/.test(raw)) { + return asNumber; + } + + return raw; +} + /** * Mounts the React editor into an arbitrary host element with an explicit * config object. Intended for host-framework wrappers (Vue, Svelte, etc.) @@ -129,9 +253,14 @@ export function mountEditor( return; } - root.render( - createElement(StrictMode, null, createElement(EditorApp, config)), - ); + // Intentionally NOT wrapped in React.StrictMode. Several + // `@wordpress/components` class components (and the block- + // editor color pipeline) are not StrictMode-safe in v32/v15 + // — the dev-mode double-invocation triggers "Maximum update + // depth exceeded" crashes during interactions like color + // picker drag. Revisit once Gutenberg's own StrictMode audit + // lands upstream. + root.render(createElement(EditorApp, config)); }, (error: unknown) => { console.error('visual-editor: failed to load editor app.', error); diff --git a/resources/js/visual-editor/editor/plugin-document-setting-panel.tsx b/resources/js/visual-editor/editor/plugin-document-setting-panel.tsx new file mode 100644 index 000000000..022f152ff --- /dev/null +++ b/resources/js/visual-editor/editor/plugin-document-setting-panel.tsx @@ -0,0 +1,162 @@ +/** + * Host-extensible document sidebar surface. + * + * Exposes two ways for a host app to add a panel to the Document tab of the + * inspector sidebar: + * + * 1. `` — a React slot-fill mirroring the + * WordPress `@wordpress/edit-post` component of the same name. Hosts + * render it anywhere inside the editor tree (typically from an + * `extraActions`-style slot in their own wrapper) and it appears in + * the sidebar. + * + * 2. `ap.visual-editor.document-panels` — a `@wordpress/hooks` filter + * taking the current panel list and returning an augmented one. This + * mirrors the `artisanpack-ui/hooks` idioms Laravel devs expect and + * is more convenient for panels registered during bootstrap rather + * than as children of the editor tree. + * + * Both surfaces funnel into a single `` that the + * inspector renders at the end of the built-in panels. Ordering within + * the slot follows React render order; filter-registered panels carry an + * optional `order` integer (lower = earlier) so plugins loaded in any + * order can still agree on positioning. + */ + +import { PanelBody } from '@wordpress/components'; +import { createSlotFill } from '@wordpress/components'; +import { applyFilters } from '@wordpress/hooks'; +import type { ReactNode } from 'react'; + +const SLOT_NAME = 'ApVisualEditorDocumentPanel'; + +const { Slot, Fill } = createSlotFill(SLOT_NAME); + +/** + * Filter name hosts use with `@wordpress/hooks` to register panels. + * Exported so host code can type-check the filter name against this + * constant instead of hand-typing the string. + */ +export const DOCUMENT_PANELS_FILTER = 'ap.visual-editor.document-panels'; + +export interface DocumentPanelSpec { + /** Stable identifier; used as the React `key` and `data-panel-name`. */ + id: string; + /** Visible panel title. Should already be translated by the caller. */ + title: string; + /** Start expanded. Defaults to `false` (mirrors WP's `PanelBody` default). */ + initialOpen?: boolean; + /** + * Sort key within the filter-registered list. Lower values render + * earlier. Panels without an `order` fall back to registration order. + * Slot-fill panels ignore this field entirely — their order is the + * order React encounters the `` elements. + */ + order?: number; + /** React render callback. Invoked once per render, no arguments. */ + render: () => ReactNode; +} + +export interface PluginDocumentSettingPanelProps { + /** Stable identifier emitted as `data-panel-name` on the panel root. */ + name: string; + /** Visible panel title. Should already be translated by the caller. */ + title: string; + /** Start expanded. Defaults to `false`. */ + initialOpen?: boolean; + /** Extra class to merge onto the `PanelBody` root. */ + className?: string; + children?: ReactNode; +} + +/** + * Slot-fill companion to the `ap.visual-editor.document-panels` filter. + * Render it anywhere inside the editor tree to contribute a panel to the + * Document tab. + */ +export function PluginDocumentSettingPanel( + props: PluginDocumentSettingPanelProps +): JSX.Element { + const { name, title, initialOpen = false, className, children } = props; + + return ( + + +
{children}
+
+
+ ); +} + +/** Slot that renders every registered `` fill. */ +export function DocumentPanelSlot(): JSX.Element { + return ; +} + +/** + * Runs the `ap.visual-editor.document-panels` filter against the empty + * list and returns the registered panels, sorted by `order`. Kept pure so + * the inspector can call it inside render without scheduling work. + */ +export function getFilteredDocumentPanels(): DocumentPanelSpec[] { + const panels = applyFilters( + DOCUMENT_PANELS_FILTER, + [] as DocumentPanelSpec[] + ); + + if (!Array.isArray(panels)) { + return []; + } + + const valid = panels.filter( + (panel): panel is DocumentPanelSpec => + panel !== null && + typeof panel === 'object' && + typeof (panel as DocumentPanelSpec).id === 'string' && + typeof (panel as DocumentPanelSpec).title === 'string' && + typeof (panel as DocumentPanelSpec).render === 'function' + ); + + // Stable sort by order, then by registration index. Array.prototype.sort + // is not guaranteed stable across engines older than ES2019, so pair + // with the original index before comparing. + const sorted = valid + .map((panel, index) => ({ panel, index })) + .sort((a, b) => { + const orderA = a.panel.order ?? 100; + const orderB = b.panel.order ?? 100; + + if (orderA !== orderB) { + return orderA - orderB; + } + + return a.index - b.index; + }) + .map(({ panel }) => panel); + + // Deduplicate by id — two plugins racing on the same identifier, or + // a single plugin re-registering after HMR, would otherwise produce + // duplicate React keys and render the panel twice. Last-wins policy + // mirrors how `@wordpress/hooks` itself composes filters: a later + // `addFilter` overrides an earlier one at the same priority. + // + // `Map#set` on an existing key updates the value but keeps the + // original insertion position. We want the *later* panel to appear + // at its later position (so ordering reflects the most recent + // registration), so delete-then-reinsert when the key already + // exists. + const deduped = new Map(); + + for (const panel of sorted) { + if (deduped.has(panel.id)) { + deduped.delete(panel.id); + } + deduped.set(panel.id, panel); + } + + return Array.from(deduped.values()); +} diff --git a/resources/js/visual-editor/editor/post-title.css b/resources/js/visual-editor/editor/post-title.css new file mode 100644 index 000000000..596900dff --- /dev/null +++ b/resources/js/visual-editor/editor/post-title.css @@ -0,0 +1,74 @@ +/** + * Post title styles. + * + * Token namespace: `--ap-visual-editor-post-title-*`. Defaults read + * DaisyUI tokens first with hex fallbacks so the title renders + * correctly outside a DaisyUI host. + */ + +.ap-visual-editor-post-title { + --ap-visual-editor-post-title-foreground: var( + --color-base-content, + #111827 + ); + --ap-visual-editor-post-title-placeholder: color-mix( + in oklch, + var(--color-base-content, #9ca3af) 45%, + transparent + ); + --ap-visual-editor-post-title-padding-x: 32px; + --ap-visual-editor-post-title-padding-y: 28px; + --ap-visual-editor-post-title-max-width: 720px; + + padding: var(--ap-visual-editor-post-title-padding-y) + var(--ap-visual-editor-post-title-padding-x) 12px; +} + +.ap-visual-editor-post-title__label { + display: block; + max-width: var(--ap-visual-editor-post-title-max-width); + margin: 0 auto; +} + +.ap-visual-editor-post-title__input { + display: block; + width: 100%; + padding: 2px 4px; + margin: 0 -4px; + background: transparent; + color: var(--ap-visual-editor-post-title-foreground); + border: 0; + border-radius: var(--rounded-btn, 6px); + outline: none; + font-family: inherit; + font-size: clamp(28px, 4vw, 40px); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; + resize: none; + overflow: hidden; + overflow-wrap: anywhere; +} + +.ap-visual-editor-post-title__input:focus-visible { + outline: 2px solid var(--color-primary, #2563eb); + outline-offset: 2px; +} + +.ap-visual-editor-post-title__input::placeholder { + color: var(--ap-visual-editor-post-title-placeholder); + font-weight: 600; +} + +.screen-reader-text { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/resources/js/visual-editor/editor/post-title.tsx b/resources/js/visual-editor/editor/post-title.tsx new file mode 100644 index 000000000..f141fcbc5 --- /dev/null +++ b/resources/js/visual-editor/editor/post-title.tsx @@ -0,0 +1,80 @@ +/** + * Post title input rendered above the block list. + * + * WordPress renders the post title as a large, editable textarea at the + * top of the canvas — the first thing above the first block. Moving + * the title out of the top bar (A1, #343) lets hosts give it a more + * prominent role and mirrors the WordPress post editor. + * + * This is deliberately a plain `