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 ? (
+

+ ) : 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 `