Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422697acec | ||
|
|
d1c57db967 | ||
|
|
071f3447fd | ||
|
|
98f2ebf118 | ||
|
|
916a568e9f | ||
|
|
32f4092156 | ||
|
|
6a9b227dda | ||
|
|
156c5bae35 | ||
|
|
69e61ab4b2 | ||
|
|
3dd6b54a35 | ||
|
|
d7eeff3a68 | ||
|
|
f43a1ef872 | ||
|
|
fd7f883d6a | ||
|
|
4cfcccd272 | ||
|
|
024f9fdd46 | ||
|
|
fc8918c1f9 | ||
|
|
9be71e8fd1 | ||
|
|
aba0d187d7 | ||
|
|
1a01834068 | ||
|
|
bfcf6278e9 | ||
|
|
e5fd74d63d | ||
|
|
cf38fdb245 | ||
|
|
2438777462 | ||
|
|
f9561c8c54 | ||
|
|
f30fc6efec | ||
|
|
eb4290a45e | ||
|
|
38c8d22e5d | ||
|
|
9885b37af5 | ||
|
|
86aacbe1a8 | ||
|
|
f0a1508acd | ||
|
|
66db1db507 | ||
|
|
b670b436c3 | ||
|
|
d89930e218 | ||
|
|
45ee004672 | ||
|
|
536e4e9f86 | ||
|
|
51f3fe81b6 | ||
|
|
321a193b83 | ||
|
|
4ac57e1c4c | ||
|
|
0d0d722dd7 | ||
|
|
a30e82accf | ||
|
|
6791345f77 | ||
|
|
fb68fa6485 | ||
|
|
c6840db0bb | ||
|
|
f5d2a23a5f | ||
|
|
c712a69c4a | ||
|
|
d460e8ac33 | ||
|
|
2b1569202a | ||
|
|
5c44dd545c | ||
|
|
2ac62c4e9e | ||
|
|
25dfcbb725 | ||
|
|
9bf78fd72d | ||
|
|
2dcc2b4d21 | ||
|
|
4e0fc78a30 | ||
|
|
85dfe181aa | ||
|
|
a698f014b0 | ||
|
|
204ea5e078 | ||
|
|
0291ddce9a | ||
|
|
f7da654c11 | ||
|
|
d6666f6f79 | ||
|
|
4a426e3513 | ||
|
|
6e628d68dd | ||
|
|
8f17b74e2b | ||
|
|
54572f648a | ||
|
|
8bf525c600 | ||
|
|
d07bc7789d | ||
|
|
b03425ac39 | ||
|
|
9750a6c2bf | ||
|
|
5b19ae97af | ||
|
|
88df4f2888 | ||
|
|
e1b4ab735c | ||
|
|
ff1e8fc096 | ||
|
|
0419291259 | ||
|
|
1d9460c173 | ||
|
|
8d69982a0b | ||
|
|
fb40e3ece4 | ||
|
|
9591fcb8a7 | ||
|
|
f80811318b | ||
|
|
cb9fea6656 | ||
|
|
b1afdcb585 | ||
|
|
f00e4db3dc | ||
|
|
b9552faa30 | ||
|
|
77f35c4e9e | ||
|
|
2c8425ffb0 | ||
|
|
2a071bc7ab |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This site builder integrates with WHP (Web Hosting Panel) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
This site builder integrates with WHP (Web Hosting Platform) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
Generated
+217
@@ -8,6 +8,15 @@
|
|||||||
"name": "whp-site-builder",
|
"name": "whp-site-builder",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
|
"@codemirror/commands": "^6.10.4",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.43.6",
|
||||||
"@craftjs/core": "^0.2.10",
|
"@craftjs/core": "^0.2.10",
|
||||||
"@craftjs/layers": "^0.2.7",
|
"@craftjs/layers": "^0.2.7",
|
||||||
"dompurify": "^3.4.5",
|
"dompurify": "^3.4.5",
|
||||||
@@ -373,6 +382,133 @@
|
|||||||
"specificity": "bin/cli.js"
|
"specificity": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||||
|
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||||
|
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-html": {
|
||||||
|
"version": "6.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||||
|
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.4.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/css": "^1.1.0",
|
||||||
|
"@lezer/html": "^1.3.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
|
"version": "6.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||||
|
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.6.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/javascript": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||||
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||||
|
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||||
|
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/theme-one-dark": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||||
|
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@craftjs/core": {
|
"node_modules/@craftjs/core": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
|
||||||
@@ -1093,6 +1229,69 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/html": {
|
||||||
|
"version": "1.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||||
|
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/javascript": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.1.3",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.59.1",
|
"version": "1.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
@@ -1873,6 +2072,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/css-color-keywords": {
|
"node_modules/css-color-keywords": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||||
@@ -2686,6 +2891,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/styled-components": {
|
"node_modules/styled-components": {
|
||||||
"version": "6.3.12",
|
"version": "6.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
|
||||||
@@ -3096,6 +3307,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/w3c-xmlserializer": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
"test:unit:watch": "vitest"
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
|
"@codemirror/commands": "^6.10.4",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/state": "^6.7.1",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.43.6",
|
||||||
"@craftjs/core": "^0.2.10",
|
"@craftjs/core": "^0.2.10",
|
||||||
"@craftjs/layers": "^0.2.7",
|
"@craftjs/layers": "^0.2.7",
|
||||||
"dompurify": "^3.4.5",
|
"dompurify": "^3.4.5",
|
||||||
|
|||||||
@@ -52,3 +52,69 @@ describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', ()
|
|||||||
expect(html).toContain('>Click Me</a>');
|
expect(html).toContain('>Click Me</a>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
|
||||||
|
test('no hover props -- no <style> block, no class added', () => {
|
||||||
|
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
|
||||||
|
expect(html).not.toContain('<style>');
|
||||||
|
expect(html).not.toContain('class=');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
|
||||||
|
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
|
||||||
|
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
|
||||||
|
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two different node ids produce different scope classes (no collision)', () => {
|
||||||
|
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
|
||||||
|
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
|
||||||
|
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
|
||||||
|
expect(scopeOf(a)).toBeTruthy();
|
||||||
|
expect(scopeOf(a)).not.toBe(scopeOf(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
|
||||||
|
const malicious = '</style><script>alert(1)</script>';
|
||||||
|
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
|
||||||
|
expect(html).not.toContain('</style><script>');
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
|
||||||
|
const malicious = 'red;}body{background:red';
|
||||||
|
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
|
||||||
|
expect(html).not.toContain('}body{');
|
||||||
|
expect(html).not.toContain(';}');
|
||||||
|
// The whole export is still exactly one <style> element -- no new rule
|
||||||
|
// or element was opened by the malicious value.
|
||||||
|
expect((html.match(/<style>/g) || []).length).toBe(1);
|
||||||
|
expect((html.match(/<\/style>/g) || []).length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
|
||||||
|
test('target defaults to _self, hoverBg/hoverColor blank', () => {
|
||||||
|
const props = (ButtonLink as any).craft.props;
|
||||||
|
expect(props.target).toBe('_self');
|
||||||
|
expect(props.hoverBg).toBe('');
|
||||||
|
expect(props.hoverColor).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (ButtonLink as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model keys', () => {
|
||||||
|
const style = (ButtonLink as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
|
||||||
|
|
||||||
interface ButtonLinkProps {
|
interface ButtonLinkProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
target?: '_self' | '_blank';
|
target?: '_self' | '_blank';
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
/** Background color applied on `:hover` via a scoped `<style>` block
|
||||||
|
* (editor preview does not show hover state -- only the published
|
||||||
|
* export). Blank means "no hover background override". */
|
||||||
|
hoverBg?: string;
|
||||||
|
/** Text color applied on `:hover`, same scoped `<style>` block. */
|
||||||
|
hoverColor?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||||
@@ -15,6 +26,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
href = '#',
|
href = '#',
|
||||||
target = '_self',
|
target = '_self',
|
||||||
style = {},
|
style = {},
|
||||||
|
hoverBg = '',
|
||||||
|
hoverColor = '',
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -23,6 +36,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const [hovered, setHovered] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||||
@@ -32,12 +47,16 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
// Prevent navigation inside editor
|
// Prevent navigation inside editor
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
...style,
|
...style,
|
||||||
|
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
|
||||||
|
...(hovered && hoverColor ? { color: hoverColor } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
@@ -53,6 +72,8 @@ ButtonLink.craft = {
|
|||||||
text: 'Click Me',
|
text: 'Click Me',
|
||||||
href: '#',
|
href: '#',
|
||||||
target: '_self',
|
target: '_self',
|
||||||
|
hoverBg: '',
|
||||||
|
hoverColor: '',
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: '#3b82f6',
|
backgroundColor: '#3b82f6',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
@@ -61,7 +82,15 @@ ButtonLink.craft = {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
},
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -72,7 +101,7 @@ ButtonLink.craft = {
|
|||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const styleStr = cssPropsToString({
|
const styleStr = cssPropsToString({
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -80,7 +109,28 @@ ButtonLink.craft = {
|
|||||||
});
|
});
|
||||||
const escapedText = escapeHtml(props.text || '');
|
const escapedText = escapeHtml(props.text || '');
|
||||||
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
|
||||||
|
// Scoped hover style -- same pattern as Navbar/Menu: a deterministic,
|
||||||
|
// per-node class (via scopeId) avoids two ButtonLink instances on the
|
||||||
|
// same page colliding on a shared `.btn-link:hover` rule. hoverBg/
|
||||||
|
// hoverColor are sanitized through cssValue -- they land inside a
|
||||||
|
// `<style>` element, the worst-case XSS sink (an unescaped `<`/`>` or
|
||||||
|
// `{`/`}` could close the rule/element and open a `<script>`).
|
||||||
|
const hoverBg = cssValue(props.hoverBg);
|
||||||
|
const hoverColor = cssValue(props.hoverColor);
|
||||||
|
let hoverCss = '';
|
||||||
|
let cls = '';
|
||||||
|
if (hoverBg || hoverColor) {
|
||||||
|
const scope = scopeId(nodeId, (props.href || '') + (props.text || ''), 'btn');
|
||||||
|
cls = ` class="${scope}"`;
|
||||||
|
const decls = [
|
||||||
|
hoverBg ? `background-color:${hoverBg}` : '',
|
||||||
|
hoverColor ? `color:${hoverColor}` : '',
|
||||||
|
].filter(Boolean).join(';');
|
||||||
|
hoverCss = `<style>.${scope}:hover{${decls}}</style>`;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
html: `${hoverCss}<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${cls}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,3 +20,14 @@ describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () =>
|
|||||||
expect(html).toContain('© 2026 MySite. All rights reserved.');
|
expect(html).toContain('© 2026 MySite. All rights reserved.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||||
|
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||||
|
const craftProps = (Footer as any).craft.props;
|
||||||
|
expect(craftProps).toHaveProperty('animation', 'none');
|
||||||
|
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||||
|
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
|
|||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Footer: UserComponent<FooterProps> = ({
|
export const Footer: UserComponent<FooterProps> = ({
|
||||||
@@ -92,6 +97,11 @@ Footer.craft = {
|
|||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
padding: '24px 20px',
|
padding: '24px 20px',
|
||||||
},
|
},
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
animation: 'none',
|
||||||
|
animationDelay: '0',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -56,3 +56,54 @@ describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () =>
|
|||||||
expect(html).toBe('<h2>Hello world</h2>');
|
expect(html).toBe('<h2>Hello world</h2>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
||||||
|
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
||||||
|
const { html } = toHtml({
|
||||||
|
text: 'x',
|
||||||
|
level: 'h2',
|
||||||
|
style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||||
|
}, '');
|
||||||
|
expect(html).toContain('line-height:1.25');
|
||||||
|
expect(html).toContain('letter-spacing:0.05em');
|
||||||
|
expect(html).toContain('text-transform:uppercase');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('italic + underline toggles emit font-style and text-decoration', () => {
|
||||||
|
const { html } = toHtml({
|
||||||
|
text: 'x',
|
||||||
|
level: 'h2',
|
||||||
|
style: { fontStyle: 'italic', textDecoration: 'underline' },
|
||||||
|
}, '');
|
||||||
|
expect(html).toContain('font-style:italic');
|
||||||
|
expect(html).toContain('text-decoration:underline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a custom font-size (not one of the presets) still flows through', () => {
|
||||||
|
const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, '');
|
||||||
|
expect(html).toContain('font-size:42px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Heading as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model + typography-depth keys', () => {
|
||||||
|
const style = (Heading as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style).toHaveProperty('lineHeight');
|
||||||
|
expect(style).toHaveProperty('letterSpacing');
|
||||||
|
expect(style).toHaveProperty('textTransform');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,7 +100,22 @@ Heading.craft = {
|
|||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
color: '#1f2937',
|
color: '#1f2937',
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
|
lineHeight: '',
|
||||||
|
letterSpacing: '',
|
||||||
|
textTransform: '' as CSSProperties['textTransform'],
|
||||||
|
fontStyle: '' as CSSProperties['fontStyle'],
|
||||||
|
textDecoration: '',
|
||||||
|
marginTop: '', marginRight: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
},
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useNode: (collect?: (node: any) => any) => {
|
||||||
|
const node = { events: { selected: false } };
|
||||||
|
return {
|
||||||
|
connectors: { connect: (el: any) => el, drag: (el: any) => el },
|
||||||
|
actions: { setProp: vi.fn() },
|
||||||
|
...(collect ? collect(node) : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HtmlBlock } from './HtmlBlock';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
|
||||||
|
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
|
||||||
|
render(
|
||||||
|
React.createElement(HtmlBlock as any, {
|
||||||
|
code: '<p>hello</p>',
|
||||||
|
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.style.backgroundColor).toBe('');
|
||||||
|
expect(wrapper.style.color).toBe('');
|
||||||
|
expect(wrapper.style.padding).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the editor affordances survive: minHeight is kept, content still renders', () => {
|
||||||
|
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.style.minHeight).toBe('40px');
|
||||||
|
expect(wrapper.innerHTML).toContain('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
|
||||||
|
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
|
||||||
|
// copy of the reference acceptance fixture used for Task 24 (widening the
|
||||||
|
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
|
||||||
|
// external fixture used to drive this task so these tests cannot silently
|
||||||
|
// drift from the thing they are supposed to be testing against.
|
||||||
|
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 24: the site owner tested a broad HTML fixture against the shipped
|
||||||
|
* sanitiser config and found 38% of it silently deleted -- merged table
|
||||||
|
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
|
||||||
|
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
|
||||||
|
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
|
||||||
|
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
|
||||||
|
* reference fixture through the *actual* purifyHtml() and assert the
|
||||||
|
* previously-broken constructs now survive with their meaningful
|
||||||
|
* attributes intact, while re-confirming (with attack payloads spliced
|
||||||
|
* into the newly-widened surface -- forms, media, inline svg) that the
|
||||||
|
* four non-negotiable security properties still hold.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
|
||||||
|
test('table merged cells keep colspan/rowspan/scope', () => {
|
||||||
|
expect(out).toContain('<td colspan="2">');
|
||||||
|
expect(out).toContain('<td rowspan="2">');
|
||||||
|
expect(out).toContain('<th scope="col">');
|
||||||
|
expect(out).toContain('<th scope="row">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
|
||||||
|
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('menu list survives with nested buttons', () => {
|
||||||
|
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub/sup survive (was flattened to "H2O")', () => {
|
||||||
|
expect(out).toContain('H<sub>2</sub>O');
|
||||||
|
expect(out).toContain('x<sup>2</sup>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('details/summary survive with the open attribute (was flattened)', () => {
|
||||||
|
expect(out).toContain('<summary>Collapsed disclosure</summary>');
|
||||||
|
expect(out).toContain('<details open="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hgroup survives', () => {
|
||||||
|
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
|
||||||
|
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picture/source with media+srcset survive', () => {
|
||||||
|
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
|
||||||
|
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video/audio survive with source/track children (was deleted entirely)', () => {
|
||||||
|
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
|
||||||
|
expect(out).toContain('<source src="clip.webm" type="video/webm">');
|
||||||
|
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
|
||||||
|
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canvas survives with its fallback text', () => {
|
||||||
|
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
|
||||||
|
expect(out).toContain('<mark>mark</mark>');
|
||||||
|
expect(out).toContain('<small>small</small>');
|
||||||
|
expect(out).toContain('<del>del</del>');
|
||||||
|
expect(out).toContain('<ins>ins</ins>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
|
||||||
|
expect(out).toContain('lang="ar" dir="rtl"');
|
||||||
|
expect(out).toContain('lang="he" dir="rtl"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role attribute preserved alongside aria-* (role was stripped)', () => {
|
||||||
|
expect(out).toMatch(/<nav aria-label="Primary">/);
|
||||||
|
expect(out).toMatch(/role="img"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
|
||||||
|
expect(out).toContain('<ol start="5" reversed="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
|
||||||
|
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
|
||||||
|
expect(out).toContain('<kbd>Ctrl</kbd>');
|
||||||
|
expect(out).toContain('<samp>output text</samp>');
|
||||||
|
expect(out).toContain('<var>variable</var>');
|
||||||
|
expect(out).toContain('<dfn>definition term</dfn>');
|
||||||
|
expect(out).toContain('<address>');
|
||||||
|
expect(out).toContain('<bdi>');
|
||||||
|
expect(out).toContain('<bdo dir="rtl">');
|
||||||
|
expect(out).toContain('<ruby>');
|
||||||
|
expect(out).toContain('<rt>kan</rt>');
|
||||||
|
expect(out).toContain('<time datetime="2026-08-09">');
|
||||||
|
expect(out).toContain('<data value="42">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wbr survives (word-break opportunity)', () => {
|
||||||
|
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden attribute survives', () => {
|
||||||
|
expect(out).toContain('<p hidden="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
|
||||||
|
expect(out).toContain('<form action="#" method="get">');
|
||||||
|
expect(out).toContain('<fieldset>');
|
||||||
|
expect(out).toContain('<legend>Text inputs</legend>');
|
||||||
|
expect(out).toContain('<label for="f-text">Text</label>');
|
||||||
|
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
|
||||||
|
expect(out).toContain('<input id="f-email" type="email" required="">');
|
||||||
|
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
|
||||||
|
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
|
||||||
|
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
|
||||||
|
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
|
||||||
|
expect(out).toContain('<select id="f-select" name="select">');
|
||||||
|
expect(out).toContain('<optgroup label="Group one">');
|
||||||
|
expect(out).toContain('<option value="1" selected="">One</option>');
|
||||||
|
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||||
|
expect(out).toContain('<datalist id="suggestions">');
|
||||||
|
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
|
||||||
|
expect(out).toContain('<output name="result" for="f-num f-range">');
|
||||||
|
expect(out).toContain('<progress id="f-prog" value="0.6">');
|
||||||
|
expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
|
||||||
|
expect(out).toContain('<button type="submit">Submit</button>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
|
||||||
|
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||||
|
expect(out).toContain('low="30" high="80" optimum="90"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
|
||||||
|
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
|
||||||
|
test('style tag never survives', () => {
|
||||||
|
expect(out).not.toMatch(/<style[\s>]/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script tag never survives', () => {
|
||||||
|
expect(out).not.toMatch(/<script[\s>]/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialog/template stay excluded (not in the widened allow-list)', () => {
|
||||||
|
expect(out).not.toContain('<dialog');
|
||||||
|
expect(out).not.toContain('<template');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
|
||||||
|
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
|
||||||
|
// The fixture's dialog/close buttons carry onclick specifically to
|
||||||
|
// prove this; their text content should still come through once the
|
||||||
|
// handler is stripped and (for dialog) the wrapping tag is dropped.
|
||||||
|
expect(out).toContain('Open dialog');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
|
||||||
|
test('script inside a newly-allowed <form> still never survives', () => {
|
||||||
|
const out = purifyHtml('<form><script>alert(1)</script></form>');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* handlers never survive on newly-allowed form controls', () => {
|
||||||
|
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
|
||||||
|
expect(out).not.toMatch(/onfocus/i);
|
||||||
|
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
|
||||||
|
expect(out2).not.toMatch(/onchange/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked in <form action>', () => {
|
||||||
|
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
|
||||||
|
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
|
||||||
|
expect(out).not.toContain('formaction');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
|
||||||
|
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
expect(out).not.toContain('xlink:href');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
|
||||||
|
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: still blocked in plain href alongside the widened surface', () => {
|
||||||
|
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
|
||||||
|
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
|
||||||
|
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||||
|
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
|
||||||
|
expect(sandbox).not.toMatch(/allow-top-navigation/);
|
||||||
|
expect(out).toContain('referrerpolicy="no-referrer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
|
||||||
|
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
|
||||||
|
expect(out).not.toMatch(/onload/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
|
||||||
|
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
|
||||||
|
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
|
||||||
|
// comment), including copies nested inside inline SVG:
|
||||||
|
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
|
||||||
|
// namespace/nesting depth, because CSS itself doesn't respect SVG
|
||||||
|
// subtree boundaries -- an unscoped <style> inside <svg> would still
|
||||||
|
// apply page-wide, so it needs the same scoping as a top-level one.
|
||||||
|
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
|
||||||
|
expect(out).toMatch(/<style/i);
|
||||||
|
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
|
||||||
|
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
|
||||||
|
expect(out).toContain('<rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contenteditable does not smuggle an event handler in alongside it', () => {
|
||||||
|
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
|
||||||
|
expect(out).not.toMatch(/onblur/i);
|
||||||
|
expect(out).toContain('contenteditable="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
|
||||||
|
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
|
||||||
|
expect(out).not.toContain('<dialog');
|
||||||
|
expect(out).not.toMatch(/onclick/i);
|
||||||
|
expect(out).toContain('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
|
||||||
|
// data: is only allow-listed for data:image/*;base64, -- confirm the
|
||||||
|
// regex is not accidentally satisfied by a text/html or bare data:
|
||||||
|
// payload on any of the newly URI-checked attributes.
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
|
||||||
|
'<blockquote cite="data:text/html,x">q</blockquote>' +
|
||||||
|
'<form action="data:text/html,x"></form>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('data:text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
|
||||||
|
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
|
||||||
|
// that gets a trailing `:` appended to every alternative, requiring a
|
||||||
|
// second colon after the one already in "base64," -- which no real
|
||||||
|
// data URI has, so the clause could never match anything. Confirm the
|
||||||
|
// fixed regex actually allows a real base64 image data URI through on
|
||||||
|
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
|
||||||
|
// src ones tested below).
|
||||||
|
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const out = purifyHtml(
|
||||||
|
`<video poster="data:image/png;base64,${b64}"></video>` +
|
||||||
|
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
|
||||||
|
`<a href="data:image/png;base64,${b64}">img</a>`,
|
||||||
|
);
|
||||||
|
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
|
||||||
|
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
|
||||||
|
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
|
||||||
|
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
|
||||||
|
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
|
||||||
|
// image, track) that accepts ANY data: URI on the `src` attribute of
|
||||||
|
// those tags regardless of declared mimetype, before our regex is ever
|
||||||
|
// consulted. Acceptable because none of those tags execute their src
|
||||||
|
// as a document/script context in mainstream browsers -- the sink
|
||||||
|
// doesn't execute. Pinned here so a future DOMPurify version change to
|
||||||
|
// DATA_URI_TAGS shows up as a failing test, not a surprise in
|
||||||
|
// production. <iframe> -- the one tag where this WOULD be dangerous --
|
||||||
|
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
|
||||||
|
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
|
||||||
|
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
|
||||||
|
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
|
||||||
|
|
||||||
|
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
|
||||||
|
expect(iframeOut).not.toContain('data:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
|
||||||
|
test('<select size> survives with its value intact (multi-select row count)', () => {
|
||||||
|
const out = purifyHtml('<select size="4"><option>a</option></select>');
|
||||||
|
expect(out).toContain('size="4"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<input size> survives with its value intact', () => {
|
||||||
|
const out = purifyHtml('<input type="text" size="10">');
|
||||||
|
expect(out).toContain('size="10"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
|
||||||
|
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
|
||||||
|
expect(out).toContain('low="1"');
|
||||||
|
expect(out).toContain('high="9"');
|
||||||
|
expect(out).toContain('optimum="5"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { purifyHtml } from './HtmlBlock';
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
import { stableHash } from '../../utils/escape';
|
||||||
|
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||||
|
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
|
||||||
|
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
|
||||||
|
// immediately before Task 25 -- `git show
|
||||||
|
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
|
||||||
|
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
|
||||||
|
// the "byte-diff against 6a9b227" describe block below -- this is the
|
||||||
|
// literal regression check the Task 25 review asked for, after the first
|
||||||
|
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
|
||||||
|
// silently changing output for a comment-led, style-free fixture.
|
||||||
|
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
|
||||||
|
|
||||||
describe('purifyHtml', () => {
|
describe('purifyHtml', () => {
|
||||||
test('strips script tags', () => {
|
test('strips script tags', () => {
|
||||||
@@ -17,8 +29,47 @@ describe('purifyHtml', () => {
|
|||||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||||
expect(out).toContain('youtube.com/embed/abc');
|
expect(out).toContain('youtube.com/embed/abc');
|
||||||
});
|
});
|
||||||
test('strips form/input', () => {
|
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
|
||||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
|
||||||
|
expect(out).toContain('<form');
|
||||||
|
expect(out).toContain('<input name="x">');
|
||||||
|
expect(out).not.toContain('onfocus');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml markup path (C1 review finding)', () => {
|
||||||
|
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
|
||||||
|
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
|
||||||
|
expect(out).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an id attribute survives sanitization (anchor targets)', () => {
|
||||||
|
const out = purifyHtml('<a href="#section" id="section">link</a>');
|
||||||
|
expect(out).toContain('id="section"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pasted table survives sanitization', () => {
|
||||||
|
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
expect(purifyHtml(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script tags still do not survive alongside a style attribute', () => {
|
||||||
|
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* handlers still do not survive on an element that also carries style', () => {
|
||||||
|
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
|
||||||
|
expect(out).not.toContain('onclick');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: URLs still do not survive on an element that also carries style', () => {
|
||||||
|
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,3 +115,286 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
|
|||||||
expect(out).toContain('<p>hi</p>');
|
expect(out).toContain('<p>hi</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
|
||||||
|
test('a block with no <style> at all is untouched: no wrapper div added', () => {
|
||||||
|
const out = purifyHtml('<p>hello</p>');
|
||||||
|
expect(out).toBe('<p>hello</p>');
|
||||||
|
expect(out).not.toContain('<div');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
|
||||||
|
// Same representative inputs the Task 24 suite already pins to an exact
|
||||||
|
// string -- re-asserted here under the Task 25 name so a future change
|
||||||
|
// that starts wrapping every block (not just style-bearing ones) fails
|
||||||
|
// loudly and obviously, not just as a Task 24 side-effect.
|
||||||
|
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
expect(purifyHtml(table)).toBe(table);
|
||||||
|
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
|
||||||
|
// The fixture is the broadest real-world stand-in this repo has for
|
||||||
|
// "a customer's actual pasted block". It contains no <style>, so this
|
||||||
|
// is the closest thing to a real before/after diff over a large,
|
||||||
|
// realistic input: the only lever Task 25 pulled (allowing <style> +
|
||||||
|
// FORCE_BODY) must produce PRECISELY the same output as before for
|
||||||
|
// content that never touches that lever.
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
|
||||||
|
const out = purifyHtml('<p>hi</p><style></style>');
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whitespace-only <style> does not trigger a wrapper', () => {
|
||||||
|
const out = purifyHtml('<p>hi</p><style> \n </style>');
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
|
||||||
|
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||||
|
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||||
|
expect(out).toContain('<h1>Hi</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
|
||||||
|
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||||
|
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
|
||||||
|
// The bare, unscoped rule must not appear anywhere in the output --
|
||||||
|
// that's exactly the leak this feature exists to close.
|
||||||
|
expect(out).not.toContain('<style>h1 { color: red; }</style>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
|
||||||
|
const code = '<style>p { color: blue; }</style><p>x</p>';
|
||||||
|
const out1 = purifyHtml(code);
|
||||||
|
const out2 = purifyHtml(code);
|
||||||
|
expect(out1).toBe(out2);
|
||||||
|
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(class1).toBe(class2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
|
||||||
|
// If this ever needs to change, it means the hash function itself
|
||||||
|
// changed -- which would silently churn every stored site's HTML on
|
||||||
|
// next save and desync already-published pages from a fresh Preview.
|
||||||
|
// That should be a loud, deliberate decision, not a side-effect of an
|
||||||
|
// unrelated refactor -- hence pinning the literal output here.
|
||||||
|
const code = '<style>h1{color:red}</style>';
|
||||||
|
expect(stableHash(code)).toBe('5fwbyn');
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toContain('class="whp-html-5fwbyn"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
|
||||||
|
// purifyHtml's signature only ever takes the code string -- there is no
|
||||||
|
// node id parameter it could even reach for. This test documents that
|
||||||
|
// invariant so a future refactor threading a node id through here (as
|
||||||
|
// html-export.ts's renderNode already does for OTHER components, see
|
||||||
|
// its `scopeId` comment) doesn't silently get wired into this path too.
|
||||||
|
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||||
|
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||||
|
expect(codeA).toBe(codeB); // sanity: truly identical strings
|
||||||
|
const outA = purifyHtml(codeA);
|
||||||
|
const outB = purifyHtml(codeB);
|
||||||
|
expect(outA).toBe(outB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
|
||||||
|
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
|
||||||
|
// HTML document and serializes only <body>. Per the HTML5 parsing
|
||||||
|
// algorithm, a <style> tag with nothing before it is implicitly placed
|
||||||
|
// in the parser's <head>, which DOMPurify's body-only serialization
|
||||||
|
// never looks at -- the whole block would silently vanish. Confirmed
|
||||||
|
// empirically against dompurify+jsdom directly before this fix existed.
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style>');
|
||||||
|
expect(out).toContain('<style>');
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
|
||||||
|
expect(out).toContain('<h1>Hi</h1>');
|
||||||
|
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
|
||||||
|
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
|
||||||
|
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
|
||||||
|
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
|
||||||
|
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
|
||||||
|
expect(out).not.toContain('@import');
|
||||||
|
expect(out).not.toContain('evil.example');
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
|
||||||
|
);
|
||||||
|
expect(out).toContain('@keyframes spin');
|
||||||
|
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
|
||||||
|
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
|
||||||
|
expect(classes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
|
||||||
|
test('</style> inside a CSS comment cannot break out into executable markup', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).not.toMatch(/on[a-z]+\s*=/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('</style> inside a CSS string cannot break out into executable markup', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
|
||||||
|
);
|
||||||
|
expect(out).not.toMatch(/onclick/i);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframe sandboxing still applies alongside a styled block', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
|
||||||
|
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
|
||||||
|
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
|
||||||
|
'not exploitable there, but not filtered by this pipeline either)',
|
||||||
|
() => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
|
||||||
|
);
|
||||||
|
expect(out).toContain('expression(alert(1))');
|
||||||
|
expect(out).toContain('behavior:url(evil.htc)');
|
||||||
|
expect(out).toContain('-moz-binding:url(evil.xml#x)');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
|
||||||
|
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
|
||||||
|
expect(out).toContain('tracker.example');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
|
||||||
|
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
|
||||||
|
// assertions for the "no <style> => unchanged" guarantee. Those all
|
||||||
|
// passed while FORCE_BODY: true (applied unconditionally at the time)
|
||||||
|
// was silently changing the ACTUAL bytes for any style-free block that
|
||||||
|
// starts with a multi-line HTML comment -- including this repo's own
|
||||||
|
// fixture, which is exactly that shape. `.not.toContain` can't catch an
|
||||||
|
// extra leading newline; only a raw diff against the real old output
|
||||||
|
// can. These tests do that: `preTask25FixtureOutput` is
|
||||||
|
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
|
||||||
|
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
|
||||||
|
// dompurify+jsdom, not re-derived from reading the code -- see that
|
||||||
|
// fixture file's own header comment.
|
||||||
|
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
|
||||||
|
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
|
||||||
|
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
|
||||||
|
// Confirmed independently against 6a9b227's exact code: a multi-line
|
||||||
|
// leading comment followed by blank-line whitespace, with no <style>
|
||||||
|
// anywhere, produces "<p>hi</p>" -- both the comment AND the
|
||||||
|
// whitespace between it and <p> are dropped by the parser's
|
||||||
|
// "before head" insertion-mode rules (unrelated to this task; that's
|
||||||
|
// the pre-existing, unconditional behavior with FORCE_BODY off). The
|
||||||
|
// point of this test is that the NEW code must reproduce that exact
|
||||||
|
// old quirk byte-for-byte for style-free input, not "improve" on it.
|
||||||
|
const commentLed =
|
||||||
|
'<!-- ============================================================\n' +
|
||||||
|
' HTML test fixture header\n' +
|
||||||
|
' ============================================================ -->\n' +
|
||||||
|
'\n<p>hi</p>';
|
||||||
|
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
|
||||||
|
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
|
||||||
|
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
|
||||||
|
function buildDeeplyNestedMedia(count: number): string {
|
||||||
|
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
|
||||||
|
// through the REAL purifyHtml() call, not just scopeCss() in isolation
|
||||||
|
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
|
||||||
|
// not merely in the unit-tested function.
|
||||||
|
let css = 'h1{color:red}';
|
||||||
|
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
|
||||||
|
return `<style>${css}</style><h1>x</h1>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
|
||||||
|
const code = buildDeeplyNestedMedia(7000);
|
||||||
|
expect(() => purifyHtml(code)).not.toThrow();
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toContain('<h1>x</h1>');
|
||||||
|
expect(out).toContain('@media');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
|
||||||
|
const code = buildDeeplyNestedMedia(7000);
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
|
||||||
|
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
|
||||||
|
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||||
|
const once = purifyHtml(code);
|
||||||
|
const twice = purifyHtml(once);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
// Specifically: no second wrapper div, no double-prefixed selector.
|
||||||
|
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent for a block using :root/media too', () => {
|
||||||
|
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
|
||||||
|
const once = purifyHtml(code);
|
||||||
|
const twice = purifyHtml(once);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
|
||||||
|
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
|
||||||
|
const gen1 = purifyHtml(code);
|
||||||
|
const gen2 = purifyHtml(gen1);
|
||||||
|
const gen3 = purifyHtml(gen2);
|
||||||
|
expect(gen3).toBe(gen1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { HtmlBlock } from './HtmlBlock';
|
import { HtmlBlock, purifyHtml } from './HtmlBlock';
|
||||||
|
|
||||||
const toHtml = (HtmlBlock as any).toHtml;
|
const toHtml = (HtmlBlock as any).toHtml;
|
||||||
|
|
||||||
@@ -23,3 +23,47 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
|||||||
expect(html).toBe('<p>hi</p>');
|
expect(html).toBe('<p>hi</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
|
||||||
|
const out = (HtmlBlock as any).toHtml(
|
||||||
|
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(out.html).toBe('<p>hi</p>');
|
||||||
|
expect(out.html).not.toContain('background');
|
||||||
|
expect(out.html).not.toContain('40px');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
||||||
|
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
|
||||||
|
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
|
||||||
|
expect(html).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a table inside `code` reaches exported output', () => {
|
||||||
|
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
|
||||||
|
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
|
||||||
|
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
|
||||||
|
// Published export) both call the exact same purifyHtml(code) -- this
|
||||||
|
// is the byte-parity invariant this project treats as a hard
|
||||||
|
// requirement. Proven here by calling purifyHtml directly (as the
|
||||||
|
// canvas's useMemo does) and toHtml (as export does) on the identical
|
||||||
|
// code string and asserting the two never diverge.
|
||||||
|
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe(purifyHtml(code));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
|
||||||
|
const code = '<p>hello</p>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe('<p>hello</p>');
|
||||||
|
expect(html).not.toContain('<div');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { CSSProperties, useMemo } from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import { stableHash } from '../../utils/escape';
|
||||||
|
import { scopeCss } from '../../utils/scope-css';
|
||||||
|
|
||||||
interface HtmlBlockProps {
|
interface HtmlBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -9,28 +11,207 @@ interface HtmlBlockProps {
|
|||||||
node_id?: string;
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 24: widening the allow-list after a customer's broad HTML fixture
|
||||||
|
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
|
||||||
|
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
|
||||||
|
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
|
||||||
|
// be generous -- this block is an explicit escape hatch and customers
|
||||||
|
// reasonably expect it to render ordinary HTML, including forms. The four
|
||||||
|
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
|
||||||
|
// sandboxed) are unaffected by the widening and are covered by dedicated
|
||||||
|
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
|
||||||
const PURIFY_CONFIG = {
|
const PURIFY_CONFIG = {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'a','p','br','hr','div','span','section','article',
|
'a','p','br','hr','div','span','section','article',
|
||||||
'header','footer','main','aside','nav',
|
'header','footer','main','aside','nav',
|
||||||
'ul','ol','li',
|
'ul','ol','li',
|
||||||
'h1','h2','h3','h4','h5','h6',
|
'h1','h2','h3','h4','h5','h6','hgroup',
|
||||||
'em','strong','b','i','u','s',
|
'em','strong','b','i','u','s',
|
||||||
'blockquote','code','pre',
|
'blockquote','code','pre',
|
||||||
'img','figure','figcaption',
|
'img','figure','figcaption',
|
||||||
'iframe',
|
'iframe',
|
||||||
|
// Tables: pasted content commonly includes these; dropping them
|
||||||
|
// silently ate customer-pasted tables (see C1 review finding).
|
||||||
|
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||||||
|
// Text semantics (Task 24).
|
||||||
|
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
|
||||||
|
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
|
||||||
|
// Lists (Task 24).
|
||||||
|
'dl','dt','dd','menu',
|
||||||
|
// Disclosure widget (Task 24). Note: <dialog> and <template> are
|
||||||
|
// deliberately NOT added -- the fixture exercises them wrapped in
|
||||||
|
// on*= handlers specifically to prove they still get neutralized/
|
||||||
|
// dropped by staying outside the allow-list.
|
||||||
|
'details','summary',
|
||||||
|
// Media (Task 24). URL-bearing attributes on these (poster, srcset,
|
||||||
|
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
|
||||||
|
// everything else -- see _isValidAttribute in dompurify, which
|
||||||
|
// URI-checks every allowed attribute value except a small fixed
|
||||||
|
// "inert" list (alt, class, id, style, title, ...) that never includes
|
||||||
|
// src/poster/srcset. The one exception: `src` itself on img/video/
|
||||||
|
// audio/source/image/track is additionally covered by DOMPurify's own
|
||||||
|
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
|
||||||
|
// tag/attribute pairs regardless of mimetype, bypassing this regex --
|
||||||
|
// see the ALLOWED_URI_REGEXP comment below and
|
||||||
|
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
|
||||||
|
// none of those tags execute their src as a document.
|
||||||
|
'picture','source','video','audio','track','canvas',
|
||||||
|
// Forms (Task 24). Site owner's explicit decision: allow the full
|
||||||
|
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
|
||||||
|
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
|
||||||
|
// same as href/src, so `javascript:` still cannot survive here either.
|
||||||
|
'form','input','button','select','option','optgroup','textarea',
|
||||||
|
'label','fieldset','legend','datalist','output','progress','meter',
|
||||||
|
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
|
||||||
|
// neighbor below for why this is an explicit tag list rather than
|
||||||
|
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
|
||||||
|
// <image> (both need xlink:href, an external-reference vector DOMPurify
|
||||||
|
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
|
||||||
|
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
|
||||||
|
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
|
||||||
|
'line','polyline','polygon','path','text','tspan',
|
||||||
|
'lineargradient','radialgradient','stop','clippath','mask','marker',
|
||||||
|
'pattern','switch','view',
|
||||||
|
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
|
||||||
|
// (stripped entirely). Now allowed through sanitisation -- its CSS is
|
||||||
|
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
|
||||||
|
// DOMPurify runs, so it can only match inside this block's own wrapper
|
||||||
|
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
|
||||||
|
// why allowing the tag alone is not sufficient.
|
||||||
|
//
|
||||||
|
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
|
||||||
|
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
|
||||||
|
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
|
||||||
|
// element -- not just the offending part -- if its text content
|
||||||
|
// contains anything that merely LOOKS tag-like (a `<` followed by a
|
||||||
|
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
|
||||||
|
// that isn't specific to <style>. So `.x::after{content:"<Read
|
||||||
|
// More>"}` -- a plausible, entirely benign real-world CSS content
|
||||||
|
// string -- makes the whole style block vanish with no error, the same
|
||||||
|
// way a `<script>` would. This is a GOOD security property (better
|
||||||
|
// paranoid than exploitable), but it's an undocumented interaction
|
||||||
|
// with this newly-widened surface that will otherwise confuse whoever
|
||||||
|
// debugs the inevitable "my CSS just disappeared" report -- confirmed
|
||||||
|
// empirically against dompurify+jsdom directly, not guessed at.
|
||||||
|
'style',
|
||||||
],
|
],
|
||||||
|
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||||
|
// allowlist rather than extending it, so anything the product needs
|
||||||
|
// (style, id, ...) must be listed explicitly here even though DOMPurify
|
||||||
|
// would allow it by default.
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
'href','src','alt','title','target','rel',
|
'href','src','alt','title','target','rel',
|
||||||
'width','height','class',
|
'width','height','class','id','style',
|
||||||
'allowfullscreen','allow','frameborder',
|
'allowfullscreen','allow','frameborder',
|
||||||
'sandbox','referrerpolicy',
|
'sandbox','referrerpolicy',
|
||||||
|
// Task 24 additions.
|
||||||
|
'colspan','rowspan','scope','headers','span','start','reversed',
|
||||||
|
'type','value','name','placeholder','required','disabled','readonly',
|
||||||
|
'checked','selected','multiple','size','min','max','step','minlength',
|
||||||
|
'maxlength','pattern','rows','cols','accept','action','method','for',
|
||||||
|
'list','label','datetime','cite','lang','dir','role','srcset','media',
|
||||||
|
'sizes','loading','controls','poster','loop','muted','autoplay',
|
||||||
|
'preload','playsinline','kind','srclang','default','open','download',
|
||||||
|
'hidden','contenteditable',
|
||||||
|
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
|
||||||
|
// were still being stripped even though <select>/<meter> are already in
|
||||||
|
// ALLOWED_TAGS -- only these four attribute names were missing here.
|
||||||
|
// Effect: a multi-select rendered at default height instead of the
|
||||||
|
// requested row count, and <meter> lost its threshold-based gauge
|
||||||
|
// colouring. Pure presentation/semantic attributes -- no URL, no
|
||||||
|
// script, no event-handler surface -- so no security weight added.
|
||||||
|
'low','high','optimum',
|
||||||
|
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
|
||||||
|
// comment on the SVG tag list). Covers the fixture's <svg viewBox
|
||||||
|
// role>/<rect>/<circle>/<text> block plus the common presentation
|
||||||
|
// attributes for the shapes/gradients allowed above. Deliberately
|
||||||
|
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
|
||||||
|
// legitimate to attach to) and the SMIL/animation attributes (begin,
|
||||||
|
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
|
||||||
|
// for the same reason on* handlers are excluded.
|
||||||
|
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
|
||||||
|
'points','d','fill','stroke','stroke-width','stroke-linecap',
|
||||||
|
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
|
||||||
|
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
|
||||||
|
'font-family','font-size','font-weight','transform','offset',
|
||||||
|
'stop-color','stop-opacity','gradientunits','gradienttransform',
|
||||||
|
'preserveaspectratio',
|
||||||
],
|
],
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
// Review fix (Task 24 follow-up): the data:image arm used to sit inside
|
||||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
// the group that gets a trailing `:` appended for every alternative
|
||||||
|
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
|
||||||
|
// a SECOND colon after the one already in "base64,figure" -- no real
|
||||||
|
// data URI has that, so the clause could never match. It is now its own
|
||||||
|
// top-level alternative. NOTE: this regex is not the only thing gating
|
||||||
|
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
|
||||||
|
// (img/video/audio/source/image/track) that accepts ANY data: URI on
|
||||||
|
// those tag/attribute pairs regardless of declared mimetype, bypassing
|
||||||
|
// this regex entirely. See HtmlBlock.security.test.ts for a regression
|
||||||
|
// test documenting that (acceptable: none of those tags execute their
|
||||||
|
// src as a document in mainstream browsers, and <iframe> -- which would
|
||||||
|
// be dangerous -- is correctly not in that DOMPurify list).
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
|
||||||
|
// they are now deliberately allowed above. script/object/embed/link/meta
|
||||||
|
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
|
||||||
|
// comment above and scopeStyleBlocks() below; it survives sanitisation
|
||||||
|
// here but its CSS gets scoped afterwards, including copies nested inside
|
||||||
|
// the newly-allowed inline <svg> (querySelectorAll('style') in
|
||||||
|
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
|
||||||
|
FORBID_TAGS: ['script','object','embed','link','meta'],
|
||||||
FORBID_ATTR: [/^on/i],
|
FORBID_ATTR: [/^on/i],
|
||||||
|
// NOTE: FORCE_BODY is deliberately NOT set here -- see
|
||||||
|
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
|
||||||
|
// call, only when the input actually has a real <style> tag to rescue.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
|
||||||
|
// HTML document via DOMParser and only serializes <body>'s contents. Per
|
||||||
|
// the HTML5 parsing algorithm, a tag that can only legally appear in
|
||||||
|
// <head> -- and now that <style> is allowed, that includes <style> --
|
||||||
|
// gets implicitly placed in <head> when it appears before any other real
|
||||||
|
// content, and is silently lost (DOMPurify never looks at <head>). A block
|
||||||
|
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
|
||||||
|
// plausible paste, style-before-markup is a common snippet shape -- would
|
||||||
|
// vanish with no error anywhere, despite <style> sitting right there in
|
||||||
|
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
|
||||||
|
// the parser is already in body-insertion-mode by the time it reaches the
|
||||||
|
// customer's first tag, keeping a leading <style> (or anything else) in
|
||||||
|
// <body> where DOMPurify's body-only serialization actually looks.
|
||||||
|
// Confirmed empirically against dompurify+jsdom directly (not just this
|
||||||
|
// app's behavior) -- see the "leading <style> with nothing before it" test
|
||||||
|
// in HtmlBlock.test.ts.
|
||||||
|
//
|
||||||
|
// Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
|
||||||
|
// that has no <style> tag at all. It also changes how the HTML parser
|
||||||
|
// treats character content sitting between a LEADING comment and the next
|
||||||
|
// real tag -- normal parsing (before <body> is established) silently drops
|
||||||
|
// pure-whitespace text runs there per the HTML5 "before head" insertion
|
||||||
|
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
|
||||||
|
// first token) preserves that whitespace as a real text node. Concretely:
|
||||||
|
// a block starting with a multi-line HTML comment -- this repo's own
|
||||||
|
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
|
||||||
|
// preserved newline) once FORCE_BODY was unconditionally on, which
|
||||||
|
// silently broke the "blocks without <style> are byte-identical to
|
||||||
|
// pre-Task-25 output" guarantee (confirmed with a raw diff against
|
||||||
|
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
|
||||||
|
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
|
||||||
|
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
|
||||||
|
// the one and only case that needs it -- so every other input takes
|
||||||
|
// exactly the pre-Task-25 code path, unchanged.
|
||||||
|
//
|
||||||
|
// "Real" deliberately excludes a `<style` substring that only appears
|
||||||
|
// inside an HTML comment (e.g. a customer's own code-sample text
|
||||||
|
// mentioning `<style>`) -- that text can never become an actual <style>
|
||||||
|
// element, but naively substring-matching it would still flip FORCE_BODY
|
||||||
|
// on and reintroduce the exact same whitespace-preservation side effect
|
||||||
|
// for a block that never had, and never needed, real style scoping.
|
||||||
|
const STYLE_TAG_RE = /<style[\s>/]/i;
|
||||||
|
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
||||||
|
function needsForceBody(input: string): boolean {
|
||||||
|
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
|
||||||
|
}
|
||||||
|
|
||||||
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
||||||
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
||||||
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
||||||
@@ -48,6 +229,120 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
|
||||||
|
* DOMPurify output) so their CSS only matches inside this block's own
|
||||||
|
* wrapper element, then wrap the whole thing in that wrapper.
|
||||||
|
*
|
||||||
|
* Deliberately does the LEAST work possible when there's nothing to scope:
|
||||||
|
* a cheap substring check bails out before touching the DOM at all, so a
|
||||||
|
* block that doesn't use <style> -- i.e. every block saved before this task
|
||||||
|
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
|
||||||
|
* no re-serialization round-trip that could subtly reformat attributes).
|
||||||
|
* That byte-for-byte identity is a hard requirement: published pages
|
||||||
|
* already contain `toHtml()` output with NO wrapper element, and adding one
|
||||||
|
* unconditionally would silently change the DOM/box-model of every
|
||||||
|
* existing customer block. See HtmlBlock.test.ts's
|
||||||
|
* "blocks without <style> are byte-identical" tests, which run this
|
||||||
|
* against real fixture content and diff the exact string.
|
||||||
|
*
|
||||||
|
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
|
||||||
|
* the existing djb2 hash from utils/escape.ts (already used for this exact
|
||||||
|
* class of problem, see `scopeId` in that file), applied to `rawCode` --
|
||||||
|
* the block's own `code` prop, nothing else. Pure function of the block's
|
||||||
|
* own content: no Math.random, no Date.now, no counter, and deliberately
|
||||||
|
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
|
||||||
|
* depends on anything outside `code` would make the editor canvas preview
|
||||||
|
* (which calls purifyHtml(code) on render) and the published output (which
|
||||||
|
* calls the same purifyHtml(code) at publish time) diverge whenever that
|
||||||
|
* outside thing differs between the two call sites, and would make the
|
||||||
|
* stored HTML churn on every save even when the block's own content didn't
|
||||||
|
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
|
||||||
|
* on its own -- same code in, byte-identical output out, every time, in
|
||||||
|
* both places it's called.
|
||||||
|
*/
|
||||||
|
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
|
||||||
|
* reachable-with-its-own-output through any CURRENT code path, but nothing
|
||||||
|
* stops a customer from pasting previously-published or exported HTML from
|
||||||
|
* this exact feature into a fresh Custom HTML block -- at which point
|
||||||
|
* `code` already contains our own `<div class="whp-html-OLD">...<style>
|
||||||
|
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
|
||||||
|
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
|
||||||
|
* to recognise the embedded selectors as already scoped (they're prefixed
|
||||||
|
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
|
||||||
|
* checks against), and nest a second wrapper div around the first while
|
||||||
|
* re-prefixing every selector under the new class on top of the old one.
|
||||||
|
*
|
||||||
|
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
|
||||||
|
* wrappers": a single root element, a <div>, whose class matches our own
|
||||||
|
* naming convention, and whose `<style>` descendant(s) are each already a
|
||||||
|
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
|
||||||
|
* change nothing. That last check reuses `scopeCss`'s own idempotency
|
||||||
|
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
|
||||||
|
* scope-css.test.ts) rather than re-implementing "is this CSS already
|
||||||
|
* scoped" as a second parser: if scoping again under the div's own class
|
||||||
|
* is a no-op, the CSS is already confined to that div, regardless of
|
||||||
|
* whether this app was the one that put it there -- which is the actual
|
||||||
|
* safety property this function exists to guarantee, not merely a proxy
|
||||||
|
* for it.
|
||||||
|
*/
|
||||||
|
function isAlreadyScoped(container: HTMLElement): boolean {
|
||||||
|
if (container.children.length !== 1) return false;
|
||||||
|
const root = container.children[0];
|
||||||
|
if (root.tagName !== 'DIV') return false;
|
||||||
|
const cls = root.getAttribute('class') || '';
|
||||||
|
if (!SCOPE_CLASS_RE.test(cls)) return false;
|
||||||
|
|
||||||
|
const scopeSelector = `.${cls}`;
|
||||||
|
const styleEls = Array.from(root.querySelectorAll('style'));
|
||||||
|
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
|
||||||
|
|
||||||
|
return styleEls.every((el) => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
if (text.trim() === '') return true;
|
||||||
|
return scopeCss(text, scopeSelector) === text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
|
||||||
|
if (!sanitized.includes('<style')) return sanitized;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = sanitized;
|
||||||
|
|
||||||
|
if (isAlreadyScoped(container)) return sanitized;
|
||||||
|
|
||||||
|
const styleEls = Array.from(container.querySelectorAll('style'));
|
||||||
|
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
|
||||||
|
if (nonEmpty.length === 0) return sanitized;
|
||||||
|
|
||||||
|
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
|
||||||
|
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
|
||||||
|
// could deliberately craft a second block's `code` to collide onto the
|
||||||
|
// same `whp-html-<hash>` class as an existing block on the same page, at
|
||||||
|
// which point the two blocks' <style> rules apply to (and override) each
|
||||||
|
// other, since they'd share one wrapper class. Impact is CSS-only --
|
||||||
|
// visual breakage, never script execution or data exposure -- the same
|
||||||
|
// trust tier as other accepted risks in this file (e.g. remote url() in
|
||||||
|
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
|
||||||
|
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
|
||||||
|
// would mean either a wider hash (cheap, but every existing scope class
|
||||||
|
// set with THIS Task 25 code would silently reshuffle -- a similar
|
||||||
|
// "changing the hash function reshuffles stored HTML" cost the pinned
|
||||||
|
// hash test above already guards against happening BY ACCIDENT) or a
|
||||||
|
// collision-checked/salted scheme, either of which is a bigger design
|
||||||
|
// decision than a follow-up-review fix.
|
||||||
|
const scopeClass = `whp-html-${stableHash(rawCode)}`;
|
||||||
|
for (const el of nonEmpty) {
|
||||||
|
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function purifyHtml(input: string): string {
|
export function purifyHtml(input: string): string {
|
||||||
// Hook is added immediately before sanitize() and removed immediately
|
// Hook is added immediately before sanitize() and removed immediately
|
||||||
// after, scoped tightly to this single call -- so it can never leak onto
|
// after, scoped tightly to this single call -- so it can never leak onto
|
||||||
@@ -56,22 +351,33 @@ export function purifyHtml(input: string): string {
|
|||||||
// multiple copies of the same hook.
|
// multiple copies of the same hook.
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
||||||
try {
|
try {
|
||||||
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
const raw = input || '';
|
||||||
|
// See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
|
||||||
|
// applied only when there's a real <style> tag to rescue, so every
|
||||||
|
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
|
||||||
|
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
|
||||||
|
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
|
||||||
|
return scopeStyleBlocks(sanitized, raw);
|
||||||
} finally {
|
} finally {
|
||||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
|
||||||
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||||
const clean = useMemo(() => purifyHtml(code), [code]);
|
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||||
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||||
|
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
|
||||||
|
// purified `code`, so anything styled here would show in the editor and
|
||||||
|
// vanish on the live page -- the exact bug this block was reported for.
|
||||||
|
// Wrapper styling belongs in the user's own markup (see the Edit HTML
|
||||||
|
// toolbar's colour control). `style` stays on the props interface so
|
||||||
|
// already-saved sites keep deserializing cleanly.
|
||||||
return React.createElement('div', {
|
return React.createElement('div', {
|
||||||
ref: setRef,
|
ref: setRef,
|
||||||
style: {
|
style: {
|
||||||
minHeight: '40px',
|
minHeight: '40px',
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
...style,
|
|
||||||
},
|
},
|
||||||
dangerouslySetInnerHTML: { __html: clean },
|
dangerouslySetInnerHTML: { __html: clean },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,3 +44,29 @@ describe('Icon.toHtml XSS hardening', () => {
|
|||||||
expect(html).not.toMatch(/"\s+onclick="/);
|
expect(html).not.toMatch(/"\s+onclick="/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Icon.toHtml bgShape/bgColor/bgSize rendering (built but, until this panel update, unexposed)', () => {
|
||||||
|
test('bgShape="circle" + bgColor render a colored 50%-radius background box', () => {
|
||||||
|
const { html } = toHtml({ icon: 'fa-star', bgShape: 'circle', bgColor: '#3b82f6', bgSize: '64px' }, '');
|
||||||
|
expect(html).toContain('background-color:#3b82f6');
|
||||||
|
expect(html).toContain('border-radius:50%');
|
||||||
|
expect(html).toContain('width:64px');
|
||||||
|
expect(html).toContain('height:64px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bgShape="none" (default) renders the bare icon with no background wrapper', () => {
|
||||||
|
const { html } = toHtml({ icon: 'fa-star' }, '');
|
||||||
|
expect(html).not.toContain('background-color');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (Icon as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ interface IconProps {
|
|||||||
bgSize?: string;
|
bgSize?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBgBorderRadius(shape: string): string {
|
function getBgBorderRadius(shape: string): string {
|
||||||
@@ -96,6 +101,11 @@ Icon.craft = {
|
|||||||
bgSize: '56px',
|
bgSize: '56px',
|
||||||
link: '',
|
link: '',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||||
|
test('download:true emits the download attribute', () => {
|
||||||
|
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
|
||||||
|
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no download prop -> no download attribute emitted', () => {
|
||||||
|
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
|
||||||
|
expect(html).not.toContain('download');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||||
|
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||||
|
const craftProps = (Logo as any).craft.props;
|
||||||
|
expect(craftProps).toHaveProperty('animation', 'none');
|
||||||
|
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||||
|
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Logo.toHtml text-logo styling sanitization', () => {
|
describe('Logo.toHtml text-logo styling sanitization', () => {
|
||||||
test('a quote-breakout color does not escape the span style attribute', () => {
|
test('a quote-breakout color does not escape the span style attribute', () => {
|
||||||
const malicious = 'red" onmouseover="alert(1)';
|
const malicious = 'red" onmouseover="alert(1)';
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ interface LogoProps {
|
|||||||
imageSrc?: string;
|
imageSrc?: string;
|
||||||
imageWidth?: string;
|
imageWidth?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
|
||||||
|
download?: boolean;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize?: string;
|
fontSize?: string;
|
||||||
fontWeight?: string;
|
fontWeight?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Component ---------- */
|
/* ---------- Component ---------- */
|
||||||
@@ -27,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
|||||||
imageSrc = '',
|
imageSrc = '',
|
||||||
imageWidth = '120px',
|
imageWidth = '120px',
|
||||||
href = '/',
|
href = '/',
|
||||||
|
download = false,
|
||||||
fontFamily = 'Inter, sans-serif',
|
fontFamily = 'Inter, sans-serif',
|
||||||
fontSize = '20px',
|
fontSize = '20px',
|
||||||
fontWeight = '700',
|
fontWeight = '700',
|
||||||
@@ -44,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
|||||||
<a
|
<a
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
href={href}
|
href={href}
|
||||||
|
download={download || undefined}
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -87,7 +96,13 @@ Logo.craft = {
|
|||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: undefined,
|
color: undefined,
|
||||||
|
download: false,
|
||||||
style: {},
|
style: {},
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
animation: 'none',
|
||||||
|
animationDelay: '0',
|
||||||
} as LogoProps,
|
} as LogoProps,
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -122,8 +137,9 @@ Logo.craft = {
|
|||||||
flexShrink: '0',
|
flexShrink: '0',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const downloadAttr = props.download ? ' download' : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||||
|
test('a link with download:true emits the download attribute', () => {
|
||||||
|
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||||
|
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link without download does not emit the attribute', () => {
|
||||||
|
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||||
|
expect(html).not.toContain(' download');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||||
|
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||||
|
const craftProps = (Menu as any).craft.props;
|
||||||
|
expect(craftProps).toHaveProperty('animation', 'none');
|
||||||
|
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||||
|
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||||
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
test('a linkHoverColor value containing </style><script> is neutralized', () => {
|
||||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface MenuLink {
|
|||||||
href: string;
|
href: string;
|
||||||
isExternal?: boolean;
|
isExternal?: boolean;
|
||||||
isCta?: boolean;
|
isCta?: boolean;
|
||||||
|
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||||
|
download?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
@@ -23,6 +25,11 @@ interface MenuProps {
|
|||||||
orientation?: 'horizontal' | 'vertical';
|
orientation?: 'horizontal' | 'vertical';
|
||||||
fontSize?: string;
|
fontSize?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Defaults ---------- */
|
/* ---------- Defaults ---------- */
|
||||||
@@ -75,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.isExternal ? '_blank' : undefined}
|
target={link.isExternal ? '_blank' : undefined}
|
||||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||||
|
download={link.download || undefined}
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
onMouseEnter={() => setHoveredLink(i)}
|
onMouseEnter={() => setHoveredLink(i)}
|
||||||
onMouseLeave={() => setHoveredLink(null)}
|
onMouseLeave={() => setHoveredLink(null)}
|
||||||
@@ -114,6 +122,11 @@ Menu.craft = {
|
|||||||
orientation: 'horizontal',
|
orientation: 'horizontal',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
style: {},
|
style: {},
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
animation: 'none',
|
||||||
|
animationDelay: '0',
|
||||||
} as MenuProps,
|
} as MenuProps,
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -162,6 +175,7 @@ Menu.craft = {
|
|||||||
|
|
||||||
const linksHtml = links.map((link) => {
|
const linksHtml = links.map((link) => {
|
||||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
const downloadAttr = link.download ? ' download' : '';
|
||||||
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||||
const linkStyle = cssPropsToString({
|
const linkStyle = cssPropsToString({
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -173,7 +187,7 @@ Menu.craft = {
|
|||||||
borderRadius: link.isCta ? '6px' : '0',
|
borderRadius: link.isCta ? '6px' : '0',
|
||||||
transition: 'color 0.15s, background-color 0.15s',
|
transition: 'color 0.15s, background-color 0.15s',
|
||||||
});
|
});
|
||||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
const hoverCss = `<style>
|
const hoverCss = `<style>
|
||||||
|
|||||||
@@ -83,6 +83,29 @@ describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
|
||||||
|
test('a link with download:true emits the download attribute', () => {
|
||||||
|
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
|
||||||
|
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a link without download does not emit the attribute', () => {
|
||||||
|
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
|
||||||
|
expect(html).not.toContain(' download');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
|
||||||
|
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
|
||||||
|
const craftProps = (Navbar as any).craft.props;
|
||||||
|
expect(craftProps).toHaveProperty('animation', 'none');
|
||||||
|
expect(craftProps).toHaveProperty('animationDelay', '0');
|
||||||
|
expect(craftProps).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(craftProps).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
|
||||||
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
|
||||||
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
const malicious = '#fff}</style><script>alert(1)</script><style>{';
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface NavLink {
|
|||||||
href: string;
|
href: string;
|
||||||
isExternal?: boolean;
|
isExternal?: boolean;
|
||||||
isCta?: boolean;
|
isCta?: boolean;
|
||||||
|
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||||
|
download?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
@@ -33,6 +35,11 @@ interface NavbarProps {
|
|||||||
isSticky?: boolean;
|
isSticky?: boolean;
|
||||||
showMobileMenu?: boolean;
|
showMobileMenu?: boolean;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Defaults ---------- */
|
/* ---------- Defaults ---------- */
|
||||||
@@ -149,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.isExternal ? '_blank' : undefined}
|
target={link.isExternal ? '_blank' : undefined}
|
||||||
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
rel={link.isExternal ? 'noopener noreferrer' : undefined}
|
||||||
|
download={link.download || undefined}
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => e.preventDefault()}
|
||||||
onMouseEnter={() => setHoveredLink(i)}
|
onMouseEnter={() => setHoveredLink(i)}
|
||||||
onMouseLeave={() => setHoveredLink(null)}
|
onMouseLeave={() => setHoveredLink(null)}
|
||||||
@@ -200,6 +208,11 @@ Navbar.craft = {
|
|||||||
style: {
|
style: {
|
||||||
borderBottom: '1px solid #e4e4e7',
|
borderBottom: '1px solid #e4e4e7',
|
||||||
},
|
},
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
animation: 'none',
|
||||||
|
animationDelay: '0',
|
||||||
} as NavbarProps,
|
} as NavbarProps,
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -308,6 +321,7 @@ Navbar.craft = {
|
|||||||
// Add CSS class to each link for hover
|
// Add CSS class to each link for hover
|
||||||
const linksHtmlWithClass = links.map((link) => {
|
const linksHtmlWithClass = links.map((link) => {
|
||||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
const downloadAttr = link.download ? ' download' : '';
|
||||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||||
const linkStyle = cssPropsToString({
|
const linkStyle = cssPropsToString({
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -319,7 +333,7 @@ Navbar.craft = {
|
|||||||
borderRadius: link.isCta ? '6px' : '0',
|
borderRadius: link.isCta ? '6px' : '0',
|
||||||
transition: 'color 0.15s, background-color 0.15s',
|
transition: 'color 0.15s, background-color 0.15s',
|
||||||
});
|
});
|
||||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,3 +30,46 @@ describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', (
|
|||||||
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
|
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F2: SearchBar was purely decorative -- no action/method/input name, so
|
||||||
|
// submitting did nothing. It now emits a real GET form.
|
||||||
|
describe('SearchBar.toHtml is a functional GET search form (not decorative)', () => {
|
||||||
|
test('defaults to a GET form action="/" with the query input named "q"', () => {
|
||||||
|
const { html } = toHtml({}, '');
|
||||||
|
expect(html).toMatch(/<form role="search" action="\/" method="GET"/);
|
||||||
|
expect(html).toContain('<input type="search" name="q"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a configured action (real search-results page) is used verbatim', () => {
|
||||||
|
const { html } = toHtml({ action: '/search' }, '');
|
||||||
|
expect(html).toContain('action="/search"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a javascript: action is blocked via safeUrl and falls back to "/"', () => {
|
||||||
|
const { html } = toHtml({ action: 'javascript:alert(1)' }, '');
|
||||||
|
expect(html).toContain('action="/"');
|
||||||
|
expect(html).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty/whitespace action falls back to "/"', () => {
|
||||||
|
const { html } = toHtml({ action: ' ' }, '');
|
||||||
|
expect(html).toContain('action="/"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchBar.toHtml box-model style passthrough', () => {
|
||||||
|
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ style: { marginBottom: '14px', border: '1px solid #aaa', boxShadow: '0 1px 4px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
|
||||||
|
expect(html).toContain('margin-bottom:14px');
|
||||||
|
expect(html).toContain('border:1px solid #aaa');
|
||||||
|
expect(html).toContain('opacity:0.9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SearchBar.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(SearchBar.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
showButton?: boolean;
|
showButton?: boolean;
|
||||||
|
/** Where the search GET request is submitted -- a real search-results page
|
||||||
|
* if the site has one, or '/' (site root) by default. The query is sent
|
||||||
|
* as `?q=...`, the conventional param name search-results pages look for. */
|
||||||
|
action?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchBar: UserComponent<SearchBarProps> = ({
|
export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
buttonText = 'Search',
|
buttonText = 'Search',
|
||||||
showButton = true,
|
showButton = true,
|
||||||
|
action = '/',
|
||||||
style = {},
|
style = {},
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +37,8 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
|||||||
<form
|
<form
|
||||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
role="search"
|
role="search"
|
||||||
|
action={action}
|
||||||
|
method="GET"
|
||||||
onSubmit={(e) => e.preventDefault()}
|
onSubmit={(e) => e.preventDefault()}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -51,6 +63,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
name="q"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -101,7 +114,13 @@ SearchBar.craft = {
|
|||||||
placeholder: 'Search...',
|
placeholder: 'Search...',
|
||||||
buttonText: 'Search',
|
buttonText: 'Search',
|
||||||
showButton: true,
|
showButton: true,
|
||||||
|
action: '/',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -117,6 +136,7 @@ SearchBar.craft = {
|
|||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
buttonText = 'Search',
|
buttonText = 'Search',
|
||||||
showButton = true,
|
showButton = true,
|
||||||
|
action = '/',
|
||||||
style = {},
|
style = {},
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -133,11 +153,19 @@ SearchBar.craft = {
|
|||||||
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// F2: previously a purely decorative <form> -- no action/method/input
|
||||||
|
// name at all, so submitting did nothing. A real GET to `action` with the
|
||||||
|
// query in the conventional `q` param makes this a functioning search
|
||||||
|
// form on publish (routes to a real search-results page if the site has
|
||||||
|
// one, or reloads '/' with ?q=... by default). `safeUrl` blocks
|
||||||
|
// javascript:/vbscript:/data:text/html breakout via the action attribute.
|
||||||
|
const actionAttr = escapeAttr(safeUrl(action) || '/');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
<div style="position:relative;flex:1">
|
<div style="position:relative;flex:1">
|
||||||
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
|
||||||
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||||
</div>
|
</div>
|
||||||
${btnHtml}
|
${btnHtml}
|
||||||
</form>`,
|
</form>`,
|
||||||
|
|||||||
@@ -51,3 +51,43 @@ describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap i
|
|||||||
expect(html).not.toContain('javascript:alert(1)');
|
expect(html).not.toContain('javascript:alert(1)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SocialLinks.toHtml iconShape/gap emission (previously built but unexposed in SocialStylePanel)', () => {
|
||||||
|
test('iconShape="circle" emits a 50% border-radius on the wrapping <a>', () => {
|
||||||
|
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle' }, '');
|
||||||
|
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:50%[^"]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iconShape="square" emits border-radius:0', () => {
|
||||||
|
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'square' }, '');
|
||||||
|
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:0[^"]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iconShape="rounded" emits a 6px border-radius', () => {
|
||||||
|
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'rounded' }, '');
|
||||||
|
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:6px[^"]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iconShape="none" omits the background box entirely (transparent bg, no fixed box size)', () => {
|
||||||
|
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'none' }, '');
|
||||||
|
const aTag = html.match(/<a[^>]*>/)![0];
|
||||||
|
expect(aTag).toContain('background-color:transparent');
|
||||||
|
expect(aTag).not.toContain('border-radius');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gap emits on the wrapper <div> style', () => {
|
||||||
|
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: '24px' }, '');
|
||||||
|
expect(html).toMatch(/<div[^>]*style="[^"]*gap:24px[^"]*"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SocialLinks.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (SocialLinks as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ interface SocialLinksProps {
|
|||||||
gap?: string;
|
gap?: string;
|
||||||
alignment?: 'left' | 'center' | 'right';
|
alignment?: 'left' | 'center' | 'right';
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformIcons: Record<string, string> = {
|
const platformIcons: Record<string, string> = {
|
||||||
@@ -151,6 +156,11 @@ SocialLinks.craft = {
|
|||||||
gap: '10px',
|
gap: '10px',
|
||||||
alignment: 'center',
|
alignment: 'center',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -75,3 +75,14 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
|
|||||||
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('StarRating.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (StarRating as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface StarRatingProps {
|
|||||||
filledColor?: string;
|
filledColor?: string;
|
||||||
emptyColor?: string;
|
emptyColor?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StarRating: UserComponent<StarRatingProps> = ({
|
export const StarRating: UserComponent<StarRatingProps> = ({
|
||||||
@@ -87,6 +92,11 @@ StarRating.craft = {
|
|||||||
filledColor: '#f59e0b',
|
filledColor: '#f59e0b',
|
||||||
emptyColor: '#d1d5db',
|
emptyColor: '#d1d5db',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -20,3 +20,48 @@ describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', ()
|
|||||||
expect(html).toBe('<p>Hello world</p>');
|
expect(html).toBe('<p>Hello world</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
||||||
|
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
||||||
|
const { html } = toHtml({
|
||||||
|
text: 'x',
|
||||||
|
style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' },
|
||||||
|
}, '');
|
||||||
|
expect(html).toContain('line-height:1.75');
|
||||||
|
expect(html).toContain('letter-spacing:-0.02em');
|
||||||
|
expect(html).toContain('text-transform:capitalize');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('italic + underline toggles emit font-style and text-decoration', () => {
|
||||||
|
const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, '');
|
||||||
|
expect(html).toContain('font-style:italic');
|
||||||
|
expect(html).toContain('text-decoration:underline');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a custom font-size (not one of the presets) still flows through', () => {
|
||||||
|
const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, '');
|
||||||
|
expect(html).toContain('font-size:19px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (TextBlock as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model + typography-depth keys', () => {
|
||||||
|
const style = (TextBlock as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style).toHaveProperty('letterSpacing');
|
||||||
|
expect(style).toHaveProperty('textTransform');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -83,7 +83,21 @@ TextBlock.craft = {
|
|||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
color: '#3f3f46',
|
color: '#3f3f46',
|
||||||
|
letterSpacing: '',
|
||||||
|
textTransform: '' as CSSProperties['textTransform'],
|
||||||
|
fontStyle: '' as CSSProperties['fontStyle'],
|
||||||
|
textDecoration: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
},
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<!-- ============================================================
|
||||||
|
HTML test fixture — everything below goes inside <body>
|
||||||
|
Unstyled on purpose. No external assets (SVG/data URIs only)
|
||||||
|
except the media/iframe block, which is intentionally broken
|
||||||
|
so you can see fallback behavior.
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<a href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>HTML Test Fixture</h1>
|
||||||
|
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#text">Text</a></li>
|
||||||
|
<li><a href="#lists">Lists</a></li>
|
||||||
|
<li><a href="#tables">Tables</a></li>
|
||||||
|
<li><a href="#forms">Forms</a></li>
|
||||||
|
<li><a href="#media">Media</a></li>
|
||||||
|
<li><a href="#edge">Edge cases</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<!-- ========== HEADINGS ========== -->
|
||||||
|
<section id="headings">
|
||||||
|
<h2>Headings</h2>
|
||||||
|
<h1>Heading level 1</h1>
|
||||||
|
<h2>Heading level 2</h2>
|
||||||
|
<h3>Heading level 3</h3>
|
||||||
|
<h4>Heading level 4</h4>
|
||||||
|
<h5>Heading level 5</h5>
|
||||||
|
<h6>Heading level 6</h6>
|
||||||
|
<hgroup>
|
||||||
|
<h2>Grouped heading</h2>
|
||||||
|
<p>Subtitle paragraph inside hgroup</p>
|
||||||
|
</hgroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== TEXT & INLINE ========== -->
|
||||||
|
<section id="text">
|
||||||
|
<h2>Text and inline elements</h2>
|
||||||
|
|
||||||
|
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||||
|
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||||
|
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||||
|
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||||
|
<samp>output text</samp>, <var>variable</var>,
|
||||||
|
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||||
|
<dfn>definition term</dfn>,
|
||||||
|
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||||
|
<data value="42">forty-two</data>,
|
||||||
|
<q>short inline quote</q>,
|
||||||
|
<cite>Cited Work</cite>,
|
||||||
|
<bdi>إسم</bdi>,
|
||||||
|
<bdo dir="rtl">reversed direction</bdo>,
|
||||||
|
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Links:
|
||||||
|
<a href="#top">internal anchor</a> ·
|
||||||
|
<a href="https://example.com">absolute</a> ·
|
||||||
|
<a href="/relative/path">relative</a> ·
|
||||||
|
<a href="mailto:test@example.com">mailto</a> ·
|
||||||
|
<a href="tel:+15555550123">tel</a> ·
|
||||||
|
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||||
|
<a href="#" download>download attr</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote cite="https://example.com/source">
|
||||||
|
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||||
|
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||||
|
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<pre><code>#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for i in {1..3}; do
|
||||||
|
printf 'iteration %d\n' "$i"
|
||||||
|
done
|
||||||
|
|
||||||
|
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Line break here,<br>after the break.</p>
|
||||||
|
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||||
|
123 Nowhere St, Somewhere
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== LISTS ========== -->
|
||||||
|
<section id="lists">
|
||||||
|
<h2>Lists</h2>
|
||||||
|
|
||||||
|
<h3>Unordered, nested</h3>
|
||||||
|
<ul>
|
||||||
|
<li>First item</li>
|
||||||
|
<li>Second item
|
||||||
|
<ul>
|
||||||
|
<li>Nested item
|
||||||
|
<ul><li>Deeply nested item</li></ul>
|
||||||
|
</li>
|
||||||
|
<li>Another nested item</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Ordered variants</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Default numbering</li>
|
||||||
|
<li>Second
|
||||||
|
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<ol start="5" reversed>
|
||||||
|
<li>Reversed, starting at 5</li>
|
||||||
|
<li>Next</li>
|
||||||
|
<li>Next</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Description list</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Term one</dt>
|
||||||
|
<dd>Definition of the first term.</dd>
|
||||||
|
<dt>Term two</dt>
|
||||||
|
<dt>Term two, alias</dt>
|
||||||
|
<dd>Definition covering both terms above.</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Menu</h3>
|
||||||
|
<menu>
|
||||||
|
<li><button type="button">Copy</button></li>
|
||||||
|
<li><button type="button">Paste</button></li>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== TABLES ========== -->
|
||||||
|
<section id="tables">
|
||||||
|
<h2>Tables</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>Quarterly figures with spans and a footer</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col span="1">
|
||||||
|
<col span="2">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Region</th>
|
||||||
|
<th scope="col">Q1</th>
|
||||||
|
<th scope="col">Q2</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">North</th>
|
||||||
|
<td>1,204</td>
|
||||||
|
<td>1,391</td>
|
||||||
|
<td rowspan="2">Shared note spanning two rows</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">South</th>
|
||||||
|
<td>988</td>
|
||||||
|
<td>1,022</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">East</th>
|
||||||
|
<td colspan="2">Merged across two quarters</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total</th>
|
||||||
|
<td>2,192</td>
|
||||||
|
<td>2,413</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Wide table (horizontal overflow)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||||
|
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== FORMS ========== -->
|
||||||
|
<section id="forms">
|
||||||
|
<h2>Forms</h2>
|
||||||
|
|
||||||
|
<form action="#" method="get">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Text inputs</legend>
|
||||||
|
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||||
|
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||||
|
<datalist id="suggestions">
|
||||||
|
<option value="alpha"></option>
|
||||||
|
<option value="beta"></option>
|
||||||
|
<option value="gamma"></option>
|
||||||
|
</datalist>
|
||||||
|
<p><label for="f-email">Email</label> <input id="f-email" type="email" required></p>
|
||||||
|
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||||
|
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||||
|
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||||
|
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||||
|
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||||
|
content
|
||||||
|
here</textarea></p>
|
||||||
|
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly></p>
|
||||||
|
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Date, time, color, range, file</legend>
|
||||||
|
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||||
|
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||||
|
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||||
|
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||||
|
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||||
|
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||||
|
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||||
|
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple accept=".txt,.md"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Choices</legend>
|
||||||
|
<p>
|
||||||
|
<label><input type="checkbox" name="c" value="1" checked> Checked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="3" disabled> Disabled</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><input type="radio" name="r" value="a" checked> Option A</label>
|
||||||
|
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-select">Select</label>
|
||||||
|
<select id="f-select" name="select">
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
<optgroup label="Group one">
|
||||||
|
<option value="1" selected>One</option>
|
||||||
|
<option value="2">Two</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Group two" disabled>
|
||||||
|
<option value="3">Three</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-multi">Multi-select</label><br>
|
||||||
|
<select id="f-multi" multiple size="4">
|
||||||
|
<option>Red</option><option selected>Green</option><option>Blue</option><option>Violet</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Output and buttons</legend>
|
||||||
|
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||||
|
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||||
|
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
<button type="reset">Reset</button>
|
||||||
|
<button type="button">Plain button</button>
|
||||||
|
<button type="button" disabled>Disabled button</button>
|
||||||
|
<input type="submit" value="Input submit">
|
||||||
|
<input type="button" value="Input button">
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="csrf" value="hidden-value">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== MEDIA & EMBEDS ========== -->
|
||||||
|
<section id="media">
|
||||||
|
<h2>Media and embeds</h2>
|
||||||
|
|
||||||
|
<h3>Inline SVG</h3>
|
||||||
|
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||||
|
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3>Figure with data-URI image</h3>
|
||||||
|
<figure>
|
||||||
|
<img alt="Small red square"
|
||||||
|
width="64" height="64"
|
||||||
|
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||||
|
<figcaption>Figure caption describing the image above.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3>Broken image (alt-text fallback test)</h3>
|
||||||
|
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||||
|
|
||||||
|
<h3>Picture element</h3>
|
||||||
|
<picture>
|
||||||
|
<source media="(min-width: 800px)" srcset="wide.png">
|
||||||
|
<source media="(min-width: 400px)" srcset="medium.png">
|
||||||
|
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<h3>Video and audio (sources intentionally missing)</h3>
|
||||||
|
<video controls width="320" poster="poster.jpg">
|
||||||
|
<source src="clip.webm" type="video/webm">
|
||||||
|
<source src="clip.mp4" type="video/mp4">
|
||||||
|
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
<audio controls>
|
||||||
|
<source src="tone.ogg" type="audio/ogg">
|
||||||
|
<source src="tone.mp3" type="audio/mpeg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<h3>Canvas and iframe</h3>
|
||||||
|
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||||
|
<iframe title="Sandboxed iframe" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
|
||||||
|
<section id="interactive">
|
||||||
|
<h2>Interactive and semantic containers</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Collapsed disclosure</summary>
|
||||||
|
<p>Hidden content revealed on toggle.</p>
|
||||||
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>Open disclosure</summary>
|
||||||
|
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<dialog id="test-dialog">
|
||||||
|
<p>Non-modal dialog content.</p>
|
||||||
|
<button type="button" onclick="this.closest('dialog').close()">Close</button>
|
||||||
|
</dialog>
|
||||||
|
<button type="button" onclick="document.getElementById('test-dialog').show()">Open dialog</button>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header><h3>Article header</h3></header>
|
||||||
|
<p>Article body content.</p>
|
||||||
|
<aside><p>An aside nested inside the article.</p></aside>
|
||||||
|
<footer><p>Article footer.</p></footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p><span contenteditable="true">Editable inline region</span></p>
|
||||||
|
<p hidden>This paragraph has the hidden attribute and should not render.</p>
|
||||||
|
|
||||||
|
<template id="tpl">
|
||||||
|
<p>Template content — must not render until cloned.</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== EDGE CASES ========== -->
|
||||||
|
<section id="edge">
|
||||||
|
<h2>Edge cases</h2>
|
||||||
|
|
||||||
|
<p>Very long unbroken token (overflow test):</p>
|
||||||
|
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||||
|
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||||
|
|
||||||
|
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||||
|
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||||
|
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||||
|
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||||
|
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||||
|
<p>Zero-width chars between letters: a​b​c</p>
|
||||||
|
|
||||||
|
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||||
|
<p>Attribute with quotes: <span title='He said "hello"'>hover me</span></p>
|
||||||
|
|
||||||
|
<p>Empty elements follow:</p>
|
||||||
|
<div></div>
|
||||||
|
<p></p>
|
||||||
|
<ul></ul>
|
||||||
|
<table></table>
|
||||||
|
|
||||||
|
<p>Deep nesting:</p>
|
||||||
|
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||||
|
|
||||||
|
<p>Inline element stress:
|
||||||
|
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||||
|
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<a href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>HTML Test Fixture</h1>
|
||||||
|
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#text">Text</a></li>
|
||||||
|
<li><a href="#lists">Lists</a></li>
|
||||||
|
<li><a href="#tables">Tables</a></li>
|
||||||
|
<li><a href="#forms">Forms</a></li>
|
||||||
|
<li><a href="#media">Media</a></li>
|
||||||
|
<li><a href="#edge">Edge cases</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
|
||||||
|
<section id="headings">
|
||||||
|
<h2>Headings</h2>
|
||||||
|
<h1>Heading level 1</h1>
|
||||||
|
<h2>Heading level 2</h2>
|
||||||
|
<h3>Heading level 3</h3>
|
||||||
|
<h4>Heading level 4</h4>
|
||||||
|
<h5>Heading level 5</h5>
|
||||||
|
<h6>Heading level 6</h6>
|
||||||
|
<hgroup>
|
||||||
|
<h2>Grouped heading</h2>
|
||||||
|
<p>Subtitle paragraph inside hgroup</p>
|
||||||
|
</hgroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="text">
|
||||||
|
<h2>Text and inline elements</h2>
|
||||||
|
|
||||||
|
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||||
|
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||||
|
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||||
|
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||||
|
<samp>output text</samp>, <var>variable</var>,
|
||||||
|
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||||
|
<dfn>definition term</dfn>,
|
||||||
|
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||||
|
<data value="42">forty-two</data>,
|
||||||
|
<q>short inline quote</q>,
|
||||||
|
<cite>Cited Work</cite>,
|
||||||
|
<bdi>إسم</bdi>,
|
||||||
|
<bdo dir="rtl">reversed direction</bdo>,
|
||||||
|
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Links:
|
||||||
|
<a href="#top">internal anchor</a> ·
|
||||||
|
<a href="https://example.com">absolute</a> ·
|
||||||
|
<a href="/relative/path">relative</a> ·
|
||||||
|
<a href="mailto:test@example.com">mailto</a> ·
|
||||||
|
<a href="tel:+15555550123">tel</a> ·
|
||||||
|
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||||
|
<a href="#" download="">download attr</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote cite="https://example.com/source">
|
||||||
|
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||||
|
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||||
|
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<pre><code>#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for i in {1..3}; do
|
||||||
|
printf 'iteration %d\n' "$i"
|
||||||
|
done
|
||||||
|
|
||||||
|
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Line break here,<br>after the break.</p>
|
||||||
|
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||||
|
123 Nowhere St, Somewhere
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="lists">
|
||||||
|
<h2>Lists</h2>
|
||||||
|
|
||||||
|
<h3>Unordered, nested</h3>
|
||||||
|
<ul>
|
||||||
|
<li>First item</li>
|
||||||
|
<li>Second item
|
||||||
|
<ul>
|
||||||
|
<li>Nested item
|
||||||
|
<ul><li>Deeply nested item</li></ul>
|
||||||
|
</li>
|
||||||
|
<li>Another nested item</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Ordered variants</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Default numbering</li>
|
||||||
|
<li>Second
|
||||||
|
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<ol start="5" reversed="">
|
||||||
|
<li>Reversed, starting at 5</li>
|
||||||
|
<li>Next</li>
|
||||||
|
<li>Next</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Description list</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Term one</dt>
|
||||||
|
<dd>Definition of the first term.</dd>
|
||||||
|
<dt>Term two</dt>
|
||||||
|
<dt>Term two, alias</dt>
|
||||||
|
<dd>Definition covering both terms above.</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Menu</h3>
|
||||||
|
<menu>
|
||||||
|
<li><button type="button">Copy</button></li>
|
||||||
|
<li><button type="button">Paste</button></li>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="tables">
|
||||||
|
<h2>Tables</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>Quarterly figures with spans and a footer</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col span="1">
|
||||||
|
<col span="2">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Region</th>
|
||||||
|
<th scope="col">Q1</th>
|
||||||
|
<th scope="col">Q2</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">North</th>
|
||||||
|
<td>1,204</td>
|
||||||
|
<td>1,391</td>
|
||||||
|
<td rowspan="2">Shared note spanning two rows</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">South</th>
|
||||||
|
<td>988</td>
|
||||||
|
<td>1,022</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">East</th>
|
||||||
|
<td colspan="2">Merged across two quarters</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total</th>
|
||||||
|
<td>2,192</td>
|
||||||
|
<td>2,413</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Wide table (horizontal overflow)</h3>
|
||||||
|
<table>
|
||||||
|
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||||
|
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Forms</h2>
|
||||||
|
|
||||||
|
<form action="#" method="get">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Text inputs</legend>
|
||||||
|
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||||
|
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||||
|
<datalist id="suggestions">
|
||||||
|
<option value="alpha"></option>
|
||||||
|
<option value="beta"></option>
|
||||||
|
<option value="gamma"></option>
|
||||||
|
</datalist>
|
||||||
|
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
|
||||||
|
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||||
|
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||||
|
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||||
|
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||||
|
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||||
|
content
|
||||||
|
here</textarea></p>
|
||||||
|
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
|
||||||
|
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Date, time, color, range, file</legend>
|
||||||
|
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||||
|
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||||
|
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||||
|
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||||
|
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||||
|
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||||
|
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||||
|
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Choices</legend>
|
||||||
|
<p>
|
||||||
|
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
|
||||||
|
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-select">Select</label>
|
||||||
|
<select id="f-select" name="select">
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
<optgroup label="Group one">
|
||||||
|
<option value="1" selected="">One</option>
|
||||||
|
<option value="2">Two</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Group two" disabled="">
|
||||||
|
<option value="3">Three</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-multi">Multi-select</label><br>
|
||||||
|
<select id="f-multi" multiple="" size="4">
|
||||||
|
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Output and buttons</legend>
|
||||||
|
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||||
|
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||||
|
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
<button type="reset">Reset</button>
|
||||||
|
<button type="button">Plain button</button>
|
||||||
|
<button type="button" disabled="">Disabled button</button>
|
||||||
|
<input type="submit" value="Input submit">
|
||||||
|
<input type="button" value="Input button">
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="csrf" value="hidden-value">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="media">
|
||||||
|
<h2>Media and embeds</h2>
|
||||||
|
|
||||||
|
<h3>Inline SVG</h3>
|
||||||
|
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||||
|
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3>Figure with data-URI image</h3>
|
||||||
|
<figure>
|
||||||
|
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||||
|
<figcaption>Figure caption describing the image above.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3>Broken image (alt-text fallback test)</h3>
|
||||||
|
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||||
|
|
||||||
|
<h3>Picture element</h3>
|
||||||
|
<picture>
|
||||||
|
<source media="(min-width: 800px)" srcset="wide.png">
|
||||||
|
<source media="(min-width: 400px)" srcset="medium.png">
|
||||||
|
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<h3>Video and audio (sources intentionally missing)</h3>
|
||||||
|
<video controls="" width="320" poster="poster.jpg">
|
||||||
|
<source src="clip.webm" type="video/webm">
|
||||||
|
<source src="clip.mp4" type="video/mp4">
|
||||||
|
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
<audio controls="">
|
||||||
|
<source src="tone.ogg" type="audio/ogg">
|
||||||
|
<source src="tone.mp3" type="audio/mpeg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<h3>Canvas and iframe</h3>
|
||||||
|
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||||
|
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="interactive">
|
||||||
|
<h2>Interactive and semantic containers</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Collapsed disclosure</summary>
|
||||||
|
<p>Hidden content revealed on toggle.</p>
|
||||||
|
</details>
|
||||||
|
<details open="">
|
||||||
|
<summary>Open disclosure</summary>
|
||||||
|
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
<p>Non-modal dialog content.</p>
|
||||||
|
<button type="button">Close</button>
|
||||||
|
|
||||||
|
<button type="button">Open dialog</button>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header><h3>Article header</h3></header>
|
||||||
|
<p>Article body content.</p>
|
||||||
|
<aside><p>An aside nested inside the article.</p></aside>
|
||||||
|
<footer><p>Article footer.</p></footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p><span contenteditable="true">Editable inline region</span></p>
|
||||||
|
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="edge">
|
||||||
|
<h2>Edge cases</h2>
|
||||||
|
|
||||||
|
<p>Very long unbroken token (overflow test):</p>
|
||||||
|
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||||
|
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||||
|
|
||||||
|
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||||
|
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||||
|
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||||
|
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||||
|
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||||
|
<p>Zero-width chars between letters: abc</p>
|
||||||
|
|
||||||
|
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||||
|
<p>Attribute with quotes: <span title="He said "hello"">hover me</span></p>
|
||||||
|
|
||||||
|
<p>Empty elements follow:</p>
|
||||||
|
<div></div>
|
||||||
|
<p></p>
|
||||||
|
<ul></ul>
|
||||||
|
<table></table>
|
||||||
|
|
||||||
|
<p>Deep nesting:</p>
|
||||||
|
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||||
|
|
||||||
|
<p>Inline element stress:
|
||||||
|
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||||
|
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||||
|
</footer>
|
||||||
@@ -126,3 +126,163 @@ describe('ContactForm.toHtml field type attribute sanitization', () => {
|
|||||||
expect(html).toContain('type="email"');
|
expect(html).toContain('type="email"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F1: the field editor (FormStylePanel) can now create fields of every type
|
||||||
|
// in sanitizeInputType's allowlist, plus textarea/select. Verify each
|
||||||
|
// renders with the right control, label/for association, and required flag.
|
||||||
|
describe('ContactForm.toHtml renders every configured field type/label/required', () => {
|
||||||
|
const cases: { type: string; tag: string }[] = [
|
||||||
|
{ type: 'text', tag: 'input' },
|
||||||
|
{ type: 'email', tag: 'input' },
|
||||||
|
{ type: 'tel', tag: 'input' },
|
||||||
|
{ type: 'number', tag: 'input' },
|
||||||
|
{ type: 'password', tag: 'input' },
|
||||||
|
{ type: 'url', tag: 'input' },
|
||||||
|
{ type: 'search', tag: 'input' },
|
||||||
|
{ type: 'date', tag: 'input' },
|
||||||
|
{ type: 'checkbox', tag: 'input' },
|
||||||
|
{ type: 'radio', tag: 'input' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(cases)('type=$type renders a sanitized <$tag type="$type"> with label + for/id wiring', ({ type, tag }) => {
|
||||||
|
const fields = [{ type: type as any, label: `Field ${type}`, name: `f_${type}`, placeholder: '', required: true }];
|
||||||
|
const { html } = toHtml({ fields }, '');
|
||||||
|
expect(html).toContain(`<${tag}`);
|
||||||
|
expect(html).toContain(`type="${type}"`);
|
||||||
|
expect(html).toContain(`Field ${type}`);
|
||||||
|
// required renders the input attribute AND the visual asterisk
|
||||||
|
expect(html).toMatch(/ required/);
|
||||||
|
expect(html).toContain('*</span>');
|
||||||
|
const labelFor = html.match(/<label for="([^"]+)"/)![1];
|
||||||
|
expect(html).toContain(`id="${labelFor}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type=textarea renders a <textarea>, not an <input>', () => {
|
||||||
|
const fields = [{ type: 'textarea' as const, label: 'Message', name: 'message', placeholder: '', required: false }];
|
||||||
|
const { html } = toHtml({ fields }, '');
|
||||||
|
expect(html).toMatch(/<textarea[^>]*name="message"/);
|
||||||
|
expect(html).not.toMatch(/<input[^>]*name="message"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type=select renders a <select> with escaped <option> values from field.options', () => {
|
||||||
|
const fields = [{ type: 'select' as const, label: 'Plan', name: 'plan', placeholder: 'Choose one', required: false, options: ['Basic', 'Pro', '"><script>alert(1)</script>'] }];
|
||||||
|
const { html } = toHtml({ fields }, '');
|
||||||
|
expect(html).toMatch(/<select[^>]*name="plan"/);
|
||||||
|
expect(html).toContain('<option value="Basic">Basic</option>');
|
||||||
|
expect(html).toContain('<option value="Pro">Pro</option>');
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-required field omits both the required attribute and the asterisk', () => {
|
||||||
|
const fields = [{ type: 'text' as const, label: 'Nickname', name: 'nickname', placeholder: '', required: false }];
|
||||||
|
const { html } = toHtml({ fields }, '');
|
||||||
|
expect(html).not.toMatch(/ required/);
|
||||||
|
expect(html).not.toContain('*</span>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Box-model / animation / visibility rollout (common enh-batch pattern):
|
||||||
|
// these are top-level props consumed generically by the export's
|
||||||
|
// buildDataAttrs() -- this just confirms the defaults are present on
|
||||||
|
// craft.props so the panel controls render and the props survive save/load.
|
||||||
|
describe('ContactForm.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults for animation, animationDelay, hideOnDesktop/Tablet/Mobile', () => {
|
||||||
|
expect(ContactForm.craft!.props).toMatchObject({
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- Webhook destination (Task 10) ----------
|
||||||
|
This file builds props inline rather than spreading a shared object, so
|
||||||
|
`defaultProps` is introduced here for the destination cases only; every
|
||||||
|
pre-existing test above is untouched. */
|
||||||
|
const defaultProps = { fields: [] as any[], formAction: '#' };
|
||||||
|
|
||||||
|
describe('ContactForm.craft.props includes the destination defaults', () => {
|
||||||
|
// The trap this pins: FormStylePanel renders each destination control behind
|
||||||
|
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
||||||
|
// invisible control and the whole feature looks like it does nothing.
|
||||||
|
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
||||||
|
expect(ContactForm.craft!.props).toMatchObject({
|
||||||
|
destinationType: 'email',
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no craft prop holds a raw secret -- only an id', () => {
|
||||||
|
const keys = Object.keys(ContactForm.craft!.props as object);
|
||||||
|
expect(keys).toContain('webhookSecretId');
|
||||||
|
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ContactForm.toHtml destination marker', () => {
|
||||||
|
test('email destination emits the legacy marker unchanged', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
||||||
|
expect(out.html).toContain('<!--WHP-FORM');
|
||||||
|
expect(out.html).toContain('recipient="a@example.com"');
|
||||||
|
expect(out.html).not.toContain('type="webhook"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
||||||
|
// The guarantee every already-published site depends on: a marker with no
|
||||||
|
// `type` still provisions an email endpoint, so its bytes must not drift by
|
||||||
|
// so much as a space.
|
||||||
|
//
|
||||||
|
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
|
||||||
|
// to each other only catches a drift that affects ONE of them -- a uniform
|
||||||
|
// change passes it. This string was captured from `071f3447` (the revision
|
||||||
|
// deployed to production before this feature) and is the actual reference:
|
||||||
|
// if it has to be edited, every already-published site's forms have changed
|
||||||
|
// shape and that is the thing to stop, not the test.
|
||||||
|
const FROZEN_LEGACY_MARKER =
|
||||||
|
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
||||||
|
|
||||||
|
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||||
|
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the new props, set to their defaults, change nothing about it.
|
||||||
|
const explicit = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||||
|
expect(explicit.html).toBe(legacy.html);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||||
|
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
||||||
|
expect(out.html).toContain('type="webhook"');
|
||||||
|
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
||||||
|
expect(out.html).toContain('secret="sec-1"');
|
||||||
|
expect(out.html).toContain('authmode="bearer"');
|
||||||
|
expect(out.html).toContain('recipient="fb@example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw secret value is never emitted, only its id', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||||
|
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
||||||
|
// Non-vacuous: the marker IS emitted (so there is something that could have
|
||||||
|
// carried the secret) and carries the id, but not the value.
|
||||||
|
expect(out.html).toContain('secret="sec-1"');
|
||||||
|
expect(out.html).not.toContain('SUPERSECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
||||||
|
expect(out.html).toContain('type="webhook"');
|
||||||
|
expect(out.html).toContain('recipient=""');
|
||||||
|
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||||
|
expect(out.html).toContain('name="_gotcha"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import { cssPropsToString } from '../../utils/style-helpers';
|
|||||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
||||||
|
|
||||||
|
// The allowlist enforced at export time lives in `sanitizeInputType`
|
||||||
|
// (utils/escape.ts) -- this union is a superset (it also covers 'textarea'
|
||||||
|
// and 'select', which take their own render branches instead of an
|
||||||
|
// `<input type>`), kept in sync by hand since TS unions can't import a
|
||||||
|
// runtime array.
|
||||||
|
export type ContactFormFieldType =
|
||||||
|
| 'text' | 'email' | 'tel' | 'number' | 'password' | 'url' | 'search' | 'date'
|
||||||
|
| 'checkbox' | 'radio' | 'textarea' | 'select';
|
||||||
|
|
||||||
interface ContactFormField {
|
interface ContactFormField {
|
||||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
type: ContactFormFieldType;
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -25,6 +34,21 @@ interface ContactFormProps {
|
|||||||
inputBorder?: string;
|
inputBorder?: string;
|
||||||
recipientEmail?: string;
|
recipientEmail?: string;
|
||||||
thankYouUrl?: string;
|
thankYouUrl?: string;
|
||||||
|
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
|
||||||
|
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
|
||||||
|
with the url / secret id / auth mode below.
|
||||||
|
There is deliberately NO raw-secret prop: craft props are serialised into
|
||||||
|
the saved project and into published output, so the secret is POSTed to
|
||||||
|
/api/form-webhook-secret.php and only the returned opaque id is kept. */
|
||||||
|
destinationType?: 'email' | 'webhook';
|
||||||
|
webhookUrl?: string;
|
||||||
|
webhookSecretId?: string;
|
||||||
|
webhookAuthMode?: 'signature' | 'bearer';
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFields: ContactFormField[] = [
|
const defaultFields: ContactFormField[] = [
|
||||||
@@ -157,6 +181,17 @@ ContactForm.craft = {
|
|||||||
inputBorder: '#d1d5db',
|
inputBorder: '#d1d5db',
|
||||||
recipientEmail: '',
|
recipientEmail: '',
|
||||||
thankYouUrl: '',
|
thankYouUrl: '',
|
||||||
|
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
|
||||||
|
// gates actually render the destination controls.
|
||||||
|
destinationType: 'email',
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -214,7 +249,17 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
|
||||||
|
// secret, by construction (see ContactFormProps).
|
||||||
|
const { marker, actionAttr, honeypot } = relayFormWiring(
|
||||||
|
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
|
||||||
|
{
|
||||||
|
type: props.destinationType,
|
||||||
|
url: props.webhookUrl,
|
||||||
|
secretId: props.webhookSecretId,
|
||||||
|
authMode: props.webhookAuthMode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// The form-sender relay delivers success via a full-page 303 redirect
|
// The form-sender relay delivers success via a full-page 303 redirect
|
||||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||||
|
|||||||
@@ -22,4 +22,19 @@ describe('FormButton.toHtml', () => {
|
|||||||
expect(html).toContain('&');
|
expect(html).toContain('&');
|
||||||
expect(html).toContain('"quoted"');
|
expect(html).toContain('"quoted"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('box-model style (margin/border/box-shadow/opacity) flows through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ text: 'Submit', style: { marginTop: '12px', border: '2px solid #000', boxShadow: '0 2px 4px rgba(0,0,0,.2)', opacity: '0.8' } }, '');
|
||||||
|
expect(html).toContain('margin-top:12px');
|
||||||
|
expect(html).toContain('border:2px solid #000');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormButton.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(FormButton.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
|
|||||||
interface FormButtonProps {
|
interface FormButtonProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||||
@@ -58,6 +63,11 @@ FormButton.craft = {
|
|||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
},
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -52,3 +52,20 @@ describe('FormContainer.toHtml method attribute sanitization', () => {
|
|||||||
expect(html).toContain('method="GET"');
|
expect(html).toContain('method="GET"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FormContainer.toHtml box-model style passthrough', () => {
|
||||||
|
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ action: '/legacy', style: { marginTop: '20px', border: '3px dashed #ccc', boxShadow: '0 4px 8px rgba(0,0,0,.2)', opacity: '0.95' } }, '');
|
||||||
|
expect(html).toContain('margin-top:20px');
|
||||||
|
expect(html).toContain('border:3px dashed #ccc');
|
||||||
|
expect(html).toContain('opacity:0.95');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormContainer.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(FormContainer.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ interface FormContainerProps {
|
|||||||
thankYouUrl?: string;
|
thankYouUrl?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||||
@@ -59,6 +64,11 @@ FormContainer.craft = {
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid #e4e4e7',
|
border: '1px solid #e4e4e7',
|
||||||
},
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -72,3 +72,20 @@ describe('InputField.toHtml type attribute sanitization', () => {
|
|||||||
expect(html).toContain('type="number"');
|
expect(html).toContain('type="number"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('InputField.toHtml box-model style passthrough', () => {
|
||||||
|
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ label: 'Name', name: 'name', style: { marginBottom: '8px', border: '1px solid #333', boxShadow: '0 1px 2px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
|
||||||
|
expect(html).toContain('margin-bottom:8px');
|
||||||
|
expect(html).toContain('border:1px solid #333');
|
||||||
|
expect(html).toContain('opacity:0.9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InputField.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(InputField.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface InputFieldProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputField: UserComponent<InputFieldProps> = ({
|
export const InputField: UserComponent<InputFieldProps> = ({
|
||||||
@@ -77,6 +82,11 @@ InputField.craft = {
|
|||||||
placeholder: 'Enter your name',
|
placeholder: 'Enter your name',
|
||||||
required: false,
|
required: false,
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const toHtml = (SubscribeForm as any).toHtml;
|
|||||||
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
|
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
|
||||||
test('form method is always POST regardless of any injected props', () => {
|
test('form method is always POST regardless of any injected props', () => {
|
||||||
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
|
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
|
||||||
expect(html).toContain('<form method="POST"');
|
expect(html).toMatch(/<form action="[^"]*" method="POST"/);
|
||||||
expect(html).not.toContain('<script');
|
expect(html).not.toContain('<script');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,3 +37,45 @@ describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop
|
|||||||
expect(html).toContain('>Go<');
|
expect(html).toContain('>Go<');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F1: SubscribeForm previously emitted `<form method="POST">` with no action
|
||||||
|
// at all -- a published subscribe form silently did nothing on submit.
|
||||||
|
// Wired through the same relay contract as ContactForm/FormContainer
|
||||||
|
// (utils/form-relay-wiring.ts) so setting a recipient makes it functional.
|
||||||
|
describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
|
||||||
|
test('without a recipient: still has a real (non-empty) action -- "#" fallback, not a bare method="POST"', () => {
|
||||||
|
const { html } = toHtml({}, '');
|
||||||
|
expect(html).toMatch(/<form action="#" method="POST"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
|
||||||
|
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
|
||||||
|
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
|
||||||
|
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||||
|
expect(html).toContain('name="_gotcha"');
|
||||||
|
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||||
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the email input keeps its name="email" so the relay receives it', () => {
|
||||||
|
const { html } = toHtml({ recipientEmail: 'news@example.com' }, '', 'node-sub2');
|
||||||
|
expect(html).toContain('name="email"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SubscribeForm.toHtml box-model style passthrough', () => {
|
||||||
|
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ style: { marginTop: '16px', border: '1px solid #ddd', boxShadow: '0 2px 6px rgba(0,0,0,.15)', opacity: '0.85' } }, '');
|
||||||
|
expect(html).toContain('margin-top:16px');
|
||||||
|
expect(html).toContain('border:1px solid #ddd');
|
||||||
|
expect(html).toContain('opacity:0.85');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SubscribeForm.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(SubscribeForm.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface SubscribeFormProps {
|
interface SubscribeFormProps {
|
||||||
@@ -10,6 +11,17 @@ interface SubscribeFormProps {
|
|||||||
buttonColor?: string;
|
buttonColor?: string;
|
||||||
layout?: 'inline' | 'stacked';
|
layout?: 'inline' | 'stacked';
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
/** "Send submissions to" address -- same relay contract as ContactForm/
|
||||||
|
* FormContainer (see utils/form-relay-wiring.ts). Blank = no relay; the
|
||||||
|
* published form then has no working action at all, which is the bug
|
||||||
|
* this prop exists to fix. */
|
||||||
|
recipientEmail?: string;
|
||||||
|
thankYouUrl?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
|
||||||
@@ -110,6 +122,13 @@ SubscribeForm.craft = {
|
|||||||
buttonColor: '#3b82f6',
|
buttonColor: '#3b82f6',
|
||||||
layout: 'inline',
|
layout: 'inline',
|
||||||
style: { backgroundColor: '#f8fafc' },
|
style: { backgroundColor: '#f8fafc' },
|
||||||
|
recipientEmail: '',
|
||||||
|
thankYouUrl: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -120,7 +139,7 @@ SubscribeForm.craft = {
|
|||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const {
|
const {
|
||||||
heading = 'Subscribe to our newsletter',
|
heading = 'Subscribe to our newsletter',
|
||||||
placeholder = 'Enter your email',
|
placeholder = 'Enter your email',
|
||||||
@@ -166,11 +185,21 @@ SubscribeForm.craft = {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same relay contract as ContactForm/FormContainer: a recipientEmail wires
|
||||||
|
// the form through the WHP form-sender relay (marker + placeholder action
|
||||||
|
// + honeypot, provisioned/rewritten at publish time). Previously this form
|
||||||
|
// always emitted `<form method="POST">` with NO action at all -- a
|
||||||
|
// published subscribe form silently did nothing on submit. Falling back to
|
||||||
|
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
|
||||||
|
// the old no-recipient case visually identical (action="#") while making
|
||||||
|
// the relay path actually functional once an admin sets an email.
|
||||||
|
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||||
${headingHtml}
|
${headingHtml}
|
||||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
${honeypot ? ` ${honeypot}\n` : ''} <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>`,
|
</div>`,
|
||||||
|
|||||||
@@ -63,3 +63,20 @@ describe('TextareaField.toHtml rows attribute sanitization', () => {
|
|||||||
expect(html).toContain('rows="8"');
|
expect(html).toContain('rows="8"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TextareaField.toHtml box-model style passthrough', () => {
|
||||||
|
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
|
||||||
|
const { html } = toHtml({ label: 'Message', name: 'message', style: { marginTop: '10px', border: '1px solid #555', boxShadow: '0 1px 3px rgba(0,0,0,.15)', opacity: '0.7' } }, '');
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('border:1px solid #555');
|
||||||
|
expect(html).toContain('opacity:0.7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TextareaField.craft.props includes animation/visibility defaults', () => {
|
||||||
|
test('has blank/false defaults', () => {
|
||||||
|
expect(TextareaField.craft!.props).toMatchObject({
|
||||||
|
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface TextareaFieldProps {
|
|||||||
rows?: number;
|
rows?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||||
@@ -79,6 +84,11 @@ TextareaField.craft = {
|
|||||||
rows: 4,
|
rows: 4,
|
||||||
required: false,
|
required: false,
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -80,3 +80,56 @@ describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
|
|||||||
expect(html).toMatch(/calc\(50% - 24px\)/);
|
expect(html).toMatch(/calc\(50% - 24px\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
|
||||||
|
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
|
||||||
|
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
|
||||||
|
expect(html).toContain('align-items:center');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
columns: 2,
|
||||||
|
split: '50-50',
|
||||||
|
gap: '16px',
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'<div>A</div><div>B</div>',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (ColumnLayout as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default alignItems and box-model keys', () => {
|
||||||
|
const style = (ColumnLayout as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('alignItems');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitToWidths: Record<string, string[]> = {
|
const splitToWidths: Record<string, string[]> = {
|
||||||
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
|
|||||||
columns: 2,
|
columns: 2,
|
||||||
split: '50-50',
|
split: '50-50',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
style: {},
|
style: {
|
||||||
|
alignItems: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -63,3 +63,88 @@ describe('Container.toHtml tag allowlist (adversarial re-review, same class as C
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||||
|
// Regression lock: Container/Section must NOT unconditionally become a
|
||||||
|
// flex container. Flex-blockifies in-flow children, forcing components
|
||||||
|
// that deliberately render display:inline-block (ButtonLink, Icon) to
|
||||||
|
// stack vertically instead of sitting side-by-side -- a real visual
|
||||||
|
// regression for existing published pages that never touch vertical
|
||||||
|
// alignment.
|
||||||
|
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||||
|
const { html } = toHtml({}, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
expect(html).toContain('min-height:400px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||||
|
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:center');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style.minHeight flows into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||||
|
expect(html).toContain('min-height:400px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
|
||||||
|
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:flex-end');
|
||||||
|
expect(html).toContain('min-height:500px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'child',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Container as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||||
|
const style = (Container as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('justifyContent');
|
||||||
|
expect(style).toHaveProperty('minHeight');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container only becomes display:flex/flex-direction:column at its root
|
||||||
|
// (both in the editor render below and in toHtml) when the user has
|
||||||
|
// actually set `style.justifyContent` (the Vertical Alignment control,
|
||||||
|
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
|
||||||
|
// vertical-align actually being in use, not unconditional. In-flow children
|
||||||
|
// of a flex container get CSS-blockified, which would force components that
|
||||||
|
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
|
||||||
|
// vertically instead of sitting side-by-side -- a real visual regression for
|
||||||
|
// any container/section that never touches vertical alignment, not a no-op.
|
||||||
|
// So plain block flow (no `display`/`flex-direction` at all) is preserved
|
||||||
|
// unless vertical-align is set. `flexAlignFromTextAlign` above still
|
||||||
|
// supplies its own conditional flex conversion (cross-axis alignItems from
|
||||||
|
// `textAlign`) independently -- unrelated to this gate.
|
||||||
|
|
||||||
export const Container: UserComponent<ContainerProps> = ({
|
export const Container: UserComponent<ContainerProps> = ({
|
||||||
style = {},
|
style = {},
|
||||||
tag = 'div',
|
tag = 'div',
|
||||||
@@ -58,10 +72,12 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
const safeTag = sanitizeContainerTag(tag);
|
const safeTag = sanitizeContainerTag(tag);
|
||||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
||||||
|
const hasVerticalAlign = !!style.justifyContent;
|
||||||
|
|
||||||
const outerStyle: CSSProperties = {
|
const outerStyle: CSSProperties = {
|
||||||
minHeight: '40px',
|
minHeight: '40px',
|
||||||
...style,
|
...style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
||||||
...(needsBoxedWrapper ? {} : flexStyles),
|
...(needsBoxedWrapper ? {} : flexStyles),
|
||||||
};
|
};
|
||||||
@@ -93,13 +109,27 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
Container.craft = {
|
Container.craft = {
|
||||||
displayName: 'Container',
|
displayName: 'Container',
|
||||||
props: {
|
props: {
|
||||||
style: { padding: '20px', minHeight: '100px' },
|
style: {
|
||||||
|
padding: '20px',
|
||||||
|
minHeight: '100px',
|
||||||
|
justifyContent: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
tag: 'div',
|
tag: 'div',
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
contentWidth: 'full',
|
contentWidth: 'full',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
cssId: '',
|
cssId: '',
|
||||||
cssClass: '',
|
cssClass: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -114,9 +144,11 @@ Container.craft = {
|
|||||||
const tag = sanitizeContainerTag(props.tag);
|
const tag = sanitizeContainerTag(props.tag);
|
||||||
const isBoxed = props.contentWidth === 'boxed';
|
const isBoxed = props.contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||||
|
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||||
|
|
||||||
const outerCss: CSSProperties = {
|
const outerCss: CSSProperties = {
|
||||||
...props.style,
|
...props.style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
...(isBoxed ? {} : flexStyles),
|
...(isBoxed ? {} : flexStyles),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,78 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
|
|||||||
expect(html).not.toContain('<svg');
|
expect(html).not.toContain('<svg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||||
|
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
|
||||||
|
// Section must not unconditionally become a flex container, or it
|
||||||
|
// blockifies inline-block children (ButtonLink, Icon) that are meant to
|
||||||
|
// sit side-by-side in existing published sections.
|
||||||
|
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||||
|
const { html } = toHtml({}, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
expect(html).toContain('min-height:600px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||||
|
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:center');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style.minHeight flows into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||||
|
expect(html).toContain('min-height:600px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'child',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Section as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||||
|
const style = (Section as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('justifyContent');
|
||||||
|
expect(style).toHaveProperty('minHeight');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ interface SectionProps {
|
|||||||
bottomDividerColor?: string;
|
bottomDividerColor?: string;
|
||||||
bottomDividerHeight?: string;
|
bottomDividerHeight?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Divider renderer ---------- */
|
/* ---------- Divider renderer ---------- */
|
||||||
@@ -98,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
|
|
||||||
const hasTopDivider = topDivider && topDivider !== 'none';
|
const hasTopDivider = topDivider && topDivider !== 'none';
|
||||||
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
||||||
|
// Section's root only becomes a column flex container when the user has
|
||||||
|
// actually set `style.justifyContent` (Vertical Alignment control, paired
|
||||||
|
// with `style.minHeight`) -- see the matching note in Container.tsx for
|
||||||
|
// why an unconditional conversion is a real regression (blockifies
|
||||||
|
// deliberately inline-block children like ButtonLink/Icon) rather than a
|
||||||
|
// no-op, so plain block flow is preserved unless vertical-align is set.
|
||||||
|
const hasVerticalAlign = !!style.justifyContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -107,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||||
...style,
|
...style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasTopDivider && (
|
{hasTopDivider && (
|
||||||
@@ -143,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
Section.craft = {
|
Section.craft = {
|
||||||
displayName: 'Section',
|
displayName: 'Section',
|
||||||
props: {
|
props: {
|
||||||
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
style: {
|
||||||
|
padding: '40px 0',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
minHeight: '',
|
||||||
|
justifyContent: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
innerMaxWidth: '1200px',
|
innerMaxWidth: '1200px',
|
||||||
topDivider: 'none',
|
topDivider: 'none',
|
||||||
topDividerColor: '#ffffff',
|
topDividerColor: '#ffffff',
|
||||||
@@ -152,6 +175,11 @@ Section.craft = {
|
|||||||
bottomDividerColor: '#ffffff',
|
bottomDividerColor: '#ffffff',
|
||||||
bottomDividerHeight: '50px',
|
bottomDividerHeight: '50px',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -199,11 +227,13 @@ function buildDividerHtml(
|
|||||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||||
|
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||||
|
|
||||||
const outerStyle = cssPropsToString({
|
const outerStyle = cssPropsToString({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||||
...props.style,
|
...props.style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
});
|
});
|
||||||
const innerStyle = cssPropsToString({
|
const innerStyle = cssPropsToString({
|
||||||
maxWidth: props.innerMaxWidth || '1200px',
|
maxWidth: props.innerMaxWidth || '1200px',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { ImageBlock } from './ImageBlock';
|
import { ImageBlock, pxAttr } from './ImageBlock';
|
||||||
|
|
||||||
const toHtml = (ImageBlock as any).toHtml;
|
const toHtml = (ImageBlock as any).toHtml;
|
||||||
|
|
||||||
@@ -32,3 +32,95 @@ describe('ImageBlock.toHtml src/alt XSS hardening', () => {
|
|||||||
expect(html).toContain('alt="A photo"');
|
expect(html).toContain('alt="A photo"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ImageBlock.toHtml perf attributes (always emitted)', () => {
|
||||||
|
test('loading="lazy" and decoding="async" are always present', () => {
|
||||||
|
const { html } = toHtml({ src: 'https://example.com/photo.jpg' }, '');
|
||||||
|
expect(html).toContain('loading="lazy"');
|
||||||
|
expect(html).toContain('decoding="async"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('width/height attributes are emitted when the style has plain px values', () => {
|
||||||
|
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '400px', height: '300px' } }, '');
|
||||||
|
expect(html).toContain('width="400"');
|
||||||
|
expect(html).toContain('height="300"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('width/height attributes are omitted when the style value is not a plain px length', () => {
|
||||||
|
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '50%', height: 'auto' } }, '');
|
||||||
|
expect(html).not.toMatch(/\swidth="/);
|
||||||
|
expect(html).not.toMatch(/\sheight="/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pxAttr', () => {
|
||||||
|
test('extracts the numeric portion of a plain px length', () => {
|
||||||
|
expect(pxAttr('400px')).toBe('400');
|
||||||
|
expect(pxAttr('12.5px')).toBe('12.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined for non-px units, non-string, or unset values', () => {
|
||||||
|
expect(pxAttr('50%')).toBeUndefined();
|
||||||
|
expect(pxAttr('auto')).toBeUndefined();
|
||||||
|
expect(pxAttr(undefined)).toBeUndefined();
|
||||||
|
expect(pxAttr(400)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageBlock.toHtml CSS framing crop (aspect-ratio + object-fit + object-position)', () => {
|
||||||
|
test('style.aspectRatio, objectFit, objectPosition all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{ src: 'https://example.com/photo.jpg', style: { aspectRatio: '16 / 9', objectFit: 'cover', objectPosition: 'center top' } },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(html).toContain('aspect-ratio:16 / 9');
|
||||||
|
expect(html).toContain('object-fit:cover');
|
||||||
|
expect(html).toContain('object-position:center top');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageBlock.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
src: 'https://example.com/photo.jpg',
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (ImageBlock as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model and crop keys', () => {
|
||||||
|
const style = (ImageBlock as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('aspectRatio');
|
||||||
|
expect(style).toHaveProperty('objectFit');
|
||||||
|
expect(style).toHaveProperty('objectPosition');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ interface ImageBlockProps {
|
|||||||
src?: string;
|
src?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the numeric portion of a plain "<n>px" CSS length string, for
|
||||||
|
* emitting real `width`/`height` HTML attributes on the exported `<img>`
|
||||||
|
* (helps the browser reserve layout space before the image loads --
|
||||||
|
* avoiding CLS -- something a CSS-only width/height can't do on its own).
|
||||||
|
* Returns undefined for any other unit ('%', 'auto', '', etc.) so the
|
||||||
|
* attribute is simply omitted when the pixel size isn't known. */
|
||||||
|
export function pxAttr(v: unknown): string | undefined {
|
||||||
|
if (typeof v !== 'string') return undefined;
|
||||||
|
const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/);
|
||||||
|
return m ? m[1] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: upload a file to the WHP API and return the proxy URL
|
// Helper: upload a file to the WHP API and return the proxy URL
|
||||||
@@ -83,7 +100,27 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
|||||||
|
|
||||||
ImageBlock.craft = {
|
ImageBlock.craft = {
|
||||||
displayName: 'Image',
|
displayName: 'Image',
|
||||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
props: {
|
||||||
|
src: PLACEHOLDER_SRC,
|
||||||
|
alt: '',
|
||||||
|
style: {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '',
|
||||||
|
objectFit: '' as CSSProperties['objectFit'],
|
||||||
|
objectPosition: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
|
},
|
||||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,5 +132,8 @@ ImageBlock.craft = {
|
|||||||
}
|
}
|
||||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||||
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
||||||
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
|
const widthAttr = pxAttr((props.style as any)?.width);
|
||||||
|
const heightAttr = pxAttr((props.style as any)?.height);
|
||||||
|
const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`;
|
||||||
|
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${dims} loading="lazy" decoding="async"${s ? ` style="${s}"` : ''} />` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,49 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MapEmbed.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the wrapper style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
address: 'New York, NY',
|
||||||
|
style: {
|
||||||
|
marginTop: '16px',
|
||||||
|
paddingRight: '4px',
|
||||||
|
border: '1px solid #cccccc',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.95',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:16px');
|
||||||
|
expect(html).toContain('padding-right:4px');
|
||||||
|
expect(html).toContain('border:1px solid #cccccc');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.95');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MapEmbed.craft.props exposes the animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (MapEmbed as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model keys', () => {
|
||||||
|
const style = (MapEmbed as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
|
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
|
||||||
test('a malicious address cannot break out of the src or title attribute', () => {
|
test('a malicious address cannot break out of the src or title attribute', () => {
|
||||||
const malicious = 'X" onerror="alert(1)';
|
const malicious = 'X" onerror="alert(1)';
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ interface MapEmbedProps {
|
|||||||
zoom?: number;
|
zoom?: number;
|
||||||
height?: string;
|
height?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMapUrl(address: string, zoom: number): string {
|
function buildMapUrl(address: string, zoom: number): string {
|
||||||
@@ -70,7 +75,18 @@ MapEmbed.craft = {
|
|||||||
address: 'New York, NY',
|
address: 'New York, NY',
|
||||||
zoom: 14,
|
zoom: 14,
|
||||||
height: '400px',
|
height: '400px',
|
||||||
style: {},
|
style: {
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -118,3 +118,75 @@ describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor
|
|||||||
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('VideoBlock.toHtml size + aspect ratio (frame honors style props, not a hardcoded 16:9)', () => {
|
||||||
|
test('direct file: style.width flows to the wrapper, style.aspectRatio flows to the <video>', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: { width: '50%', aspectRatio: '4 / 3' } }, '');
|
||||||
|
expect(html).toMatch(/<div style="[^"]*width:50%[^"]*"/);
|
||||||
|
expect(html).toMatch(/<video[^>]*style="[^"]*aspect-ratio:4 \/ 3[^"]*"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct file: no aspectRatio set -- no aspect-ratio declaration is forced onto the <video>', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||||
|
const videoTag = html.match(/<video[^>]*>/)![0];
|
||||||
|
expect(videoTag).not.toContain('aspect-ratio');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube/Vimeo: style.aspectRatio overrides the 16:9 default on the iframe container', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: { aspectRatio: '1 / 1' } }, '');
|
||||||
|
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:1 \/ 1[^"]*"[^>]*><iframe/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('YouTube/Vimeo: defaults to 16 / 9 when no aspectRatio style is set', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789' }, '');
|
||||||
|
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:16 \/ 9[^"]*"[^>]*><iframe/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VideoBlock.toHtml poster + preload (file type)', () => {
|
||||||
|
test('poster attribute is emitted (escaped) and preload="metadata" is always present on a direct file', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'https://example.com/poster.jpg' }, '');
|
||||||
|
expect(html).toContain('poster="https://example.com/poster.jpg"');
|
||||||
|
expect(html).toContain('preload="metadata"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no poster prop -- no poster attribute is emitted, but preload="metadata" still is', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||||
|
expect(html).not.toContain('poster=');
|
||||||
|
expect(html).toContain('preload="metadata"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malicious poster (javascript: scheme) is blocked by safeImageUrl', () => {
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'javascript:alert(1)' }, '');
|
||||||
|
expect(html).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a poster value cannot break out of the poster attribute', () => {
|
||||||
|
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
|
||||||
|
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: malicious }, '');
|
||||||
|
expect(html).not.toContain('onerror="alert(1)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VideoBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
|
||||||
|
test('poster, animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (VideoBlock as any).craft.props;
|
||||||
|
expect(props.poster).toBe('');
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model + size/aspect keys', () => {
|
||||||
|
const style = (VideoBlock as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('width');
|
||||||
|
expect(style).toHaveProperty('aspectRatio');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- Types ---------- */
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ interface VideoBlockProps {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
videoType?: VideoType;
|
videoType?: VideoType;
|
||||||
embedUrl?: string;
|
embedUrl?: string;
|
||||||
|
poster?: string;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
loop?: boolean;
|
loop?: boolean;
|
||||||
@@ -22,6 +23,11 @@ interface VideoBlockProps {
|
|||||||
innerMaxWidth?: string;
|
innerMaxWidth?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- URL detection ---------- */
|
/* ---------- URL detection ---------- */
|
||||||
@@ -120,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
|||||||
videoUrl = '',
|
videoUrl = '',
|
||||||
videoType: _videoTypeProp,
|
videoType: _videoTypeProp,
|
||||||
embedUrl: _embedUrlProp,
|
embedUrl: _embedUrlProp,
|
||||||
|
poster = '',
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
muted = true,
|
muted = true,
|
||||||
loop = false,
|
loop = false,
|
||||||
@@ -259,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
paddingBottom: '56.25%',
|
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||||
height: 0,
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: (style as any)?.borderRadius || undefined,
|
borderRadius: (style as any)?.borderRadius || undefined,
|
||||||
}}
|
}}
|
||||||
@@ -269,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
|||||||
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
inset: 0,
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -284,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
|||||||
{type === 'file' && (
|
{type === 'file' && (
|
||||||
<video
|
<video
|
||||||
src={embedUrl}
|
src={embedUrl}
|
||||||
|
poster={poster || undefined}
|
||||||
autoPlay={autoplay}
|
autoPlay={autoplay}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
|
preload="metadata"
|
||||||
playsInline
|
playsInline
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||||
|
objectFit: 'cover',
|
||||||
borderRadius: (style as any)?.borderRadius || undefined,
|
borderRadius: (style as any)?.borderRadius || undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -310,6 +319,7 @@ VideoBlock.craft = {
|
|||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
videoType: 'none',
|
videoType: 'none',
|
||||||
embedUrl: '',
|
embedUrl: '',
|
||||||
|
poster: '',
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
muted: true,
|
muted: true,
|
||||||
loop: false,
|
loop: false,
|
||||||
@@ -318,7 +328,20 @@ VideoBlock.craft = {
|
|||||||
overlayColor: '#000000',
|
overlayColor: '#000000',
|
||||||
overlayOpacity: 50,
|
overlayOpacity: 50,
|
||||||
innerMaxWidth: '1200px',
|
innerMaxWidth: '1200px',
|
||||||
style: {},
|
style: {
|
||||||
|
width: '',
|
||||||
|
aspectRatio: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -334,6 +357,7 @@ VideoBlock.craft = {
|
|||||||
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
||||||
const {
|
const {
|
||||||
videoUrl = '',
|
videoUrl = '',
|
||||||
|
poster = '',
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
muted = true,
|
muted = true,
|
||||||
loop: doLoop = false,
|
loop: doLoop = false,
|
||||||
@@ -422,15 +446,13 @@ VideoBlock.craft = {
|
|||||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
||||||
const containerStyle = cssPropsToString({
|
const containerStyle = cssPropsToString({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
paddingBottom: '56.25%',
|
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||||
height: '0',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: (style as any)?.borderRadius || undefined,
|
borderRadius: (style as any)?.borderRadius || undefined,
|
||||||
});
|
});
|
||||||
const iframeStyle = cssPropsToString({
|
const iframeStyle = cssPropsToString({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '0',
|
inset: '0',
|
||||||
left: '0',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -447,13 +469,16 @@ VideoBlock.craft = {
|
|||||||
if (doLoop) vidAttrs.push('loop');
|
if (doLoop) vidAttrs.push('loop');
|
||||||
if (controls) vidAttrs.push('controls');
|
if (controls) vidAttrs.push('controls');
|
||||||
vidAttrs.push('playsinline');
|
vidAttrs.push('playsinline');
|
||||||
|
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
|
||||||
const vidStyle = cssPropsToString({
|
const vidStyle = cssPropsToString({
|
||||||
display: 'block',
|
display: 'block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||||
|
objectFit: 'cover',
|
||||||
borderRadius: (style as any)?.borderRadius || undefined,
|
borderRadius: (style as any)?.borderRadius || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}"${posterAttr} preload="metadata" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { Accordion } from './Accordion';
|
||||||
|
|
||||||
|
const toHtml = (Accordion as any).toHtml;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ title: 'Q1', content: 'A1', isOpen: true },
|
||||||
|
{ title: 'Q2', content: 'A2', isOpen: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Accordion.toHtml basic export', () => {
|
||||||
|
test('renders a <details> per item with the open attribute honored', () => {
|
||||||
|
const { html } = toHtml({ items }, '');
|
||||||
|
const detailsBlocks = html.match(/<details[^>]*>/g) || [];
|
||||||
|
expect(detailsBlocks.length).toBe(2);
|
||||||
|
expect(detailsBlocks[0]).toContain(' open');
|
||||||
|
expect(detailsBlocks[1]).not.toContain(' open');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headerBg/headerColor/contentBg/borderColor emit into the panel styles', () => {
|
||||||
|
const { html } = toHtml({ items, headerBg: '#111111', headerColor: '#222222', contentBg: '#333333', borderColor: '#444444' }, '');
|
||||||
|
expect(html).toContain('#111111');
|
||||||
|
expect(html).toContain('#222222');
|
||||||
|
expect(html).toContain('#333333');
|
||||||
|
expect(html).toContain('#444444');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accordion.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (Accordion as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,11 @@ interface AccordionProps {
|
|||||||
contentBg?: string;
|
contentBg?: string;
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultItems: AccordionItem[] = [
|
const defaultItems: AccordionItem[] = [
|
||||||
@@ -135,6 +140,11 @@ Accordion.craft = {
|
|||||||
contentBg: '#ffffff',
|
contentBg: '#ffffff',
|
||||||
borderColor: '#e2e8f0',
|
borderColor: '#e2e8f0',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { CTASection } from './CTASection';
|
||||||
|
|
||||||
|
const toHtml = (CTASection as any).toHtml;
|
||||||
|
|
||||||
|
describe('CTASection.toHtml basic export', () => {
|
||||||
|
test('renders heading, description, and CTA buttons', () => {
|
||||||
|
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||||
|
expect(html).toContain('Hi');
|
||||||
|
expect(html).toContain('Sub');
|
||||||
|
expect(html).toContain('Go');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||||
|
const { html } = toHtml({
|
||||||
|
heading: 'Hi',
|
||||||
|
description: 'Sub',
|
||||||
|
style: { marginTop: '10px', paddingLeft: '5px', border: '1px solid #000', boxShadow: '0 1px 2px rgba(0,0,0,0.1)', opacity: '0.5' },
|
||||||
|
}, '');
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-left:5px');
|
||||||
|
expect(html).toContain('border:1px solid #000');
|
||||||
|
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.1)');
|
||||||
|
expect(html).toContain('opacity:0.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CTASection.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (CTASection as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,11 @@ interface CTASectionProps {
|
|||||||
gradient?: string;
|
gradient?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||||
@@ -83,6 +88,11 @@ CTASection.craft = {
|
|||||||
gradient: defaultGradient,
|
gradient: defaultGradient,
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { CallToAction } from './CallToAction';
|
||||||
|
|
||||||
|
const toHtml = (CallToAction as any).toHtml;
|
||||||
|
|
||||||
|
describe('CallToAction.toHtml basic export', () => {
|
||||||
|
test('renders heading, description, and CTA buttons', () => {
|
||||||
|
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
|
||||||
|
expect(html).toContain('Hi');
|
||||||
|
expect(html).toContain('Sub');
|
||||||
|
expect(html).toContain('Go');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
|
||||||
|
const { html } = toHtml({
|
||||||
|
heading: 'Hi',
|
||||||
|
description: 'Sub',
|
||||||
|
style: { marginBottom: '12px', paddingRight: '6px', border: '2px dashed #333', boxShadow: '0 4px 8px rgba(0,0,0,0.12)', opacity: '0.75' },
|
||||||
|
}, '');
|
||||||
|
expect(html).toContain('margin-bottom:12px');
|
||||||
|
expect(html).toContain('padding-right:6px');
|
||||||
|
expect(html).toContain('border:2px dashed #333');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.75');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CallToAction.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (CallToAction as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,11 @@ interface CallToActionProps {
|
|||||||
buttonColor?: string;
|
buttonColor?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||||
@@ -131,6 +136,11 @@ CallToAction.craft = {
|
|||||||
textColor: '#ffffff',
|
textColor: '#ffffff',
|
||||||
buttonColor: '#ffffff',
|
buttonColor: '#ffffff',
|
||||||
style: {},
|
style: {},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -139,3 +139,46 @@ describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be c
|
|||||||
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
|
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ContentSlider.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
slides,
|
||||||
|
style: {
|
||||||
|
marginBottom: '24px',
|
||||||
|
paddingTop: '8px',
|
||||||
|
border: '3px dashed #00ff00',
|
||||||
|
boxShadow: '0 10px 24px rgba(0,0,0,0.18)',
|
||||||
|
opacity: '0.75',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-bottom:24px');
|
||||||
|
expect(html).toContain('padding-top:8px');
|
||||||
|
expect(html).toContain('border:3px dashed #00ff00');
|
||||||
|
expect(html).toContain('box-shadow:0 10px 24px rgba(0,0,0,0.18)');
|
||||||
|
expect(html).toContain('opacity:0.75');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ContentSlider.craft.props exposes the animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (ContentSlider as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model keys', () => {
|
||||||
|
const style = (ContentSlider as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ interface ContentSliderProps {
|
|||||||
showArrows?: boolean;
|
showArrows?: boolean;
|
||||||
height?: string;
|
height?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSlides: Slide[] = [
|
const defaultSlides: Slide[] = [
|
||||||
@@ -227,7 +232,18 @@ ContentSlider.craft = {
|
|||||||
showDots: true,
|
showDots: true,
|
||||||
showArrows: true,
|
showArrows: true,
|
||||||
height: '400px',
|
height: '400px',
|
||||||
style: {},
|
style: {
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -62,3 +62,14 @@ describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
|
|||||||
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
|
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Countdown.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (Countdown as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ interface CountdownProps {
|
|||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeLeft {
|
interface TimeLeft {
|
||||||
@@ -139,6 +144,11 @@ Countdown.craft = {
|
|||||||
labelColor: 'rgba(255,255,255,0.7)',
|
labelColor: 'rgba(255,255,255,0.7)',
|
||||||
bgColor: '#18181b',
|
bgColor: '#18181b',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -21,3 +21,14 @@ describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml a
|
|||||||
expect(html).toMatch(/<a href=""/);
|
expect(html).toMatch(/<a href=""/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FeaturesGrid.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (FeaturesGrid as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ interface FeaturesGridProps {
|
|||||||
features?: FeatureItem[];
|
features?: FeatureItem[];
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
||||||
@@ -98,6 +103,11 @@ FeaturesGrid.craft = {
|
|||||||
features: defaultFeatures,
|
features: defaultFeatures,
|
||||||
style: { backgroundColor: '#ffffff' },
|
style: { backgroundColor: '#ffffff' },
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -3,6 +3,73 @@ import { Gallery } from './Gallery';
|
|||||||
|
|
||||||
const toHtml = (Gallery as any).toHtml;
|
const toHtml = (Gallery as any).toHtml;
|
||||||
|
|
||||||
|
describe('Gallery.toHtml columns + lightbox controls (previously unexposed props)', () => {
|
||||||
|
test('columns drives the grid-template-columns repeat count', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 5 }, '');
|
||||||
|
expect(html).toContain('grid-template-columns:repeat(5,1fr)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lightbox=true adds the delegated-listener overlay markup (columns unaffected)', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 4, lightbox: true }, '');
|
||||||
|
expect(html).toContain('grid-template-columns:repeat(4,1fr)');
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-numeric columns value falls back safely (Number() coercion, not NaN in the template)', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg' }], columns: 'not-a-number' as any }, '');
|
||||||
|
expect(html).toContain('grid-template-columns:repeat(3,1fr)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gallery.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
images: [{ src: '/a.jpg', alt: 'a' }],
|
||||||
|
style: {
|
||||||
|
marginTop: '20px',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
border: '1px solid #333333',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
opacity: '0.9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:20px');
|
||||||
|
expect(html).toContain('padding-left:12px');
|
||||||
|
expect(html).toContain('border:1px solid #333333');
|
||||||
|
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.08)');
|
||||||
|
expect(html).toContain('opacity:0.9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Gallery.craft.props exposes columns/lightbox + the animation/visibility rollout', () => {
|
||||||
|
test('columns and lightbox have their existing defaults', () => {
|
||||||
|
const props = (Gallery as any).craft.props;
|
||||||
|
expect(props.columns).toBe(3);
|
||||||
|
expect(props.lightbox).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Gallery as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default box-model keys', () => {
|
||||||
|
const style = (Gallery as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
|
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
|
||||||
test('no per-item inline onclick with interpolated src', () => {
|
test('no per-item inline onclick with interpolated src', () => {
|
||||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface GalleryProps {
|
|||||||
gap?: string;
|
gap?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
lightbox?: boolean;
|
lightbox?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderSvg = (index: number) => {
|
const placeholderSvg = (index: number) => {
|
||||||
@@ -112,8 +117,20 @@ Gallery.craft = {
|
|||||||
images: defaultImages,
|
images: defaultImages,
|
||||||
columns: 3,
|
columns: 3,
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
style: { backgroundColor: '#ffffff' },
|
style: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
lightbox: false,
|
lightbox: false,
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -73,3 +73,14 @@ describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- mus
|
|||||||
expect(html).toContain('data-target="150"');
|
expect(html).toContain('data-target="150"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NumberCounter.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (NumberCounter as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ interface NumberCounterProps {
|
|||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
numberSize?: string;
|
numberSize?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCounters: Counter[] = [
|
const defaultCounters: Counter[] = [
|
||||||
@@ -99,6 +104,11 @@ NumberCounter.craft = {
|
|||||||
labelColor: '#6b7280',
|
labelColor: '#6b7280',
|
||||||
numberSize: '48px',
|
numberSize: '48px',
|
||||||
style: { backgroundColor: '#ffffff' },
|
style: { backgroundColor: '#ffffff' },
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { PricingTable } from './PricingTable';
|
||||||
|
|
||||||
|
const toHtml = (PricingTable as any).toHtml;
|
||||||
|
|
||||||
|
const plans = [
|
||||||
|
{ name: 'Basic', price: '$9', period: '/month', features: ['Feature A'], buttonText: 'Buy', buttonHref: '#', isFeatured: false },
|
||||||
|
{ name: 'Pro', price: '$29', period: '/month', features: ['Feature B'], buttonText: 'Buy', buttonHref: '#', isFeatured: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('PricingTable.toHtml regular-card color overrides (previously hard-coded, now real props)', () => {
|
||||||
|
test('cardBg emits as the non-featured card background', () => {
|
||||||
|
const { html } = toHtml({ plans, cardBg: '#f0f0f0' }, '');
|
||||||
|
expect(html).toContain('background-color:#f0f0f0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('textColor emits as the non-featured heading/price color', () => {
|
||||||
|
const { html } = toHtml({ plans, textColor: '#123456' }, '');
|
||||||
|
expect(html).toContain('color:#123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subColor emits as the non-featured period text color', () => {
|
||||||
|
const { html } = toHtml({ plans, subColor: '#abcdef' }, '');
|
||||||
|
expect(html).toContain('color:#abcdef');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('featColor emits as the non-featured feature list text color', () => {
|
||||||
|
const { html } = toHtml({ plans, featColor: '#334455' }, '');
|
||||||
|
expect(html).toContain('color:#334455');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checkColor emits as the non-featured bullet color', () => {
|
||||||
|
const { html } = toHtml({ plans, checkColor: '#00ff00' }, '');
|
||||||
|
expect(html).toContain('color:#00ff00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('btnBg/btnColor emit as the non-featured button colors', () => {
|
||||||
|
const { html } = toHtml({ plans, btnBg: '#111111', btnColor: '#eeeeee' }, '');
|
||||||
|
expect(html).toContain('background-color:#111111');
|
||||||
|
expect(html).toContain('color:#eeeeee');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
|
||||||
|
const { html } = toHtml({ plans }, '');
|
||||||
|
// Regular (non-featured) card literals unchanged from before these props existed.
|
||||||
|
expect(html).toContain('background-color:#ffffff');
|
||||||
|
expect(html).toContain('color:#18181b');
|
||||||
|
expect(html).toContain('color:#64748b');
|
||||||
|
expect(html).toContain('color:#4b5563');
|
||||||
|
expect(html).toContain('color:#10b981');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unset btnBg falls back to featuredBg (original derivation)', () => {
|
||||||
|
const { html } = toHtml({ plans, featuredBg: '#654321' }, '');
|
||||||
|
expect(html).toContain('background-color:#654321');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PricingTable.toHtml XSS hardening (new card color props into style=)', () => {
|
||||||
|
test('a cardBg breakout string cannot escape style=""', () => {
|
||||||
|
const malicious = '#fff" onmouseover="alert(1)';
|
||||||
|
const { html } = toHtml({ plans, cardBg: malicious }, '');
|
||||||
|
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a textColor breakout string cannot escape style=""', () => {
|
||||||
|
const malicious = '#000" onmouseover="alert(1)';
|
||||||
|
const { html } = toHtml({ plans, textColor: malicious }, '');
|
||||||
|
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a btnBg breakout string cannot escape style=""', () => {
|
||||||
|
const malicious = '#000" onmouseover="alert(1)';
|
||||||
|
const { html } = toHtml({ plans, btnBg: malicious }, '');
|
||||||
|
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a btnColor breakout string cannot escape style=""', () => {
|
||||||
|
const malicious = '#000" onmouseover="alert(1)';
|
||||||
|
const { html } = toHtml({ plans, btnColor: malicious }, '');
|
||||||
|
expect(html).not.toMatch(/"\s+onmouseover="/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PricingTable.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('the new regular-card color props are declared blank by default', () => {
|
||||||
|
const props = (PricingTable as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('cardBg', '');
|
||||||
|
expect(props).toHaveProperty('textColor', '');
|
||||||
|
expect(props).toHaveProperty('subColor', '');
|
||||||
|
expect(props).toHaveProperty('featColor', '');
|
||||||
|
expect(props).toHaveProperty('checkColor', '');
|
||||||
|
expect(props).toHaveProperty('btnBg', '');
|
||||||
|
expect(props).toHaveProperty('btnColor', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (PricingTable as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,23 @@ interface PricingTableProps {
|
|||||||
featuredBg?: string;
|
featuredBg?: string;
|
||||||
bulletType?: string;
|
bulletType?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
/* ---- Regular (non-featured) card colors ----
|
||||||
|
All optional; each falls back to the exact literal the card was
|
||||||
|
previously hard-coded to (or, for the button, to featuredBg -- the
|
||||||
|
button's original derivation) when left unset, so existing saved
|
||||||
|
projects render pixel-identical until a color is explicitly picked. */
|
||||||
|
cardBg?: string;
|
||||||
|
textColor?: string;
|
||||||
|
subColor?: string;
|
||||||
|
featColor?: string;
|
||||||
|
checkColor?: string;
|
||||||
|
btnBg?: string;
|
||||||
|
btnColor?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulletChars: Record<string, string> = {
|
const bulletChars: Record<string, string> = {
|
||||||
@@ -61,6 +78,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
featuredBg = '#3b82f6',
|
featuredBg = '#3b82f6',
|
||||||
bulletType = 'check',
|
bulletType = 'check',
|
||||||
anchorId,
|
anchorId,
|
||||||
|
cardBg,
|
||||||
|
textColor,
|
||||||
|
subColor,
|
||||||
|
featColor,
|
||||||
|
checkColor,
|
||||||
|
btnBg,
|
||||||
|
btnColor,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -69,6 +93,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const regCardBg = cardBg || '#ffffff';
|
||||||
|
const regTextColor = textColor || '#18181b';
|
||||||
|
const regSubColor = subColor || '#64748b';
|
||||||
|
const regFeatColor = featColor || '#4b5563';
|
||||||
|
const regCheckColor = checkColor || '#10b981';
|
||||||
|
const regBtnBg = btnBg || featuredBg;
|
||||||
|
const regBtnColor = btnColor || '#ffffff';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
@@ -95,7 +127,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
flex: '1 1 280px',
|
flex: '1 1 280px',
|
||||||
maxWidth: '360px',
|
maxWidth: '360px',
|
||||||
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
|
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
|
||||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
padding: '40px 32px',
|
padding: '40px 32px',
|
||||||
@@ -127,7 +159,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
<h3 style={{
|
<h3 style={{
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
marginTop: plan.isFeatured ? '8px' : '0',
|
marginTop: plan.isFeatured ? '8px' : '0',
|
||||||
}}>
|
}}>
|
||||||
@@ -137,14 +169,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '48px',
|
fontSize: '48px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||||
lineHeight: '1',
|
lineHeight: '1',
|
||||||
}}>
|
}}>
|
||||||
{plan.price}
|
{plan.price}
|
||||||
</span>
|
</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
|
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
|
||||||
}}>
|
}}>
|
||||||
{plan.period}
|
{plan.period}
|
||||||
</span>
|
</span>
|
||||||
@@ -161,12 +193,12 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||||
<li key={fi} style={{
|
<li key={fi} style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
|
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -178,8 +210,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
marginTop: 'auto',
|
marginTop: 'auto',
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '14px 32px',
|
padding: '14px 32px',
|
||||||
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
|
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
|
||||||
color: plan.isFeatured ? featuredBg : '#ffffff',
|
color: plan.isFeatured ? featuredBg : regBtnColor,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -207,6 +239,18 @@ PricingTable.craft = {
|
|||||||
featuredBg: '#3b82f6',
|
featuredBg: '#3b82f6',
|
||||||
bulletType: 'check',
|
bulletType: 'check',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
cardBg: '',
|
||||||
|
textColor: '',
|
||||||
|
subColor: '',
|
||||||
|
featColor: '',
|
||||||
|
checkColor: '',
|
||||||
|
btnBg: '',
|
||||||
|
btnColor: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -228,16 +272,28 @@ PricingTable.craft = {
|
|||||||
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
||||||
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
||||||
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
|
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
|
||||||
|
// Sanitized -- regular (non-featured) card color overrides, all raw
|
||||||
|
// string-interpolation sinks into style="..." below. Each falls back to
|
||||||
|
// the exact literal the card was previously hard-coded to (or, for the
|
||||||
|
// button, to featuredBg) when unset, so unmodified pricing tables render
|
||||||
|
// identically to before these props existed.
|
||||||
|
const regCardBg = cssValue(props.cardBg) || '#ffffff';
|
||||||
|
const regTextColor = cssValue(props.textColor) || '#18181b';
|
||||||
|
const regSubColor = cssValue(props.subColor) || '#64748b';
|
||||||
|
const regFeatColor = cssValue(props.featColor) || '#4b5563';
|
||||||
|
const regCheckColor = cssValue(props.checkColor) || '#10b981';
|
||||||
|
const regBtnBg = cssValue(props.btnBg) || featuredBg;
|
||||||
|
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
|
||||||
|
|
||||||
const cards = plans.map((plan) => {
|
const cards = plans.map((plan) => {
|
||||||
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
|
||||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||||
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
|
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
|
||||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
|
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
|
||||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
|
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
|
||||||
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
|
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
|
||||||
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
|
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
|
||||||
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
|
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
|
||||||
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
||||||
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,14 @@ describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-co
|
|||||||
expect(html1).toBe(html2);
|
expect(html1).toBe(html2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Tabs.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (Tabs as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ interface TabsProps {
|
|||||||
inactiveTabColor?: string;
|
inactiveTabColor?: string;
|
||||||
contentBg?: string;
|
contentBg?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TabItem[] = [
|
const defaultTabs: TabItem[] = [
|
||||||
@@ -114,6 +119,11 @@ Tabs.craft = {
|
|||||||
inactiveTabColor: '#64748b',
|
inactiveTabColor: '#64748b',
|
||||||
contentBg: '#ffffff',
|
contentBg: '#ffffff',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -71,3 +71,14 @@ describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `ratin
|
|||||||
expect(html).toContain('aria-label="Rating: 4 out of 5"');
|
expect(html).toContain('aria-label="Rating: 4 out of 5"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Testimonials.craft.props includes the box-model/animation/visibility rollout props', () => {
|
||||||
|
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
|
||||||
|
const props = (Testimonials as any).craft.props;
|
||||||
|
expect(props).toHaveProperty('animation', '');
|
||||||
|
expect(props).toHaveProperty('animationDelay', '');
|
||||||
|
expect(props).toHaveProperty('hideOnDesktop', false);
|
||||||
|
expect(props).toHaveProperty('hideOnTablet', false);
|
||||||
|
expect(props).toHaveProperty('hideOnMobile', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ interface TestimonialsProps {
|
|||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
starColor?: string;
|
starColor?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTestimonials: Testimonial[] = [
|
const defaultTestimonials: Testimonial[] = [
|
||||||
@@ -131,6 +136,11 @@ Testimonials.craft = {
|
|||||||
cardBg: '#f8fafc',
|
cardBg: '#f8fafc',
|
||||||
starColor: '#f59e0b',
|
starColor: '#f59e0b',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
|
|||||||
tablet: '768px',
|
tablet: '768px',
|
||||||
mobile: '375px',
|
mobile: '375px',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------- ENH-Foundation shared presets ----------
|
||||||
|
Consumed by the reusable StylePanel controls in
|
||||||
|
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
|
||||||
|
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
|
||||||
|
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
|
||||||
|
|
||||||
|
// Width/height preset row used by SizeControl (image + video sizing).
|
||||||
|
export const SIZE_PRESETS = [
|
||||||
|
{ label: '25%', value: '25%' },
|
||||||
|
{ label: '50%', value: '50%' },
|
||||||
|
{ label: '75%', value: '75%' },
|
||||||
|
{ label: '100%', value: '100%' },
|
||||||
|
{ label: 'Auto', value: 'auto' },
|
||||||
|
{ label: 'Full', value: '100vw' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
|
||||||
|
export const ASPECT_RATIOS = [
|
||||||
|
{ label: 'Original', value: '' },
|
||||||
|
{ label: '1:1', value: '1 / 1' },
|
||||||
|
{ label: '4:3', value: '4 / 3' },
|
||||||
|
{ label: '3:2', value: '3 / 2' },
|
||||||
|
{ label: '16:9', value: '16 / 9' },
|
||||||
|
{ label: '4:5', value: '4 / 5' },
|
||||||
|
{ label: '9:16', value: '9 / 16' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
|
||||||
|
export const SHADOW_PRESETS = [
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
|
||||||
|
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
|
||||||
|
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
|
||||||
|
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LINE_HEIGHTS = [
|
||||||
|
{ label: 'Tight', value: '1.1' },
|
||||||
|
{ label: 'Snug', value: '1.25' },
|
||||||
|
{ label: 'Normal', value: '1.5' },
|
||||||
|
{ label: 'Relaxed', value: '1.75' },
|
||||||
|
{ label: 'Loose', value: '2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LETTER_SPACINGS = [
|
||||||
|
{ label: 'Tight', value: '-0.02em' },
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Wide', value: '0.05em' },
|
||||||
|
{ label: 'Wider', value: '0.1em' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OBJECT_FIT = [
|
||||||
|
{ label: 'Cover', value: 'cover' },
|
||||||
|
{ label: 'Contain', value: 'contain' },
|
||||||
|
{ label: 'Fill', value: 'fill' },
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BORDER_STYLES = [
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Solid', value: 'solid' },
|
||||||
|
{ label: 'Dashed', value: 'dashed' },
|
||||||
|
{ label: 'Dotted', value: 'dotted' },
|
||||||
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DeviceMode } from '../types';
|
|||||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||||
|
|
||||||
interface CanvasProps {
|
interface CanvasProps {
|
||||||
device: DeviceMode;
|
device: DeviceMode;
|
||||||
@@ -135,6 +136,42 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
|||||||
|
|
||||||
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
|
||||||
|
|
||||||
|
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
|
||||||
|
// tall, just above the tab bar), it covers whatever content was at the
|
||||||
|
// very bottom of the canvas's own scroll area -- on a short page, the
|
||||||
|
// last section could sit permanently under the toolbar with no way to
|
||||||
|
// scroll past it. Mirrors that toolbar's own visibility condition
|
||||||
|
// (`selectedId && activeSheet === null`) exactly, computed independently
|
||||||
|
// here since Canvas has no other reason to depend on the toolbar
|
||||||
|
// component itself.
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { activeSheet } = useMobileChrome();
|
||||||
|
const { selectedId } = useEditor((state) => {
|
||||||
|
const selected = state.events.selected;
|
||||||
|
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
|
||||||
|
return { selectedId: id && id !== 'ROOT' ? id : null };
|
||||||
|
});
|
||||||
|
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
|
||||||
|
|
||||||
|
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
|
||||||
|
// selected node up towards the top of the canvas so it stays visible
|
||||||
|
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
|
||||||
|
// by the canvas's own natural scroll range. For a selected node near the
|
||||||
|
// END of the content (very plausibly the last section on the page --
|
||||||
|
// exactly the kind of node someone just added/duplicated and wants to
|
||||||
|
// style), there may not be enough scrollable distance below it to bring
|
||||||
|
// its top all the way up to the visible band above the sheet; the browser
|
||||||
|
// simply clamps at its existing max scrollTop, leaving the node's top
|
||||||
|
// stuck behind the sheet with nothing anyone can do about it (verified
|
||||||
|
// live: the last section of a page landed under the sheet even after the
|
||||||
|
// scroll-into-view ran). Pad the canvas with a full extra viewport's
|
||||||
|
// worth of scroll room while the sheet is open with a selection, exactly
|
||||||
|
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
|
||||||
|
// fixed toolbar -- generous enough that ANY node, including the very
|
||||||
|
// last one, can always be scrolled with its top reaching the very top of
|
||||||
|
// the canvas (comfortably within the "top ~30%" target).
|
||||||
|
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
|
||||||
|
|
||||||
const frameStyle = isEditingHeader
|
const frameStyle = isEditingHeader
|
||||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||||
: isEditingFooter
|
: isEditingFooter
|
||||||
@@ -144,7 +181,11 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
|||||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-canvas">
|
<div
|
||||||
|
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
|
||||||
|
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { TopBar } from '../panels/topbar/TopBar';
|
|||||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||||
import { RightPanel } from '../panels/right/RightPanel';
|
import { RightPanel } from '../panels/right/RightPanel';
|
||||||
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
||||||
|
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
|
||||||
import { Canvas } from './Canvas';
|
import { Canvas } from './Canvas';
|
||||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||||
import { useContextMenu } from '../hooks/useContextMenu';
|
import { useContextMenu } from '../hooks/useContextMenu';
|
||||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||||
|
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||||
import { DeviceMode } from '../types';
|
import { DeviceMode } from '../types';
|
||||||
|
|
||||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||||
@@ -82,29 +84,32 @@ export const EditorShell: React.FC = () => {
|
|||||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||||
// identically to before (same booleans, just sourced from context).
|
// identically to before (same booleans, just sourced from context).
|
||||||
<MobileChromeProvider>
|
<MobileChromeProvider>
|
||||||
<div className="editor-app">
|
<LayerFocusProvider>
|
||||||
<TopBar
|
<div className="editor-app">
|
||||||
device={device}
|
<TopBar
|
||||||
onDeviceChange={setDevice}
|
device={device}
|
||||||
showGuides={showGuides}
|
onDeviceChange={setDevice}
|
||||||
onToggleGuides={() => setShowGuides(!showGuides)}
|
showGuides={showGuides}
|
||||||
/>
|
onToggleGuides={() => setShowGuides(!showGuides)}
|
||||||
<div className="editor-container">
|
/>
|
||||||
{!isMobile && <LeftPanel />}
|
<div className="editor-container">
|
||||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
{!isMobile && <LeftPanel />}
|
||||||
<Canvas device={device} showGuides={showGuides} />
|
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||||
|
<Canvas device={device} showGuides={showGuides} />
|
||||||
|
</div>
|
||||||
|
{!isMobile && <RightPanel />}
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && <RightPanel />}
|
{isMobile && <MobilePanelBar />}
|
||||||
|
{isMobile && <MobileSelectionToolbar />}
|
||||||
|
<ContextMenu
|
||||||
|
visible={menuState.visible}
|
||||||
|
x={menuState.x}
|
||||||
|
y={menuState.y}
|
||||||
|
nodeId={menuState.nodeId}
|
||||||
|
onClose={hideMenu}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isMobile && <MobilePanelBar />}
|
</LayerFocusProvider>
|
||||||
<ContextMenu
|
|
||||||
visible={menuState.visible}
|
|
||||||
x={menuState.x}
|
|
||||||
y={menuState.y}
|
|
||||||
nodeId={menuState.nodeId}
|
|
||||||
onClose={hideMenu}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</MobileChromeProvider>
|
</MobileChromeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +1,95 @@
|
|||||||
import { describe, test, expect, afterEach } from 'vitest';
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import type { NodeTree } from '@craftjs/core';
|
||||||
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
|
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
|
||||||
|
return {
|
||||||
|
rootNodeId: rootId,
|
||||||
|
nodes: {
|
||||||
|
[rootId]: {
|
||||||
|
id: rootId,
|
||||||
|
data: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
name: 'Container',
|
||||||
|
displayName: 'Container',
|
||||||
|
props,
|
||||||
|
custom: {},
|
||||||
|
isCanvas: false,
|
||||||
|
parent: 'wherever-it-originally-lived',
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
info: {},
|
||||||
|
events: { selected: false, dragged: false, hovered: false },
|
||||||
|
dom: null,
|
||||||
|
related: {},
|
||||||
|
rules: {},
|
||||||
|
_hydrationTimestamp: 0,
|
||||||
|
} as unknown as NodeTree['nodes'][string],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('clipboard', () => {
|
describe('clipboard', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('starts empty', () => {
|
test('starts empty', () => {
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('set then get returns the stored node id', () => {
|
test('set then get returns a tree with the same root id and shape', () => {
|
||||||
setClipboardNodeId('node-123');
|
const tree = makeTree('node-123', { text: 'hello' });
|
||||||
expect(getClipboardNodeId()).toBe('node-123');
|
setClipboardTree(tree);
|
||||||
|
const got = getClipboardTree();
|
||||||
|
expect(got).not.toBeNull();
|
||||||
|
expect(got!.rootNodeId).toBe('node-123');
|
||||||
|
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||||
setClipboardNodeId('first');
|
setClipboardTree(makeTree('first'));
|
||||||
setClipboardNodeId('second');
|
setClipboardTree(makeTree('second'));
|
||||||
expect(getClipboardNodeId()).toBe('second');
|
expect(getClipboardTree()!.rootNodeId).toBe('second');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be cleared back to null', () => {
|
test('can be cleared back to null', () => {
|
||||||
setClipboardNodeId('node-123');
|
setClipboardTree(makeTree('node-123'));
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
|
||||||
|
const original = makeTree('node-123', { text: 'original' });
|
||||||
|
setClipboardTree(original);
|
||||||
|
|
||||||
|
// Mutate the original tree's props object directly (as if the source
|
||||||
|
// node were edited, or the same live node got copied again).
|
||||||
|
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
|
||||||
|
|
||||||
|
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
|
||||||
|
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
|
||||||
|
setClipboardTree(original);
|
||||||
|
|
||||||
|
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
|
||||||
|
|
||||||
|
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
|
||||||
|
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
|
||||||
|
setClipboardTree(tree);
|
||||||
|
tree = null; // simulate the original page's node/tree going away entirely
|
||||||
|
|
||||||
|
const got = getClipboardTree();
|
||||||
|
expect(got).not.toBeNull();
|
||||||
|
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Node, NodeId, NodeTree } from '@craftjs/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tiny shared clipboard for canvas node copy/paste.
|
* Tiny shared clipboard for canvas node copy/paste.
|
||||||
*
|
*
|
||||||
@@ -9,15 +11,71 @@
|
|||||||
* Deliberately not React state -- nothing in the UI needs to re-render
|
* Deliberately not React state -- nothing in the UI needs to re-render
|
||||||
* reactively when the clipboard changes; consumers just read the current
|
* reactively when the clipboard changes; consumers just read the current
|
||||||
* value at the moment they need it (on paste, or when a menu opens).
|
* value at the moment they need it (on paste, or when a menu opens).
|
||||||
|
*
|
||||||
|
* Historical bug (cross-page copy/paste): this used to store only the copied
|
||||||
|
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
|
||||||
|
* at paste time. That works fine same-page, but the moment the user switches
|
||||||
|
* pages the canvas is re-deserialized to the target page's Craft.js state --
|
||||||
|
* the copied id no longer exists in `query` at all -- so a cross-page paste
|
||||||
|
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
|
||||||
|
* TREE SNAPSHOT at copy time instead means paste never needs to look the
|
||||||
|
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
|
||||||
|
* `actions.addNodeTree`, which works identically regardless of which page's
|
||||||
|
* state is currently loaded on the canvas.
|
||||||
*/
|
*/
|
||||||
let clipboardNodeId: string | null = null;
|
let clipboardTree: NodeTree | null = null;
|
||||||
|
|
||||||
/** Returns the id of the node currently on the clipboard, or null if empty. */
|
/**
|
||||||
export function getClipboardNodeId(): string | null {
|
* Deep, detached clone of a live Craft.js `NodeTree` (as returned by
|
||||||
return clipboardNodeId;
|
* `query.node(id).toNodeTree()`).
|
||||||
|
*
|
||||||
|
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
|
||||||
|
* `data.type` is the actual component function/class reference (not a
|
||||||
|
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
|
||||||
|
* function and throws `DataCloneError` (see the identical note on
|
||||||
|
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
|
||||||
|
* historically). `type` is a stable reference shared by every node of that
|
||||||
|
* component across the whole app (it doesn't change per page), so it's safe
|
||||||
|
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
|
||||||
|
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
|
||||||
|
* subsequent paste's `setProp`, or a fresh copy of the same live node)
|
||||||
|
* can never reach back into this stored snapshot.
|
||||||
|
*/
|
||||||
|
function cloneNodeTree(tree: NodeTree): NodeTree {
|
||||||
|
const nodes: Record<NodeId, Node> = {};
|
||||||
|
for (const [id, node] of Object.entries(tree.nodes)) {
|
||||||
|
nodes[id] = {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
props: structuredClone(node.data.props),
|
||||||
|
custom: structuredClone(node.data.custom),
|
||||||
|
nodes: [...(node.data.nodes || [])],
|
||||||
|
linkedNodes: { ...(node.data.linkedNodes || {}) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { rootNodeId: tree.rootNodeId, nodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets (or clears, with `null`) the node id on the clipboard. */
|
/**
|
||||||
export function setClipboardNodeId(nodeId: string | null): void {
|
* Returns the tree snapshot currently on the clipboard, or null if empty.
|
||||||
clipboardNodeId = nodeId;
|
* The returned tree is safe to hand straight to `regenerateTreeIds` --
|
||||||
|
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
|
||||||
|
* same clipboard contents (including across a page switch) all work off the
|
||||||
|
* same untouched snapshot.
|
||||||
|
*/
|
||||||
|
export function getClipboardTree(): NodeTree | null {
|
||||||
|
return clipboardTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
|
||||||
|
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
|
||||||
|
* detached from the live Craft.js node it was captured from -- it survives
|
||||||
|
* that node being deleted, mutated, or (the whole point) the canvas being
|
||||||
|
* re-deserialized to a different page entirely.
|
||||||
|
*/
|
||||||
|
export function setClipboardTree(tree: NodeTree | null): void {
|
||||||
|
clipboardTree = tree ? cloneNodeTree(tree) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import type { NodeTree, Node } from '@craftjs/core';
|
import type { NodeTree, Node } from '@craftjs/core';
|
||||||
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||||
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
|||||||
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||||
* input-focus guard.
|
* input-focus guard.
|
||||||
*
|
*
|
||||||
|
* Also covers the cross-page clipboard fix: copy stores a detached TREE
|
||||||
|
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
|
||||||
|
* to re-resolve the original node via `query.node(id)`, which is exactly
|
||||||
|
* what breaks once the canvas has been re-deserialized to a different page
|
||||||
|
* (see `hooks/clipboard.ts` and the cross-page integration test in
|
||||||
|
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
|
||||||
|
*
|
||||||
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||||
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||||
* mounted via a bare consumer component, with REAL `keydown` events
|
* mounted via a bare consumer component, with REAL `keydown` events
|
||||||
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const addNodeTreeMock = vi.fn();
|
const addNodeTreeMock = vi.fn();
|
||||||
|
const selectNodeMock = vi.fn();
|
||||||
let selectedIds: string[] = [];
|
let selectedIds: string[] = [];
|
||||||
|
|
||||||
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||||
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
|
||||||
'selected-1': { data: { parent: 'parent-container-1' } },
|
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||||
ROOT: { data: { parent: null } },
|
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
|
||||||
|
ROOT: { data: { parent: null, nodes: [] } },
|
||||||
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
addNodeTree: addNodeTreeMock,
|
addNodeTree: addNodeTreeMock,
|
||||||
|
selectNode: selectNodeMock,
|
||||||
history: { undo: vi.fn(), redo: vi.fn() },
|
history: { undo: vi.fn(), redo: vi.fn() },
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
clearEvents: vi.fn(),
|
clearEvents: vi.fn(),
|
||||||
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
selectedIds = [];
|
selectedIds = [];
|
||||||
addNodeTreeMock.mockClear();
|
addNodeTreeMock.mockClear();
|
||||||
|
selectNodeMock.mockClear();
|
||||||
regenerateTreeIdsMock.mockClear();
|
regenerateTreeIdsMock.mockClear();
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
if (root) unmount();
|
if (root) unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||||
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
selectedIds = ['selected-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
|
|
||||||
expect(getClipboardNodeId()).toBe('selected-1');
|
const clip = getClipboardTree();
|
||||||
|
expect(clip).not.toBeNull();
|
||||||
|
expect(clip!.rootNodeId).toBe('copied-root-1');
|
||||||
|
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
|
|
||||||
selectedIds = ['copied-root-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBe('copied-root-1');
|
expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
|
||||||
|
|
||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
pressKey('v');
|
pressKey('v');
|
||||||
|
|
||||||
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||||
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
|
||||||
|
|
||||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
|
|
||||||
// Sibling of the current selection: selected-1's data.parent.
|
// Sibling of the current selection: selected-1's data.parent.
|
||||||
expect(targetParent).toBe('parent-container-1');
|
expect(targetParent).toBe('parent-container-1');
|
||||||
|
// Immediately after selected-1 (index 1 among parent-container-1's
|
||||||
|
// children), matching duplicate()'s "insert right after" UX.
|
||||||
|
expect(insertIndex).toBe(2);
|
||||||
|
|
||||||
// The regression this guards: pasted ids must be fresh, never reuse the
|
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||||
// ids the copied node already occupies in the live Craft.js tree.
|
// ids the copied node already occupies in the live Craft.js tree.
|
||||||
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
for (const id of pastedIds) {
|
for (const id of pastedIds) {
|
||||||
expect(originalIds.has(id)).toBe(false);
|
expect(originalIds.has(id)).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The new copy is selected, same as duplicate()'s existing UX.
|
||||||
|
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
|
|
||||||
selectedIds = ['copied-root-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
pressKey('v');
|
pressKey('v');
|
||||||
|
|
||||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
expect(targetParent).toBe('ROOT');
|
expect(targetParent).toBe('ROOT');
|
||||||
|
expect(insertIndex).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
selectedIds = [];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
|
expect(targetParent).toBe('ROOT');
|
||||||
|
expect(insertIndex).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||||
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
|
||||||
setClipboardNodeId('copied-root-1');
|
setClipboardTree(COPIED_TREE);
|
||||||
pressKey('v');
|
pressKey('v');
|
||||||
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
|
||||||
activeElementSpy.mockRestore();
|
activeElementSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { findDeletableTarget } from '../utils/craft-helpers';
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||||
import { regenerateTreeIds } from '../utils/craft-tree';
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
function isInputFocused(): boolean {
|
function isInputFocused(): boolean {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+C: copy selected node id to the shared clipboard
|
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
|
||||||
|
// just its id -- see clipboard.ts for why: an id-based clipboard can't
|
||||||
|
// survive a page switch, since the copied id no longer exists in
|
||||||
|
// `query` once the canvas is re-deserialized to a different page).
|
||||||
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const selected = query.getEvent('selected').all();
|
const selected = query.getEvent('selected').all();
|
||||||
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||||
setClipboardNodeId(selected[0]);
|
setClipboardTree(query.node(selected[0]).toNodeTree());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Copy failed:', err);
|
console.error('Copy failed:', err);
|
||||||
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V: paste the clipboard node as a sibling of the current selection
|
// Ctrl+V: paste the clipboard tree as a sibling of the current
|
||||||
|
// selection (immediately after it, matching duplicate()'s UX), or
|
||||||
|
// append to ROOT when nothing is selected. Works regardless of which
|
||||||
|
// page is currently on the canvas -- the clipboard tree is a detached
|
||||||
|
// snapshot, not a reference to a node that may no longer exist here.
|
||||||
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const sourceId = getClipboardNodeId();
|
const clip = getClipboardTree();
|
||||||
if (!sourceId || !query.node(sourceId).get()) return;
|
if (!clip) return;
|
||||||
|
|
||||||
const selected = query.getEvent('selected').all();
|
const selected = query.getEvent('selected').all();
|
||||||
if (selected.length === 0) return;
|
const selectedId = selected.length > 0 ? selected[0] : null;
|
||||||
const selectedId = selected[0];
|
|
||||||
|
|
||||||
let targetParent = 'ROOT';
|
let targetParentId = 'ROOT';
|
||||||
if (selectedId !== 'ROOT') {
|
let insertIndex: number | undefined;
|
||||||
|
if (selectedId && selectedId !== 'ROOT') {
|
||||||
const node = query.node(selectedId).get();
|
const node = query.node(selectedId).get();
|
||||||
targetParent = node?.data?.parent || 'ROOT';
|
const parentId: string | null | undefined = node?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
targetParentId = parentId;
|
||||||
|
try {
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(selectedId);
|
||||||
|
if (idx !== -1) insertIndex = idx + 1;
|
||||||
|
} catch {
|
||||||
|
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
const tree = regenerateTreeIds(clip);
|
||||||
actions.addNodeTree(tree, targetParent);
|
if (insertIndex !== undefined) {
|
||||||
|
actions.addNodeTree(tree, targetParentId, insertIndex);
|
||||||
|
} else {
|
||||||
|
actions.addNodeTree(tree, targetParentId);
|
||||||
|
}
|
||||||
|
actions.selectNode(tree.rootNodeId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Paste failed:', err);
|
console.error('Paste failed:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import type { NodeTree, Node } from '@craftjs/core';
|
||||||
|
import { useNodeActions, type NodeActions } from './useNodeActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so
|
||||||
|
* the desktop right-click menu and the mobile selection toolbar
|
||||||
|
* (`MobileSelectionToolbar`) share one implementation. This suite exercises
|
||||||
|
* the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the
|
||||||
|
* pattern in `useKeyboardShortcuts.test.tsx`), covering:
|
||||||
|
* - moveUp/moveDown call `actions.move` with the right target index, and
|
||||||
|
* are no-ops at the respective boundary
|
||||||
|
* - canMoveUp/canMoveDown reflect the node's position among its siblings
|
||||||
|
* - duplicate regenerates ids, inserts the copy IMMEDIATELY AFTER the
|
||||||
|
* source node (`actions.addNodeTree(tree, parentId, sourceIndex + 1)`),
|
||||||
|
* and selects the new copy (`actions.selectNode(tree.rootNodeId)`) --
|
||||||
|
* Phase B fast-follow: the previous append-at-end behavior left the copy
|
||||||
|
* off-screen with the ORIGINAL still selected, so users couldn't see
|
||||||
|
* what "Duplicate" had just done.
|
||||||
|
* - selectParent calls `actions.selectNode` with the parent id
|
||||||
|
* - canSelectParent reflects whether the node's parent is itself real
|
||||||
|
* (not ROOT/missing) -- false at a top-level section, true one level in
|
||||||
|
* - deleteNode routes through `findDeletableTarget` (also covering the
|
||||||
|
* "no deletable target" no-op)
|
||||||
|
* - ROOT/null nodeId disables every action safely (no-ops, all
|
||||||
|
* canMoveUp/canMoveDown/canDelete/canSelectParent false)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const moveMock = vi.fn();
|
||||||
|
const addNodeTreeMock = vi.fn();
|
||||||
|
const selectNodeMock = vi.fn();
|
||||||
|
const deleteMock = vi.fn();
|
||||||
|
|
||||||
|
interface FakeNodeData {
|
||||||
|
parent: string | null;
|
||||||
|
nodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fakeNodes: Record<string, { data: FakeNodeData }> = {};
|
||||||
|
|
||||||
|
const COPIED_TREE: NodeTree = {
|
||||||
|
rootNodeId: 'child-1',
|
||||||
|
nodes: {
|
||||||
|
'child-1': {
|
||||||
|
id: 'child-1',
|
||||||
|
data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
|
||||||
|
info: {},
|
||||||
|
events: { selected: false, dragged: false, hovered: false },
|
||||||
|
dom: null,
|
||||||
|
related: {},
|
||||||
|
rules: {},
|
||||||
|
_hydrationTimestamp: 0,
|
||||||
|
} as unknown as Node,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeQuery() {
|
||||||
|
return {
|
||||||
|
node: (id: string) => ({
|
||||||
|
get: () => fakeNodes[id] ?? null,
|
||||||
|
toNodeTree: () => {
|
||||||
|
if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
|
||||||
|
return COPIED_TREE;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: (collector?: (state: any, query: any) => any) => {
|
||||||
|
const state = {
|
||||||
|
nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
|
||||||
|
};
|
||||||
|
const query = makeQuery();
|
||||||
|
const collected = collector ? collector(state, query) : {};
|
||||||
|
return {
|
||||||
|
...collected,
|
||||||
|
actions: {
|
||||||
|
move: moveMock,
|
||||||
|
addNodeTree: addNodeTreeMock,
|
||||||
|
selectNode: selectNodeMock,
|
||||||
|
delete: deleteMock,
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/craft-tree', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
|
||||||
|
return { ...actual, regenerateTreeIds: vi.fn(actual.regenerateTreeIds) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
|
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let captured: NodeActions | null = null;
|
||||||
|
|
||||||
|
const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
|
||||||
|
captured = useNodeActions(nodeId);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(nodeId: string | null) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<Probe nodeId={nodeId} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerender(nodeId: string | null) {
|
||||||
|
act(() => {
|
||||||
|
root.render(<Probe nodeId={nodeId} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
moveMock.mockClear();
|
||||||
|
addNodeTreeMock.mockClear();
|
||||||
|
selectNodeMock.mockClear();
|
||||||
|
deleteMock.mockClear();
|
||||||
|
regenerateTreeIdsMock.mockClear();
|
||||||
|
captured = null;
|
||||||
|
fakeNodes = {
|
||||||
|
ROOT: { data: { parent: null, nodes: ['parent-1'] } },
|
||||||
|
'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
|
||||||
|
'child-0': { data: { parent: 'parent-1', nodes: [] } },
|
||||||
|
'child-1': { data: { parent: 'parent-1', nodes: [] } },
|
||||||
|
'child-2': { data: { parent: 'parent-1', nodes: [] } },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useNodeActions', () => {
|
||||||
|
test('a middle child can move both up and down', () => {
|
||||||
|
render('child-1');
|
||||||
|
expect(captured!.canMoveUp).toBe(true);
|
||||||
|
expect(captured!.canMoveDown).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the first child cannot move up, but can move down', () => {
|
||||||
|
render('child-0');
|
||||||
|
expect(captured!.canMoveUp).toBe(false);
|
||||||
|
expect(captured!.canMoveDown).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last child can move up, but not down', () => {
|
||||||
|
render('child-2');
|
||||||
|
expect(captured!.canMoveUp).toBe(true);
|
||||||
|
expect(captured!.canMoveDown).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
|
||||||
|
render('child-1');
|
||||||
|
act(() => captured!.moveUp());
|
||||||
|
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
|
||||||
|
|
||||||
|
moveMock.mockClear();
|
||||||
|
act(() => captured!.moveDown());
|
||||||
|
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveUp/moveDown at a boundary are no-ops', () => {
|
||||||
|
render('child-0');
|
||||||
|
act(() => captured!.moveUp());
|
||||||
|
expect(moveMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender('child-2');
|
||||||
|
act(() => captured!.moveDown());
|
||||||
|
expect(moveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate inserts the copy immediately after the source (parentId, sourceIndex + 1) and selects it', () => {
|
||||||
|
render('child-1');
|
||||||
|
let returned: string | null | undefined;
|
||||||
|
act(() => {
|
||||||
|
returned = captured!.duplicate();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [tree, parentId, index] = addNodeTreeMock.mock.calls[0];
|
||||||
|
// child-1 is at index 1 among ['child-0', 'child-1', 'child-2'] -> insert at 2.
|
||||||
|
expect(parentId).toBe('parent-1');
|
||||||
|
expect(index).toBe(2);
|
||||||
|
expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
|
||||||
|
|
||||||
|
expect(selectNodeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(selectNodeMock).toHaveBeenCalledWith(tree.rootNodeId);
|
||||||
|
expect(returned).toBe(tree.rootNodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectParent calls actions.selectNode with the parent id', () => {
|
||||||
|
render('child-1');
|
||||||
|
act(() => captured!.selectParent());
|
||||||
|
expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canSelectParent is false at a top-level node (parent is ROOT), true one level deeper', () => {
|
||||||
|
render('parent-1'); // parent-1's own parent is 'ROOT'
|
||||||
|
expect(captured!.canSelectParent).toBe(false);
|
||||||
|
|
||||||
|
rerender('child-1'); // child-1's parent is 'parent-1', a real node
|
||||||
|
expect(captured!.canSelectParent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteNode calls actions.delete with the node id when deletable', () => {
|
||||||
|
render('child-1');
|
||||||
|
expect(captured!.canDelete).toBe(true);
|
||||||
|
act(() => captured!.deleteNode());
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith('child-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ROOT nodeId disables every action and is a safe no-op', () => {
|
||||||
|
render('ROOT');
|
||||||
|
expect(captured!.canMoveUp).toBe(false);
|
||||||
|
expect(captured!.canMoveDown).toBe(false);
|
||||||
|
expect(captured!.canDelete).toBe(false);
|
||||||
|
expect(captured!.canSelectParent).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
captured!.moveUp();
|
||||||
|
captured!.moveDown();
|
||||||
|
captured!.duplicate();
|
||||||
|
captured!.selectParent();
|
||||||
|
captured!.deleteNode();
|
||||||
|
});
|
||||||
|
expect(moveMock).not.toHaveBeenCalled();
|
||||||
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
expect(selectNodeMock).not.toHaveBeenCalled();
|
||||||
|
expect(deleteMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null nodeId disables every action and is a safe no-op', () => {
|
||||||
|
render(null);
|
||||||
|
expect(captured!.canMoveUp).toBe(false);
|
||||||
|
expect(captured!.canMoveDown).toBe(false);
|
||||||
|
expect(captured!.canDelete).toBe(false);
|
||||||
|
expect(captured!.canSelectParent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||||
|
|
||||||
|
export interface NodeActions {
|
||||||
|
moveUp: () => void;
|
||||||
|
moveDown: () => void;
|
||||||
|
/** Duplicates `nodeId`, inserting the copy immediately after the source in
|
||||||
|
* the parent's children and selecting it. Returns the new node's id (so
|
||||||
|
* callers -- e.g. `MobileSelectionToolbar` -- can scroll it into view),
|
||||||
|
* or null if the duplicate could not be performed (no-op for ROOT/null,
|
||||||
|
* or a caught error). */
|
||||||
|
duplicate: () => string | null;
|
||||||
|
deleteNode: () => void;
|
||||||
|
selectParent: () => void;
|
||||||
|
/** True if `nodeId` has an earlier sibling under the same parent (so
|
||||||
|
* `moveUp` would actually move it). False for ROOT/null/no-parent. */
|
||||||
|
canMoveUp: boolean;
|
||||||
|
/** True if `nodeId` has a later sibling under the same parent (so
|
||||||
|
* `moveDown` would actually move it). False for ROOT/null/no-parent. */
|
||||||
|
canMoveDown: boolean;
|
||||||
|
/** True if `findDeletableTarget` resolves to a real, deletable node (either
|
||||||
|
* `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node
|
||||||
|
* slot whose siblings are all also empty -- see `craft-helpers.ts`). */
|
||||||
|
canDelete: boolean;
|
||||||
|
/** True if `nodeId` has a real parent to select -- i.e. the parent is
|
||||||
|
* neither ROOT nor missing. `selectParent` on a top-level section (whose
|
||||||
|
* parent IS 'ROOT') would only select the page-wide ROOT node, which has
|
||||||
|
* no on-canvas outline and no toolbar of its own -- a dead end for a
|
||||||
|
* mobile user with no way back. False for ROOT/null/no-parent too. */
|
||||||
|
canSelectParent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared node-action logic (move up/down, duplicate, delete, select parent)
|
||||||
|
* extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click
|
||||||
|
* menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`)
|
||||||
|
* drive the exact same behavior from one place instead of two independent
|
||||||
|
* copies drifting apart.
|
||||||
|
*
|
||||||
|
* IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor`
|
||||||
|
* collector (`@craftjs/utils`' `useCollector`) only synchronously computes
|
||||||
|
* the collector function ONCE, on this hook's very first mount. After that,
|
||||||
|
* it updates purely in reaction to the underlying store's OWN change
|
||||||
|
* notifications, invoking whatever collector closure is current AT THAT
|
||||||
|
* NOTIFICATION -- so a collector that closes over `nodeId` (an argument that
|
||||||
|
* changes across renders of the SAME mounted hook instance, e.g. every time
|
||||||
|
* `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale
|
||||||
|
* for exactly one render: the cached value from the last store notification
|
||||||
|
* (computed against the PREVIOUS nodeId) is what gets returned, until some
|
||||||
|
* unrelated store event happens to trigger a fresh computation. An earlier
|
||||||
|
* version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside
|
||||||
|
* such a collector and was caught showing the PREVIOUS selection's move
|
||||||
|
* boundaries in the mobile toolbar for one render after tapping a new node
|
||||||
|
* (verified with real Playwright touch taps against a real Craft.js
|
||||||
|
* document -- a plain mocked `useEditor` in a unit test doesn't reproduce
|
||||||
|
* this, since a hand-rolled mock has no reason to replicate the real
|
||||||
|
* library's caching).
|
||||||
|
*
|
||||||
|
* The fix: get `actions`/`query` from a collector-FREE `useEditor()` call
|
||||||
|
* (an always-live reference, same pattern `ContextMenu.tsx` used before this
|
||||||
|
* extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain
|
||||||
|
* synchronous code during render using that live `query` -- never cached.
|
||||||
|
*
|
||||||
|
* That still leaves one gap: `nodeId` staying the SAME across renders while
|
||||||
|
* its position changes (e.g. tapping "Move Up" repeatedly on the same
|
||||||
|
* still-selected node) needs SOMETHING to trigger a re-render so the boundary
|
||||||
|
* flags below get recomputed. An earlier version of this hook forced that
|
||||||
|
* via a second `useEditor((state) => ({ _: state.nodes }))` subscription --
|
||||||
|
* but subscribing to the WHOLE node map reacts to every `dom` ref
|
||||||
|
* assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM`
|
||||||
|
* synchronously from a React ref callback during COMMIT, i.e. while some
|
||||||
|
* OTHER component is still mounting), which trips React's "Cannot update a
|
||||||
|
* component while rendering a different component" warning the moment a
|
||||||
|
* freshly-added container with children mounts. Callers that need
|
||||||
|
* `canMoveUp`/`canMoveDown` to refresh after a move they themselves
|
||||||
|
* triggered (`MobileSelectionToolbar`) should instead bump their OWN local
|
||||||
|
* state right after calling `moveUp`/`moveDown` -- a plain, local,
|
||||||
|
* event-handler-triggered re-render, not a store-wide subscription.
|
||||||
|
*/
|
||||||
|
export function useNodeActions(nodeId: string | null | undefined): NodeActions {
|
||||||
|
const { actions, query } = useEditor();
|
||||||
|
|
||||||
|
const isRootOrNull = !nodeId || nodeId === 'ROOT';
|
||||||
|
|
||||||
|
let canMoveUp = false;
|
||||||
|
let canMoveDown = false;
|
||||||
|
let canSelectParent = false;
|
||||||
|
if (!isRootOrNull) {
|
||||||
|
try {
|
||||||
|
const node = query.node(nodeId).get();
|
||||||
|
const parentId: string | null | undefined = node?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
canSelectParent = parentId !== 'ROOT';
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(nodeId);
|
||||||
|
canMoveUp = idx > 0;
|
||||||
|
canMoveDown = idx !== -1 && idx < siblings.length - 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Node no longer exists (e.g. deleted out from under a stale
|
||||||
|
// reference) -- leave both false.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId);
|
||||||
|
|
||||||
|
const getParentId = (): string | null => {
|
||||||
|
if (!nodeId) return null;
|
||||||
|
try {
|
||||||
|
const node = query.node(nodeId).get();
|
||||||
|
return node?.data?.parent || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const duplicate = (): string | null => {
|
||||||
|
if (!nodeId || nodeId === 'ROOT') return null;
|
||||||
|
try {
|
||||||
|
const parentId = getParentId();
|
||||||
|
if (!parentId) return null;
|
||||||
|
|
||||||
|
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
||||||
|
|
||||||
|
// Insert the copy IMMEDIATELY AFTER the source node, not appended at
|
||||||
|
// the end of the parent -- on a top-level section, "append at end"
|
||||||
|
// landed the copy at the bottom of the page, off-screen, with the
|
||||||
|
// ORIGINAL still selected, so a duplicate looked like nothing had
|
||||||
|
// happened at all. Resolve the source's index among its siblings so
|
||||||
|
// the copy lands right next to what it was copied from; if that can't
|
||||||
|
// be resolved for any reason, fall back to the old append behavior
|
||||||
|
// rather than guessing at an index (or throwing).
|
||||||
|
let insertIndex: number | undefined;
|
||||||
|
try {
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const sourceIndex = siblings.indexOf(nodeId);
|
||||||
|
if (sourceIndex !== -1) insertIndex = sourceIndex + 1;
|
||||||
|
} catch {
|
||||||
|
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertIndex !== undefined) {
|
||||||
|
actions.addNodeTree(tree, parentId, insertIndex);
|
||||||
|
} else {
|
||||||
|
actions.addNodeTree(tree, parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the new copy (not the original) so the toolbar/context menu
|
||||||
|
// and any on-canvas outline immediately reflect what was just created.
|
||||||
|
actions.selectNode(tree.rootNodeId);
|
||||||
|
|
||||||
|
return tree.rootNodeId;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Duplicate failed:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveUp = () => {
|
||||||
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
|
try {
|
||||||
|
const parentId = getParentId();
|
||||||
|
if (!parentId) return;
|
||||||
|
const parent = query.node(parentId).get();
|
||||||
|
const children = parent.data.nodes || [];
|
||||||
|
const idx = children.indexOf(nodeId);
|
||||||
|
if (idx > 0) {
|
||||||
|
actions.move(nodeId, parentId, idx - 1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Move up failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDown = () => {
|
||||||
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
|
try {
|
||||||
|
const parentId = getParentId();
|
||||||
|
if (!parentId) return;
|
||||||
|
const parent = query.node(parentId).get();
|
||||||
|
const children = parent.data.nodes || [];
|
||||||
|
const idx = children.indexOf(nodeId);
|
||||||
|
if (idx < children.length - 1) {
|
||||||
|
actions.move(nodeId, parentId, idx + 2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Move down failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectParent = () => {
|
||||||
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
|
const parentId = getParentId();
|
||||||
|
if (parentId) {
|
||||||
|
actions.selectNode(parentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNode = () => {
|
||||||
|
const target = findDeletableTarget(query, nodeId);
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
actions.delete(target);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
moveUp,
|
||||||
|
moveDown,
|
||||||
|
duplicate,
|
||||||
|
deleteNode,
|
||||||
|
selectParent,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
canDelete,
|
||||||
|
canSelectParent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { useVisualViewportInsets } from './useVisualViewport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's
|
||||||
|
* keyboard-avoidance source of truth). Mirrors the bare createRoot + act
|
||||||
|
* harness `useIsMobile.test.tsx` uses (no @testing-library/react in this
|
||||||
|
* repo). Covers:
|
||||||
|
* - the no-`visualViewport` fallback (older WebViews) returns
|
||||||
|
* `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no
|
||||||
|
* listener (there's nothing to listen to).
|
||||||
|
* - the keyboard-open math: `keyboardInset = innerHeight - (vv.height +
|
||||||
|
* vv.offsetTop)`, recomputed when the mocked vv fires `resize`.
|
||||||
|
* - both `resize` and `scroll` listeners are added on mount and the SAME
|
||||||
|
* handler is removed for both on unmount (a leaked listener would keep
|
||||||
|
* reacting after the owning component -- e.g. BottomSheet -- is gone).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let originalInnerHeight: number;
|
||||||
|
let originalVisualViewport: VisualViewport | null;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal fake `visualViewport`: just enough surface for the hook
|
||||||
|
* (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */
|
||||||
|
function makeVisualViewport(height: number, offsetTop: number) {
|
||||||
|
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||||
|
const vv: any = {
|
||||||
|
height,
|
||||||
|
offsetTop,
|
||||||
|
addEventListener: vi.fn((type: string, fn: () => void) => {
|
||||||
|
listeners[type].push(fn);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn((type: string, fn: () => void) => {
|
||||||
|
const i = listeners[type].indexOf(fn);
|
||||||
|
if (i !== -1) listeners[type].splice(i, 1);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
vv,
|
||||||
|
fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()),
|
||||||
|
listenerCount: (type: 'resize' | 'scroll') => listeners[type].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let probed: { height: number; keyboardInset: number } | null = null;
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
probed = useVisualViewportInsets();
|
||||||
|
return <span data-height={probed.height} data-inset={probed.keyboardInset} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalInnerHeight = window.innerHeight;
|
||||||
|
originalVisualViewport = window.visualViewport;
|
||||||
|
probed = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'innerHeight', {
|
||||||
|
value: originalInnerHeight,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'visualViewport', {
|
||||||
|
value: originalVisualViewport,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useVisualViewportInsets', () => {
|
||||||
|
test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => {
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||||
|
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
|
||||||
|
|
||||||
|
render(<Probe />);
|
||||||
|
|
||||||
|
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computes keyboardInset from visualViewport and recomputes on resize', () => {
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||||
|
const { vv, fire } = makeVisualViewport(800, 0);
|
||||||
|
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||||||
|
|
||||||
|
render(<Probe />);
|
||||||
|
|
||||||
|
// No keyboard open: vv fills the layout viewport exactly.
|
||||||
|
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||||||
|
|
||||||
|
// Simulate the on-screen keyboard opening: the visual viewport shrinks.
|
||||||
|
vv.height = 500;
|
||||||
|
vv.offsetTop = 0;
|
||||||
|
act(() => {
|
||||||
|
fire('resize');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(probed).toEqual({ height: 500, keyboardInset: 300 });
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => {
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||||
|
const { vv, listenerCount } = makeVisualViewport(800, 0);
|
||||||
|
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||||||
|
|
||||||
|
render(<Probe />);
|
||||||
|
|
||||||
|
expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||||
|
expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||||
|
expect(listenerCount('resize')).toBe(1);
|
||||||
|
expect(listenerCount('scroll')).toBe(1);
|
||||||
|
|
||||||
|
const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1];
|
||||||
|
const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1];
|
||||||
|
expect(resizeHandler).toBe(scrollHandler);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler);
|
||||||
|
expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler);
|
||||||
|
expect(listenerCount('resize')).toBe(0);
|
||||||
|
expect(listenerCount('scroll')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface VisualViewportInsets {
|
||||||
|
/** The visual viewport's current height (shrinks when the on-screen
|
||||||
|
* keyboard opens). Falls back to `window.innerHeight` when
|
||||||
|
* `visualViewport` isn't supported. */
|
||||||
|
height: number;
|
||||||
|
/** Extra inset a `position: fixed`, bottom-anchored element should add to
|
||||||
|
* its own `bottom` offset to stay clear of the on-screen keyboard --
|
||||||
|
* `window.innerHeight` minus the visual viewport's bottom edge (its
|
||||||
|
* height + offsetTop). Zero whenever no keyboard is open, or
|
||||||
|
* `visualViewport` isn't supported (a safe no-op fallback). */
|
||||||
|
keyboardInset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO_INSETS: VisualViewportInsets = { height: 0, keyboardInset: 0 };
|
||||||
|
|
||||||
|
function computeInsets(): VisualViewportInsets {
|
||||||
|
if (typeof window === 'undefined') return ZERO_INSETS;
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
|
||||||
|
const keyboardInset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
|
||||||
|
return { height: vv.height, keyboardInset };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks `window.visualViewport`'s height/offset (item 5, Phase B) so
|
||||||
|
* `BottomSheet` can stay clear of the on-screen keyboard. `position: fixed`
|
||||||
|
* elements are positioned against the LAYOUT viewport, which does NOT shrink
|
||||||
|
* when a mobile keyboard opens -- only the visual viewport does -- so a
|
||||||
|
* bottom-anchored sheet's inputs can otherwise end up hidden underneath the
|
||||||
|
* keyboard with no visual indication anything is wrong.
|
||||||
|
*
|
||||||
|
* Guards for browsers without `visualViewport` (older WebViews): falls back
|
||||||
|
* to `{ height: window.innerHeight, keyboardInset: 0 }`, i.e. a no-op, so
|
||||||
|
* the sheet just keeps its existing (keyboard-unaware) sizing there.
|
||||||
|
*/
|
||||||
|
export function useVisualViewportInsets(): VisualViewportInsets {
|
||||||
|
const [insets, setInsets] = useState<VisualViewportInsets>(computeInsets);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !window.visualViewport) return;
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const handleChange = () => setInsets(computeInsets());
|
||||||
|
handleChange();
|
||||||
|
vv.addEventListener('resize', handleChange);
|
||||||
|
vv.addEventListener('scroll', handleChange);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', handleChange);
|
||||||
|
vv.removeEventListener('scroll', handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return insets;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||||
|
import { PageProvider, usePages } from '../state/PageContext';
|
||||||
|
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||||
|
import { useWhpApi } from './useWhpApi';
|
||||||
|
import { WhpConfig } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKG-H §5 round-trip coverage: `load()` must restore per-page `seo`
|
||||||
|
* (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed
|
||||||
|
* `PageData`, exactly like it already restores `craftState`. Mocks
|
||||||
|
* `@craftjs/core`'s `useEditor` (same pattern as
|
||||||
|
* `PageContext.pure-updaters.test.tsx`) since this test only needs
|
||||||
|
* `query.serialize`/`actions.deserialize` as inert stubs -- it drives
|
||||||
|
* `load()`, not the live canvas.
|
||||||
|
*/
|
||||||
|
const deserializeMock = vi.fn();
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: { serialize: () => '{}' },
|
||||||
|
actions: { deserialize: deserializeMock },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const whpConfig: WhpConfig = {
|
||||||
|
user: 'testuser',
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
siteName: 'Test Site',
|
||||||
|
backUrl: '/panel/sites',
|
||||||
|
isRoot: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
load: ReturnType<typeof useWhpApi>['load'];
|
||||||
|
pages: ReturnType<typeof usePages>['pages'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): { get: () => Captured } {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let captured: Captured | null = null;
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
const { load } = useWhpApi();
|
||||||
|
const { pages } = usePages();
|
||||||
|
captured = { load, pages };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={whpConfig}>
|
||||||
|
<SiteDesignProvider>
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>
|
||||||
|
</SiteDesignProvider>
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { get: () => captured! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => {
|
||||||
|
const seoPayload = {
|
||||||
|
metaTitle: 'Custom Title',
|
||||||
|
metaDescription: 'A custom description.',
|
||||||
|
ogTitle: 'Custom OG Title',
|
||||||
|
ogImage: '/uploads/og.jpg',
|
||||||
|
twitterCard: 'summary_large_image' as const,
|
||||||
|
noindex: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload },
|
||||||
|
{ id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { pages } = harness.get();
|
||||||
|
expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload);
|
||||||
|
// A page with no seo in the payload stays undefined -- back-compat, not
|
||||||
|
// coerced into an empty object.
|
||||||
|
expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { pages } = harness.get();
|
||||||
|
expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||||
|
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
|
||||||
|
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||||
|
import { useWhpApi } from './useWhpApi';
|
||||||
|
import { WhpConfig } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13b: `PageContext.loadState` runs every stored craft state through
|
||||||
|
* `repairOrphanNodes` before handing it to `actions.deserialize` -- that
|
||||||
|
* covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
|
||||||
|
* the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
|
||||||
|
* fired once on mount by `TopBar.tsx` -- used to call
|
||||||
|
* `actions.deserialize(state)` directly on the first page's stored
|
||||||
|
* `craftState`, bypassing repair entirely. A site whose saved state
|
||||||
|
* contains a node unreachable from ROOT would get it silently repaired on
|
||||||
|
* the NEXT page switch but not on the load that actually renders it first.
|
||||||
|
*
|
||||||
|
* This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
|
||||||
|
* `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
|
||||||
|
* string handed to the mocked `actions.deserialize` -- the orphan must be
|
||||||
|
* reattached to ROOT and a `console.error` must fire, exactly like the
|
||||||
|
* page-switch path (I5 review: this was `console.warn`, which
|
||||||
|
* console-buffer.ts -- feeding the in-builder issue reporter -- does not
|
||||||
|
* capture).
|
||||||
|
*/
|
||||||
|
const deserializeMock = vi.fn();
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: { serialize: () => '{}' },
|
||||||
|
actions: { deserialize: deserializeMock },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const whpConfig: WhpConfig = {
|
||||||
|
user: 'testuser',
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
siteName: 'Test Site',
|
||||||
|
backUrl: '/panel/sites',
|
||||||
|
isRoot: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
load: ReturnType<typeof useWhpApi>['load'];
|
||||||
|
pages: ReturnType<typeof usePages>['pages'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): { get: () => Captured } {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let captured: Captured | null = null;
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
const { load } = useWhpApi();
|
||||||
|
const { pages } = usePages();
|
||||||
|
captured = { load, pages };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={whpConfig}>
|
||||||
|
<SiteDesignProvider>
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>
|
||||||
|
</SiteDesignProvider>
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { get: () => captured! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
|
||||||
|
* a ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||||
|
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||||
|
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||||
|
const ORPHAN_STATE = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
stray: {
|
||||||
|
type: { resolvedName: 'HtmlBlock' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} },
|
||||||
|
displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deserializeMock).toHaveBeenCalled();
|
||||||
|
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||||
|
const parsed = JSON.parse(passedState);
|
||||||
|
// The orphan is now an ordinary, reachable child of ROOT.
|
||||||
|
expect(parsed.ROOT.nodes).toContain('stray');
|
||||||
|
expect(parsed.stray.parent).toBe('ROOT');
|
||||||
|
|
||||||
|
// The observable signal that repair actually ran, not just that the
|
||||||
|
// orphan happened to be absent for some unrelated reason. (React's own
|
||||||
|
// act()-environment warnings also go through console.error in this
|
||||||
|
// harness, so search all calls rather than assuming index 0.)
|
||||||
|
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I5 (review): `PageContext.loadState`'s deserialize failure path logs via
|
||||||
|
* `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
|
||||||
|
* up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
|
||||||
|
* used to be `console.warn` with NO fallback deserialize -- the initial
|
||||||
|
* load, which decides whether the user sees a working editor at all, both
|
||||||
|
* failed harder (silently leaving the Frame undeserialized) and reported
|
||||||
|
* quieter than every subsequent page switch. This pins the aligned
|
||||||
|
* behaviour.
|
||||||
|
*/
|
||||||
|
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{"broken":true}}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
deserializeMock.mockImplementationOnce(() => {
|
||||||
|
throw new Error('malformed state');
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call (the broken state) threw; the second call is the fallback.
|
||||||
|
expect(deserializeMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,4 +147,63 @@ describe('buildSavePayload', () => {
|
|||||||
expect(payload.design).toEqual(design);
|
expect(payload.design).toEqual(design);
|
||||||
expect(payload.design.headCode).toBe('<meta name="x">');
|
expect(payload.design.headCode).toBe('<meta name="x">');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PKG-H §5: per-page seo is included in both pages_craft_state and pages entries', () => {
|
||||||
|
const pageWithSeo: PageData = {
|
||||||
|
id: 'home',
|
||||||
|
name: 'Home',
|
||||||
|
slug: 'index',
|
||||||
|
craftState: 'STORED_HOME',
|
||||||
|
seo: {
|
||||||
|
metaTitle: 'Custom Title',
|
||||||
|
metaDescription: 'Custom description',
|
||||||
|
ogTitle: 'Custom OG',
|
||||||
|
ogImage: '/uploads/og.jpg',
|
||||||
|
twitterCard: 'summary_large_image',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_PAGE_HOME',
|
||||||
|
pages: [pageWithSeo, pageB],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: 'home',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: false,
|
||||||
|
headCode: DEFAULT_SITE_DESIGN.headCode,
|
||||||
|
design: DEFAULT_SITE_DESIGN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.seo).toEqual(pageWithSeo.seo);
|
||||||
|
expect(payload.pages.find((p) => p.filename === 'index.html')?.seo).toEqual(pageWithSeo.seo);
|
||||||
|
|
||||||
|
// A page with no seo overrides omits the field entirely (undefined),
|
||||||
|
// not an empty object -- back-compat with pre-PKG-H saved shapes.
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
|
||||||
|
expect(payload.pages.find((p) => p.filename === 'about.html')?.seo).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PKG-H §5: favicon flows through the design object already carried by the payload', () => {
|
||||||
|
const design = { ...DEFAULT_SITE_DESIGN, favicon: '/uploads/favicon.png' };
|
||||||
|
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_PAGE_HOME',
|
||||||
|
pages: [pageA, pageB],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: 'home',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: false,
|
||||||
|
headCode: DEFAULT_SITE_DESIGN.headCode,
|
||||||
|
design,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.design.favicon).toBe('/uploads/favicon.png');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages, EMPTY_CANVAS } from '../state/PageContext';
|
||||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
|
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||||
import { PageData } from '../types';
|
import { PageData } from '../types';
|
||||||
|
|
||||||
export interface BuildSavePayloadInput {
|
export interface BuildSavePayloadInput {
|
||||||
@@ -149,6 +150,12 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
|||||||
filename,
|
filename,
|
||||||
title: page.name,
|
title: page.name,
|
||||||
html: pageHtml,
|
html: pageHtml,
|
||||||
|
// PKG-H §5: per-page SEO overrides, read by the backend's
|
||||||
|
// `handlePublish` (mirrors the existing `title` field above) to build
|
||||||
|
// the published <head> for this file. Omitted (undefined) when the
|
||||||
|
// page has no seo overrides -- JSON.stringify drops undefined keys,
|
||||||
|
// so legacy-shaped payloads round-trip unchanged.
|
||||||
|
seo: page.seo,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,6 +173,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
|||||||
// name.html) lines up with the file we just wrote (index.html).
|
// name.html) lines up with the file we just wrote (index.html).
|
||||||
slug: i === 0 ? 'index' : page.slug,
|
slug: i === 0 ? 'index' : page.slug,
|
||||||
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
||||||
|
// PKG-H §5: round-trips PageData.seo through save -> load() so the
|
||||||
|
// editor's SEO fields survive a reload (mirrors craftState above).
|
||||||
|
seo: page.seo,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -292,19 +302,46 @@ export function useWhpApi() {
|
|||||||
|
|
||||||
// Restore pages and load the first page into the canvas
|
// Restore pages and load the first page into the canvas
|
||||||
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
|
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
|
||||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({
|
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
|
||||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null,
|
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// Load the first page (home) into the canvas
|
// Load the first page (home) into the canvas. Routed through
|
||||||
|
// `repairOrphanNodes` first -- same as `PageContext.loadState` does
|
||||||
|
// for every subsequent page switch -- so a node that's unreachable
|
||||||
|
// from ROOT (invisible to Layers/selection) gets reattached here
|
||||||
|
// too, on the load that actually puts it on screen, rather than only
|
||||||
|
// on the next page switch. `repairOrphanNodes` never throws and
|
||||||
|
// returns the original string reference when nothing needed fixing,
|
||||||
|
// so this is cheap to run unconditionally.
|
||||||
const firstPage = proj.pages_craft_state[0];
|
const firstPage = proj.pages_craft_state[0];
|
||||||
if (firstPage.craftState) {
|
if (firstPage.craftState) {
|
||||||
try {
|
try {
|
||||||
const state = typeof firstPage.craftState === 'string'
|
const rawState = typeof firstPage.craftState === 'string'
|
||||||
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
||||||
|
const { state, repaired } = repairOrphanNodes(rawState);
|
||||||
|
if (repaired.length > 0) {
|
||||||
|
// I5: console-buffer.ts only patches console.error, and this
|
||||||
|
// reattach signal is the single most diagnostic clue for the
|
||||||
|
// still-unreproduced "elements drop off the canvas" report --
|
||||||
|
// it must reach the in-builder issue reporter's console buffer.
|
||||||
|
console.error(
|
||||||
|
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||||
|
repaired.join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
actions.deserialize(state);
|
actions.deserialize(state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load page state:', e);
|
// I5: this is the initial load that decides whether the user
|
||||||
|
// sees a working editor at all -- align with `loadState`'s
|
||||||
|
// behaviour (console.error + a known-safe fallback) instead of
|
||||||
|
// warning quietly and leaving the Frame on whatever it last had.
|
||||||
|
console.error('Failed to load page state:', e);
|
||||||
|
try {
|
||||||
|
actions.deserialize(EMPTY_CANVAS);
|
||||||
|
} catch (_e2) {
|
||||||
|
// give up
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,24 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { WhpConfig } from './types';
|
import { WhpConfig } from './types';
|
||||||
|
import { installConsoleErrorBuffer } from './utils/console-buffer';
|
||||||
|
import { editorBuild } from './utils/build-stamp';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
|
// Installed before React mounts so errors thrown during the first render are
|
||||||
|
// captured too.
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
|
||||||
|
// Exposed on window (not logged -- no need to print this on every load for
|
||||||
|
// every customer) so support can ask someone to type __WHP_EDITOR_BUILD__
|
||||||
|
// in the console on request. This call is also load-bearing for bundling:
|
||||||
|
// it is currently the only reference to `editorBuild()`, which keeps the
|
||||||
|
// __EDITOR_BUILD__-reading module from being tree-shaken out of the bundle
|
||||||
|
// before Task 19 wires it into the report payload. Do not remove this line
|
||||||
|
// as a "stray global" cleanup -- doing so silently reverts every future
|
||||||
|
// bug report to showing 'dev' instead of a real build stamp.
|
||||||
|
(window as any).__WHP_EDITOR_BUILD__ = editorBuild();
|
||||||
|
|
||||||
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
||||||
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useCallback, useRef } from 'react';
|
import React, { useEffect, useCallback, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { findDeletableTarget } from '../../utils/craft-helpers';
|
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
import { regenerateTreeIds } from '../../utils/craft-tree';
|
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||||
|
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -65,52 +65,68 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
}
|
}
|
||||||
}, [nodeId, query]);
|
}, [nodeId, query]);
|
||||||
|
|
||||||
const duplicate = useCallback(() => {
|
// Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
// extracted into `useNodeActions` so the mobile selection toolbar drives
|
||||||
try {
|
// the exact same behavior. Wrapped here purely to also `onClose()` the
|
||||||
const parentId = getParentId();
|
// menu after each action, same as before the extraction.
|
||||||
if (!parentId) return;
|
const nodeActions = useNodeActions(nodeId);
|
||||||
|
|
||||||
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
const duplicate = useCallback(() => {
|
||||||
actions.addNodeTree(tree, parentId);
|
nodeActions.duplicate();
|
||||||
} catch (e) {
|
|
||||||
console.error('Duplicate failed:', e);
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, query, getParentId, onClose]);
|
}, [nodeActions, onClose]);
|
||||||
|
|
||||||
const copyNode = useCallback(() => {
|
const copyNode = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
try {
|
try {
|
||||||
setClipboardNodeId(nodeId);
|
// Store a detached subtree snapshot, not just the id -- an id-based
|
||||||
|
// clipboard can't survive a page switch (the copied id no longer
|
||||||
|
// exists in `query` once the canvas is re-deserialized to a different
|
||||||
|
// page's Craft.js state). See clipboard.ts.
|
||||||
|
setClipboardTree(query.node(nodeId).toNodeTree());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Copy failed:', e);
|
console.error('Copy failed:', e);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, onClose]);
|
}, [nodeId, query, onClose]);
|
||||||
|
|
||||||
const pasteNode = useCallback(() => {
|
const pasteNode = useCallback(() => {
|
||||||
const sourceId = getClipboardNodeId();
|
const clip = getClipboardTree();
|
||||||
if (!sourceId) {
|
if (!clip) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!query.node(sourceId).get()) {
|
// Paste as a SIBLING of the right-clicked node (immediately after it,
|
||||||
onClose();
|
// matching duplicate()'s UX), not as its child -- using the clicked
|
||||||
return;
|
// node itself as the parent throws when it's a leaf. Falls back to
|
||||||
}
|
// appending into ROOT when nothing valid was right-clicked. This works
|
||||||
|
// regardless of which page is on the canvas -- the clipboard tree is a
|
||||||
// Paste as a SIBLING of the right-clicked node, not as its child --
|
// detached snapshot, not a reference to a node that may not exist here.
|
||||||
// using the clicked node itself as the parent throws when it's a leaf.
|
|
||||||
let targetParent = 'ROOT';
|
let targetParent = 'ROOT';
|
||||||
|
let insertIndex: number | undefined;
|
||||||
if (nodeId && nodeId !== 'ROOT') {
|
if (nodeId && nodeId !== 'ROOT') {
|
||||||
const clickedNode = query.node(nodeId).get();
|
const clickedNode = query.node(nodeId).get();
|
||||||
targetParent = clickedNode?.data?.parent || 'ROOT';
|
const parentId: string | null | undefined = clickedNode?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
targetParent = parentId;
|
||||||
|
try {
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(nodeId);
|
||||||
|
if (idx !== -1) insertIndex = idx + 1;
|
||||||
|
} catch {
|
||||||
|
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
const tree = regenerateTreeIds(clip);
|
||||||
actions.addNodeTree(tree, targetParent);
|
if (insertIndex !== undefined) {
|
||||||
|
actions.addNodeTree(tree, targetParent, insertIndex);
|
||||||
|
} else {
|
||||||
|
actions.addNodeTree(tree, targetParent);
|
||||||
|
}
|
||||||
|
actions.selectNode(tree.rootNodeId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Paste failed:', e);
|
console.error('Paste failed:', e);
|
||||||
}
|
}
|
||||||
@@ -118,47 +134,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
}, [nodeId, actions, query, onClose]);
|
}, [nodeId, actions, query, onClose]);
|
||||||
|
|
||||||
const moveUp = useCallback(() => {
|
const moveUp = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
nodeActions.moveUp();
|
||||||
try {
|
|
||||||
const parentId = getParentId();
|
|
||||||
if (!parentId) return;
|
|
||||||
const parent = query.node(parentId).get();
|
|
||||||
const children = parent.data.nodes || [];
|
|
||||||
const idx = children.indexOf(nodeId);
|
|
||||||
if (idx > 0) {
|
|
||||||
actions.move(nodeId, parentId, idx - 1);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Move up failed:', e);
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, query, getParentId, onClose]);
|
}, [nodeActions, onClose]);
|
||||||
|
|
||||||
const moveDown = useCallback(() => {
|
const moveDown = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
nodeActions.moveDown();
|
||||||
try {
|
|
||||||
const parentId = getParentId();
|
|
||||||
if (!parentId) return;
|
|
||||||
const parent = query.node(parentId).get();
|
|
||||||
const children = parent.data.nodes || [];
|
|
||||||
const idx = children.indexOf(nodeId);
|
|
||||||
if (idx < children.length - 1) {
|
|
||||||
actions.move(nodeId, parentId, idx + 2);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Move down failed:', e);
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, query, getParentId, onClose]);
|
}, [nodeActions, onClose]);
|
||||||
|
|
||||||
const selectParent = useCallback(() => {
|
const selectParent = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
nodeActions.selectParent();
|
||||||
const parentId = getParentId();
|
|
||||||
if (parentId) {
|
|
||||||
actions.selectNode(parentId);
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, getParentId, onClose]);
|
}, [nodeActions, onClose]);
|
||||||
|
|
||||||
const askSitesmith = useCallback(() => {
|
const askSitesmith = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
@@ -172,18 +160,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
}, [nodeId, query, openSitesmith, onClose]);
|
}, [nodeId, query, openSitesmith, onClose]);
|
||||||
|
|
||||||
const deleteNode = useCallback(() => {
|
const deleteNode = useCallback(() => {
|
||||||
const target = findDeletableTarget(query, nodeId);
|
nodeActions.deleteNode();
|
||||||
if (!target) {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
actions.delete(target);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Delete failed:', e);
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, query, onClose]);
|
}, [nodeActions, onClose]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
@@ -216,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
icon: 'clipboard',
|
icon: 'clipboard',
|
||||||
shortcut: 'Ctrl+V',
|
shortcut: 'Ctrl+V',
|
||||||
action: pasteNode,
|
action: pasteNode,
|
||||||
disabled: !getClipboardNodeId(),
|
disabled: !getClipboardTree(),
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -235,7 +214,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
label: 'Select Parent',
|
label: 'Select Parent',
|
||||||
icon: 'level-up',
|
icon: 'level-up',
|
||||||
action: selectParent,
|
action: selectParent,
|
||||||
disabled: isRoot,
|
// Not just `isRoot`: the real dead-end is when nodeId's PARENT is
|
||||||
|
// ROOT (selecting a top-level section -> Select Parent would only
|
||||||
|
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
|
||||||
|
// `canSelectParent` (useNodeActions) already encodes that and matches
|
||||||
|
// the mobile selection toolbar's identical guard.
|
||||||
|
disabled: !nodeActions.canSelectParent,
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||||
import { Container } from '../../components/layout/Container';
|
import { Container } from '../../components/layout/Container';
|
||||||
import { Section } from '../../components/layout/Section';
|
import { Section } from '../../components/layout/Section';
|
||||||
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
||||||
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
|
|||||||
|
|
||||||
export const BlocksPanel: React.FC = () => {
|
export const BlocksPanel: React.FC = () => {
|
||||||
const { connectors, actions, query } = useEditor();
|
const { connectors, actions, query } = useEditor();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { closeSheet } = useMobileChrome();
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
||||||
const initial: Record<string, boolean> = {};
|
const initial: Record<string, boolean> = {};
|
||||||
categories.forEach((cat, index) => {
|
categories.forEach((cat, index) => {
|
||||||
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
|
|||||||
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
|
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Default insertion target when there's no usable selection to anchor
|
||||||
|
* to: the first real Craft.js canvas in the document (falls back to
|
||||||
|
* ROOT). Extracted from the pre-existing onDoubleClick handler so
|
||||||
|
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
|
||||||
|
* the exact same fallback. */
|
||||||
|
const findDefaultCanvasId = useCallback((): string => {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.parse(query.serialize());
|
||||||
|
const nodeIds = Object.keys(serialized);
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
if (serialized[id].isCanvas && id !== 'ROOT') return id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to ROOT below.
|
||||||
|
}
|
||||||
|
return 'ROOT';
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a fresh node tree for `block` and inserts it into the canvas,
|
||||||
|
* returning the new node's id (or null on failure). Shared by desktop's
|
||||||
|
* double-click (unchanged behavior/position: always appended to
|
||||||
|
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
|
||||||
|
* prefers inserting as a sibling right after the current selection --
|
||||||
|
* `insertAfterSelection: true` only from the mobile tap handler below.
|
||||||
|
*/
|
||||||
|
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
|
||||||
|
try {
|
||||||
|
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
if (insertAfterSelection) {
|
||||||
|
try {
|
||||||
|
const selectedIds = query.getEvent('selected').all();
|
||||||
|
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
|
||||||
|
if (selectedId && selectedId !== 'ROOT') {
|
||||||
|
const selectedNode = query.node(selectedId).get();
|
||||||
|
const parentId = selectedNode?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
const parent = query.node(parentId).get();
|
||||||
|
const siblings: string[] = parent?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(selectedId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
actions.addNodeTree(tree, parentId, idx + 1);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Selection isn't a valid sibling target (e.g. lives in a
|
||||||
|
// linkedNodes slot) -- fall through to the default canvas below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
actions.addNodeTree(tree, findDefaultCanvasId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree.rootNodeId;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to add block:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [query, actions, findDefaultCanvasId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
|
||||||
|
* closes the Blocks sheet, then selects the new node and scrolls it into
|
||||||
|
* view so the user immediately sees it (and gets the selection toolbar).
|
||||||
|
* The select/scroll step is deferred two animation frames past the
|
||||||
|
* `addNodeTree` call -- Craft.js's own state update (and thus the new
|
||||||
|
* node's real DOM element) lands asynchronously after this handler
|
||||||
|
* returns, so `query.node(id).get().dom` isn't populated yet if read
|
||||||
|
* synchronously here.
|
||||||
|
*/
|
||||||
|
const handleTapToAdd = useCallback((block: BlockDef) => {
|
||||||
|
const newId = addBlockNode(block, true);
|
||||||
|
closeSheet();
|
||||||
|
if (!newId) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
actions.selectNode(newId);
|
||||||
|
} catch {
|
||||||
|
// Node may have failed to mount -- nothing to select.
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} catch {
|
||||||
|
// Best-effort scroll -- not fatal if the node/DOM isn't found.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [addBlockNode, closeSheet, actions, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
|
|||||||
key={block.id}
|
key={block.id}
|
||||||
className="block-item"
|
className="block-item"
|
||||||
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => addBlockNode(block, false)}
|
||||||
try {
|
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
|
||||||
const serialized = JSON.parse(query.serialize());
|
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
|
||||||
const nodeIds = Object.keys(serialized);
|
|
||||||
let canvasId = 'ROOT';
|
|
||||||
for (const nodeId of nodeIds) {
|
|
||||||
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
|
|
||||||
canvasId = nodeId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
|
|
||||||
actions.addNodeTree(tree, canvasId);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to add block:', e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={`Drag or double-click to add ${block.label}`}
|
|
||||||
>
|
>
|
||||||
<i className={`fa ${block.icon} block-item-icon`} />
|
<i className={`fa ${block.icon} block-item-icon`} />
|
||||||
<span className="block-item-label">{block.label}</span>
|
<span className="block-item-label">{block.label}</span>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
const { focus, requestFocus } = useLayerFocus();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
|
||||||
|
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LayerFocusContext', () => {
|
||||||
|
test('starts with no focus request', () => {
|
||||||
|
render();
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requestFocus publishes the target', () => {
|
||||||
|
render();
|
||||||
|
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeating the same request bumps the nonce so consumers re-fire', () => {
|
||||||
|
render();
|
||||||
|
const btn = container.querySelector('button') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useLayerFocus outside a provider is inert rather than a crash', () => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<Probe />);
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries "the user clicked a virtual Layers row -- scroll that array item's
|
||||||
|
* card into view" from the Layers panel (left) to the array editors (right).
|
||||||
|
*
|
||||||
|
* Deliberately a request, not a command: consumers that don't implement
|
||||||
|
* scrolling simply ignore it. Selecting the parent node always happens in
|
||||||
|
* LayersPanel itself, so a click is useful even with no consumer at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LayerFocusRequest {
|
||||||
|
nodeId: string;
|
||||||
|
prop: string;
|
||||||
|
index: number;
|
||||||
|
/** Bumped on every request so an identical repeat still re-fires effects. */
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerFocusValue {
|
||||||
|
focus: LayerFocusRequest | null;
|
||||||
|
requestFocus(nodeId: string, prop: string, index: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayerFocusContext = createContext<LayerFocusValue>({
|
||||||
|
focus: null,
|
||||||
|
requestFocus: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
|
||||||
|
|
||||||
|
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
|
||||||
|
const nonceRef = useRef(0);
|
||||||
|
|
||||||
|
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
|
||||||
|
nonceRef.current += 1;
|
||||||
|
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
|
||||||
|
|
||||||
|
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||||
|
import { LayersPanel } from './LayersPanel';
|
||||||
|
import { LayerFocusProvider } from './LayerFocusContext';
|
||||||
|
|
||||||
|
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
|
||||||
|
return JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' }, isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' }, displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresNode = {
|
||||||
|
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
|
||||||
|
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
|
||||||
|
displayName: 'Features Grid',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LayersPanel', () => {
|
||||||
|
test('shows virtual rows for a Features Grid array prop', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
|
||||||
|
// Scoped to `.layer-virtual-row` rather than the whole container:
|
||||||
|
// `FeaturesGrid` itself renders `feat.title` on the live canvas (which
|
||||||
|
// is also mounted inside `harness.container`, alongside the panel), so
|
||||||
|
// asserting on `container.textContent` alone would pass even if
|
||||||
|
// LayersPanel never rendered a single virtual row -- the "Fast"/"Secure"
|
||||||
|
// text would already be there from the canvas. Scoping to the virtual
|
||||||
|
// row elements themselves makes the assertion actually exercise
|
||||||
|
// LayersPanel's own rendering.
|
||||||
|
const virtualRows = harness.container.querySelectorAll('.layer-virtual-row');
|
||||||
|
expect(virtualRows).toHaveLength(2);
|
||||||
|
const virtualText = Array.from(virtualRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(virtualText).toContain('Fast');
|
||||||
|
expect(virtualText).toContain('Secure');
|
||||||
|
|
||||||
|
// The real node row for the parent is still shown too.
|
||||||
|
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||||
|
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(nodeText).toContain('Features Grid');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('virtual rows are not rendered for a component with no registry entry', () => {
|
||||||
|
const heading = {
|
||||||
|
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||||
|
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Unplaced group does not render for a healthy tree', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
expect(harness.container.textContent).not.toContain('Unplaced');
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Unplaced group appears and lists a node unreachable from ROOT', () => {
|
||||||
|
// 'stray' is a real node in the deserialized state, but no node's
|
||||||
|
// `nodes`/`linkedNodes` list references it -- exactly the "dropped
|
||||||
|
// outside the page" scenario findUnreachableNodeIds exists to catch.
|
||||||
|
const heading = {
|
||||||
|
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||||
|
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
const stray = {
|
||||||
|
type: { resolvedName: 'HtmlBlock' }, isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} }, displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
};
|
||||||
|
const harness = renderEditorHarness({
|
||||||
|
initialState: stateWith({ h1: heading, stray }, ['h1']),
|
||||||
|
});
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('Unplaced');
|
||||||
|
expect(harness.container.textContent).toContain('Unplaced (1)');
|
||||||
|
|
||||||
|
// The orphan itself is rendered as a selectable/deletable LayerNode row
|
||||||
|
// (displayName 'HTML') underneath the Unplaced heading, not silently
|
||||||
|
// dropped from the tree.
|
||||||
|
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||||
|
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(nodeText).toContain('HTML');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { clickableProps } from '../../utils/a11y';
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
|
||||||
|
import { useLayerFocus } from './LayerFocusContext';
|
||||||
|
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
||||||
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
const DEFAULT_ICON = 'fa-cube';
|
const DEFAULT_ICON = 'fa-cube';
|
||||||
const ROOT_ICON = 'fa-desktop';
|
const ROOT_ICON = 'fa-desktop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A display-only row for one item of a composite's array prop (a Features
|
||||||
|
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
|
||||||
|
* deleted. Clicking it selects the PARENT and asks the array editor to
|
||||||
|
* scroll that item's card into view.
|
||||||
|
*/
|
||||||
|
const VirtualRowNode: React.FC<{
|
||||||
|
parentId: string;
|
||||||
|
prop: string;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
depth: number;
|
||||||
|
onActivate: () => void;
|
||||||
|
}> = ({ index, label, depth, onActivate }) => (
|
||||||
|
<div
|
||||||
|
{...clickableProps(onActivate)}
|
||||||
|
className="layer-virtual-row"
|
||||||
|
title={label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
paddingLeft: `${8 + depth * 16}px`,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--color-text-dim)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">▪</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
interface LayerNodeProps {
|
interface LayerNodeProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||||
|
const { requestFocus } = useLayerFocus();
|
||||||
const { node, selectedId, actions, query } = useEditor((state) => {
|
const { node, selectedId, actions, query } = useEditor((state) => {
|
||||||
const n = state.nodes[nodeId];
|
const n = state.nodes[nodeId];
|
||||||
const selectedIds = state.events.selected;
|
const selectedIds = state.events.selected;
|
||||||
@@ -140,12 +182,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
const isRoot = nodeId === 'ROOT';
|
const isRoot = nodeId === 'ROOT';
|
||||||
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
||||||
|
|
||||||
|
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
|
||||||
|
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
|
||||||
|
const hasDisclosure = allChildren.length + virtualRows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
{...clickableProps(handleActivate)}
|
{...clickableProps(handleActivate)}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className="layer-node-row"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -186,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Indentation/disclosure indicator */}
|
{/* Indentation/disclosure indicator */}
|
||||||
{allChildren.length > 0 ? (
|
{hasDisclosure ? (
|
||||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
||||||
▼
|
▼
|
||||||
</span>
|
</span>
|
||||||
@@ -216,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Virtual rows: array-prop items (composite content Craft doesn't see
|
||||||
|
as child nodes), rendered before real children. */}
|
||||||
|
{virtualSpec && virtualRows.map((row) => (
|
||||||
|
<VirtualRowNode
|
||||||
|
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
|
||||||
|
parentId={nodeId}
|
||||||
|
prop={virtualSpec.prop}
|
||||||
|
index={row.index}
|
||||||
|
label={row.label}
|
||||||
|
depth={depth + 1}
|
||||||
|
onActivate={() => {
|
||||||
|
actions.selectNode(nodeId);
|
||||||
|
requestFocus(nodeId, virtualSpec.prop, row.index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Render children */}
|
{/* Render children */}
|
||||||
{allChildren.map((childId) => (
|
{allChildren.map((childId) => (
|
||||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||||
@@ -225,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LayersPanel: React.FC = () => {
|
export const LayersPanel: React.FC = () => {
|
||||||
const { nodeIds } = useEditor((state) => {
|
const { nodeIds, unplacedIds } = useEditor((state) => {
|
||||||
|
const serializable: Record<string, any> = {};
|
||||||
|
for (const [id, n] of Object.entries(state.nodes)) {
|
||||||
|
serializable[id] = {
|
||||||
|
nodes: n.data.nodes || [],
|
||||||
|
linkedNodes: n.data.linkedNodes || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
nodeIds: Object.keys(state.nodes),
|
nodeIds: Object.keys(state.nodes),
|
||||||
|
unplacedIds: findUnreachableNodeIds(serializable),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,13 +314,7 @@ export const LayersPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
margin: '-12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@@ -258,11 +324,44 @@ export const LayersPanel: React.FC = () => {
|
|||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
borderBottom: '1px solid var(--color-border)',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Component Tree
|
Component Tree
|
||||||
</div>
|
</div>
|
||||||
<LayerNode nodeId="ROOT" depth={0} />
|
|
||||||
|
{/* Own scroll container: a deep or long tree must stay fully reachable
|
||||||
|
regardless of how the parent tab panel is sized. */}
|
||||||
|
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||||
|
<LayerNode nodeId="ROOT" depth={0} />
|
||||||
|
|
||||||
|
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
|
||||||
|
these on load, so this should stay empty -- it exists so an
|
||||||
|
element stranded mid-session is still selectable and deletable
|
||||||
|
rather than invisible. */}
|
||||||
|
{unplacedIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
color: 'var(--color-warning, #f59e0b)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
title="These elements are not attached to the page. Select one to delete it."
|
||||||
|
>
|
||||||
|
Unplaced ({unplacedIds.length})
|
||||||
|
</div>
|
||||||
|
{unplacedIds.map((id) => (
|
||||||
|
<LayerNode key={id} nodeId={id} depth={1} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user