Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e628d68dd | ||
|
|
8f17b74e2b | ||
|
|
8bf525c600 | ||
|
|
d07bc7789d | ||
|
|
b03425ac39 | ||
|
|
5b19ae97af | ||
|
|
88df4f2888 | ||
|
|
e1b4ab735c | ||
|
|
ff1e8fc096 | ||
|
|
0419291259 | ||
|
|
1d9460c173 | ||
|
|
8d69982a0b | ||
|
|
fb40e3ece4 | ||
|
|
9591fcb8a7 | ||
|
|
f80811318b | ||
|
|
cb9fea6656 | ||
|
|
b1afdcb585 | ||
|
|
f00e4db3dc | ||
|
|
b9552faa30 | ||
|
|
77f35c4e9e | ||
|
|
2c8425ffb0 | ||
|
|
2a071bc7ab | ||
|
|
2a8a26687b | ||
|
|
979331b12d | ||
|
|
329a782052 | ||
|
|
da558fd52d | ||
|
|
8aeadefa88 | ||
|
|
2066059c35 | ||
|
|
473fe8d421 | ||
|
|
5d6ae7946b | ||
|
|
1c85ab93eb | ||
|
|
67228f24b4 | ||
|
|
613a44c4d4 | ||
|
|
5685670895 | ||
|
|
72f85a97e5 | ||
|
|
ab28ad8f2c | ||
|
|
05e00c572d | ||
|
|
458069afb6 | ||
|
|
b3e5009aec | ||
|
|
eeb0660d83 | ||
|
|
138e1a8273 | ||
|
|
1b12b79a0d | ||
|
|
621bb21d52 | ||
|
|
3f3c6fb851 | ||
|
|
802938ec1a | ||
|
|
3e43aee6e9 |
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Site Builder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
Generated
+217
@@ -8,6 +8,15 @@
|
||||
"name": "whp-site-builder",
|
||||
"version": "2.0.0",
|
||||
"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/layers": "^0.2.7",
|
||||
"dompurify": "^3.4.5",
|
||||
@@ -373,6 +382,133 @@
|
||||
"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": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
|
||||
@@ -1093,6 +1229,69 @@
|
||||
"@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": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
@@ -1873,6 +2072,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
@@ -2686,6 +2891,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.3.12",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"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/layers": "^0.2.7",
|
||||
"dompurify": "^3.4.5",
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Editor } from '@craftjs/core';
|
||||
import { EditorShell } from './editor/EditorShell';
|
||||
import { RenderNode } from './editor/RenderNode';
|
||||
import { componentResolver } from './components/resolver';
|
||||
import { WhpConfig } from './types';
|
||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||
return (
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
|
||||
<PageProvider>
|
||||
<SitesmithProvider>
|
||||
<EditorShell />
|
||||
|
||||
@@ -52,3 +52,69 @@ describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', ()
|
||||
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 { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
|
||||
interface ButtonLinkProps {
|
||||
text?: string;
|
||||
href?: string;
|
||||
target?: '_self' | '_blank';
|
||||
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> = ({
|
||||
@@ -15,6 +26,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
href = '#',
|
||||
target = '_self',
|
||||
style = {},
|
||||
hoverBg = '',
|
||||
hoverColor = '',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -23,6 +36,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<a
|
||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
@@ -32,12 +47,16 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||
// Prevent navigation inside editor
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
|
||||
...(hovered && hoverColor ? { color: hoverColor } : {}),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
@@ -53,6 +72,8 @@ ButtonLink.craft = {
|
||||
text: 'Click Me',
|
||||
href: '#',
|
||||
target: '_self',
|
||||
hoverBg: '',
|
||||
hoverColor: '',
|
||||
style: {
|
||||
backgroundColor: '#3b82f6',
|
||||
color: '#ffffff',
|
||||
@@ -61,7 +82,15 @@ ButtonLink.craft = {
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -72,7 +101,7 @@ ButtonLink.craft = {
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const styleStr = cssPropsToString({
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
@@ -80,7 +109,28 @@ ButtonLink.craft = {
|
||||
});
|
||||
const escapedText = escapeHtml(props.text || '');
|
||||
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 {
|
||||
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>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,3 +56,54 @@ describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () =>
|
||||
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',
|
||||
color: '#1f2937',
|
||||
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: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -44,3 +44,29 @@ describe('Icon.toHtml XSS hardening', () => {
|
||||
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;
|
||||
link?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
function getBgBorderRadius(shape: string): string {
|
||||
@@ -96,6 +101,11 @@ Icon.craft = {
|
||||
bgSize: '56px',
|
||||
link: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -104,7 +104,7 @@ Logo.craft = {
|
||||
let innerHtml: string;
|
||||
if (props.type === 'image' && props.imageSrc) {
|
||||
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
||||
innerHtml = `<img src="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
innerHtml = `<img src="${escapeAttr(safeImageUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||
} else {
|
||||
const spanStyle = cssPropsToString({
|
||||
fontWeight: props.fontWeight || '700',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -252,7 +252,7 @@ Navbar.craft = {
|
||||
let logoHtml: string;
|
||||
if (props.logoType === 'image' && props.logoImage) {
|
||||
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||
} else {
|
||||
const logoStyle = cssPropsToString({
|
||||
fontWeight: '700',
|
||||
|
||||
@@ -30,3 +30,46 @@ describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', (
|
||||
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 { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
buttonText?: string;
|
||||
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;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
action = '/',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -27,6 +37,8 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
<form
|
||||
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
role="search"
|
||||
action={action}
|
||||
method="GET"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -51,6 +63,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -101,7 +114,13 @@ SearchBar.craft = {
|
||||
placeholder: 'Search...',
|
||||
buttonText: 'Search',
|
||||
showButton: true,
|
||||
action: '/',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -117,6 +136,7 @@ SearchBar.craft = {
|
||||
placeholder = 'Search...',
|
||||
buttonText = 'Search',
|
||||
showButton = true,
|
||||
action = '/',
|
||||
style = {},
|
||||
} = 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>`
|
||||
: '';
|
||||
|
||||
// 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 {
|
||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<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>
|
||||
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||
</div>
|
||||
${btnHtml}
|
||||
</form>`,
|
||||
|
||||
@@ -51,3 +51,43 @@ describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap i
|
||||
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;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
@@ -151,6 +156,11 @@ SocialLinks.craft = {
|
||||
gap: '10px',
|
||||
alignment: 'center',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
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"/);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
emptyColor?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const StarRating: UserComponent<StarRatingProps> = ({
|
||||
@@ -87,6 +92,11 @@ StarRating.craft = {
|
||||
filledColor: '#f59e0b',
|
||||
emptyColor: '#d1d5db',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -20,3 +20,48 @@ describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', ()
|
||||
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',
|
||||
lineHeight: '1.6',
|
||||
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: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -126,3 +126,73 @@ describe('ContactForm.toHtml field type attribute sanitization', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,17 @@ import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
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 {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
type: ContactFormFieldType;
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder: string;
|
||||
@@ -25,6 +34,11 @@ interface ContactFormProps {
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
@@ -157,6 +171,11 @@ ContactForm.craft = {
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -22,4 +22,19 @@ describe('FormButton.toHtml', () => {
|
||||
expect(html).toContain('&');
|
||||
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 {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const FormButton: UserComponent<FormButtonProps> = ({
|
||||
@@ -58,6 +63,11 @@ FormButton.craft = {
|
||||
fontSize: '16px',
|
||||
border: 'none',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -52,3 +52,20 @@ describe('FormContainer.toHtml method attribute sanitization', () => {
|
||||
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;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const FormContainer: UserComponent<FormContainerProps> = ({
|
||||
@@ -59,6 +64,11 @@ FormContainer.craft = {
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e4e4e7',
|
||||
},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -72,3 +72,20 @@ describe('InputField.toHtml type attribute sanitization', () => {
|
||||
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;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const InputField: UserComponent<InputFieldProps> = ({
|
||||
@@ -77,6 +82,11 @@ InputField.craft = {
|
||||
placeholder: 'Enter your name',
|
||||
required: false,
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -6,7 +6,7 @@ const toHtml = (SubscribeForm as any).toHtml;
|
||||
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
|
||||
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, '');
|
||||
expect(html).toContain('<form method="POST"');
|
||||
expect(html).toMatch(/<form action="[^"]*" method="POST"/);
|
||||
expect(html).not.toContain('<script');
|
||||
});
|
||||
|
||||
@@ -37,3 +37,45 @@ describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop
|
||||
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 { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface SubscribeFormProps {
|
||||
@@ -10,6 +11,17 @@ interface SubscribeFormProps {
|
||||
buttonColor?: string;
|
||||
layout?: 'inline' | 'stacked';
|
||||
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> = ({
|
||||
@@ -110,6 +122,13 @@ SubscribeForm.craft = {
|
||||
buttonColor: '#3b82f6',
|
||||
layout: 'inline',
|
||||
style: { backgroundColor: '#f8fafc' },
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -120,7 +139,7 @@ SubscribeForm.craft = {
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||
const {
|
||||
heading = 'Subscribe to our newsletter',
|
||||
placeholder = 'Enter your email',
|
||||
@@ -166,11 +185,21 @@ SubscribeForm.craft = {
|
||||
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 {
|
||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||
<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${honeypot ? ` ${honeypot}\n` : ''} <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
||||
</form>
|
||||
</div>`,
|
||||
|
||||
@@ -63,3 +63,20 @@ describe('TextareaField.toHtml rows attribute sanitization', () => {
|
||||
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;
|
||||
required?: boolean;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export const TextareaField: UserComponent<TextareaFieldProps> = ({
|
||||
@@ -79,6 +84,11 @@ TextareaField.craft = {
|
||||
rows: 4,
|
||||
required: false,
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* ImageBlock only needs useNode from @craftjs/core. Mock it following the
|
||||
DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no
|
||||
@testing-library/react in this repo) so we can render the real component
|
||||
tree and inspect the emitted <img src> without a real <Editor>. */
|
||||
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 { ImageBlock } from './ImageBlock';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => {
|
||||
test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => {
|
||||
render(<ImageBlock src="" alt="Image" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).not.toBe('');
|
||||
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => {
|
||||
render(<ImageBlock alt="Image" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).not.toBe('');
|
||||
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('a real src is rendered unchanged', () => {
|
||||
render(<ImageBlock src="https://example.com/photo.jpg" alt="A photo" />);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg');
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { ImageBlock, pxAttr } from './ImageBlock';
|
||||
|
||||
const toHtml = (ImageBlock as any).toHtml;
|
||||
|
||||
@@ -32,3 +32,95 @@ describe('ImageBlock.toHtml src/alt XSS hardening', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import React, { CSSProperties, useCallback, useRef } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeAttr, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
||||
export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
||||
|
||||
interface ImageBlockProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
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
|
||||
@@ -66,7 +83,7 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
imgRef.current = ref;
|
||||
if (ref) connect(drag(ref));
|
||||
}}
|
||||
src={src}
|
||||
src={src || PLACEHOLDER_SRC}
|
||||
alt={alt || 'Image'}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -83,7 +100,27 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
||||
|
||||
ImageBlock.craft = {
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -95,5 +132,8 @@ ImageBlock.craft = {
|
||||
}
|
||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
||||
return { html: `<img src="${escapeAttr(safeUrl(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', () => {
|
||||
test('a malicious address cannot break out of the src or title attribute', () => {
|
||||
const malicious = 'X" onerror="alert(1)';
|
||||
|
||||
@@ -8,6 +8,11 @@ interface MapEmbedProps {
|
||||
zoom?: number;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
function buildMapUrl(address: string, zoom: number): string {
|
||||
@@ -70,7 +75,18 @@ MapEmbed.craft = {
|
||||
address: 'New York, NY',
|
||||
zoom: 14,
|
||||
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: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -118,3 +118,75 @@ describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor
|
||||
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 { Container } from '../layout/Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
@@ -12,6 +12,7 @@ interface VideoBlockProps {
|
||||
videoUrl?: string;
|
||||
videoType?: VideoType;
|
||||
embedUrl?: string;
|
||||
poster?: string;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
@@ -22,6 +23,11 @@ interface VideoBlockProps {
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
/* ---------- URL detection ---------- */
|
||||
@@ -120,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
videoUrl = '',
|
||||
videoType: _videoTypeProp,
|
||||
embedUrl: _embedUrlProp,
|
||||
poster = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop = false,
|
||||
@@ -259,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: 0,
|
||||
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
@@ -269,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
@@ -284,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
|
||||
{type === 'file' && (
|
||||
<video
|
||||
src={embedUrl}
|
||||
poster={poster || undefined}
|
||||
autoPlay={autoplay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
controls={controls}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||
objectFit: 'cover',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -310,6 +319,7 @@ VideoBlock.craft = {
|
||||
videoUrl: '',
|
||||
videoType: 'none',
|
||||
embedUrl: '',
|
||||
poster: '',
|
||||
autoplay: false,
|
||||
muted: true,
|
||||
loop: false,
|
||||
@@ -318,7 +328,20 @@ VideoBlock.craft = {
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 50,
|
||||
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: {
|
||||
canDrag: () => true,
|
||||
@@ -334,6 +357,7 @@ VideoBlock.craft = {
|
||||
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
|
||||
const {
|
||||
videoUrl = '',
|
||||
poster = '',
|
||||
autoplay = false,
|
||||
muted = true,
|
||||
loop: doLoop = false,
|
||||
@@ -422,15 +446,13 @@ VideoBlock.craft = {
|
||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
|
||||
const containerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
paddingBottom: '56.25%',
|
||||
height: '0',
|
||||
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
|
||||
overflow: 'hidden',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
const iframeStyle = cssPropsToString({
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
inset: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
@@ -447,13 +469,16 @@ VideoBlock.craft = {
|
||||
if (doLoop) vidAttrs.push('loop');
|
||||
if (controls) vidAttrs.push('controls');
|
||||
vidAttrs.push('playsinline');
|
||||
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
|
||||
const vidStyle = cssPropsToString({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
aspectRatio: (style as any)?.aspectRatio || undefined,
|
||||
objectFit: 'cover',
|
||||
borderRadius: (style as any)?.borderRadius || undefined,
|
||||
});
|
||||
|
||||
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;
|
||||
borderColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
@@ -135,6 +140,11 @@ Accordion.craft = {
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
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;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
@@ -83,6 +88,11 @@ CTASection.craft = {
|
||||
gradient: defaultGradient,
|
||||
anchorId: '',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
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;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
@@ -131,6 +136,11 @@ CallToAction.craft = {
|
||||
textColor: '#ffffff',
|
||||
buttonColor: '#ffffff',
|
||||
style: {},
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -109,6 +109,15 @@ describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT
|
||||
expect(html).not.toContain('background-image:url(');
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
|
||||
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const slidesWithSvg = [
|
||||
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
|
||||
];
|
||||
const { html } = toHtml({ slides: slidesWithSvg }, '');
|
||||
expect(html).toContain(`background-image:url('${svgDataUri}')`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
|
||||
@@ -130,3 +139,46 @@ describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be c
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Slide {
|
||||
type: 'image' | 'content';
|
||||
@@ -21,6 +21,11 @@ interface ContentSliderProps {
|
||||
showArrows?: boolean;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultSlides: Slide[] = [
|
||||
@@ -227,7 +232,18 @@ ContentSlider.craft = {
|
||||
showDots: true,
|
||||
showArrows: true,
|
||||
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: {
|
||||
canDrag: () => true,
|
||||
@@ -283,7 +299,7 @@ ContentSlider.craft = {
|
||||
// sink (a malicious value could break out of the style="..." attribute).
|
||||
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
|
||||
const bgStyle = hasBgImage
|
||||
? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
|
||||
? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? `background-image:${safeBgColor}`
|
||||
: `background-color:${safeBgColor}`;
|
||||
|
||||
@@ -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*\{/);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
bgColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
@@ -139,6 +144,11 @@ Countdown.craft = {
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { FeaturesGrid } from './FeaturesGrid';
|
||||
|
||||
const toHtml = (FeaturesGrid as any).toHtml;
|
||||
|
||||
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
|
||||
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
|
||||
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
expect(html).toContain(`<img src="${svgDataUri}"`);
|
||||
});
|
||||
|
||||
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
|
||||
const features = [
|
||||
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
|
||||
];
|
||||
const { html } = toHtml({ features }, '');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
@@ -17,6 +17,11 @@ interface FeaturesGridProps {
|
||||
features?: FeatureItem[];
|
||||
style?: CSSProperties;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
||||
@@ -98,6 +103,11 @@ FeaturesGrid.craft = {
|
||||
features: defaultFeatures,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -116,7 +126,7 @@ FeaturesGrid.craft = {
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||
const media = feat.image
|
||||
? `<img src="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||
? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
|
||||
const button = feat.buttonText
|
||||
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
|
||||
|
||||
@@ -3,6 +3,73 @@ import { Gallery } from './Gallery';
|
||||
|
||||
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)', () => {
|
||||
test('no per-item inline onclick with interpolated src', () => {
|
||||
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||
@@ -94,6 +161,32 @@ describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Ma
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml default SVG placeholder images survive export (Bug 2 regression)', () => {
|
||||
test('a default data:image/svg+xml image emits a non-empty img src, not src=""', () => {
|
||||
const { html } = toHtml({}, ''); // no images prop -> component default SVG placeholders
|
||||
expect(html).not.toContain('src=""');
|
||||
expect(html).toMatch(/src="data:image\/svg\+xml[^"]*"/);
|
||||
});
|
||||
|
||||
test('an explicit data:image/svg+xml gallery image src is preserved (not stripped to empty)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }] }, '');
|
||||
expect(html).toContain(`src="${svg}"`);
|
||||
});
|
||||
|
||||
test('lightbox data-lb-src also preserves data:image/svg+xml (still an image context)', () => {
|
||||
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
|
||||
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }], lightbox: true }, '');
|
||||
expect(html).toContain(`data-lb-src="${svg}"`);
|
||||
});
|
||||
|
||||
test('a javascript: gallery image src still yields an empty src (safeImageUrl still blocks it)', () => {
|
||||
const { html } = toHtml({ images: [{ src: 'javascript:alert(1)', alt: 'a' }] }, '');
|
||||
expect(html).toContain('src=""');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gallery.toHtml lightbox focus management (M-2)', () => {
|
||||
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
@@ -15,6 +15,11 @@ interface GalleryProps {
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
lightbox?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const placeholderSvg = (index: number) => {
|
||||
@@ -112,8 +117,20 @@ Gallery.craft = {
|
||||
images: defaultImages,
|
||||
columns: 3,
|
||||
gap: '16px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
opacity: '1',
|
||||
},
|
||||
lightbox: false,
|
||||
animation: '',
|
||||
animationDelay: '0',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -154,10 +171,10 @@ Gallery.craft = {
|
||||
// inline onclick with an interpolated src -- a single delegated click
|
||||
// listener below reads it, so a src containing a quote can't break out
|
||||
// of a per-item event-handler string.
|
||||
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}" role="button" tabindex="0"` : '';
|
||||
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : '';
|
||||
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
|
||||
return `<div${lbAttr} style="${itemStyle}">
|
||||
<img src="${escapeAttr(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
<img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
${caption}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
@@ -73,3 +73,14 @@ describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- mus
|
||||
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;
|
||||
numberSize?: string;
|
||||
style?: CSSProperties;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultCounters: Counter[] = [
|
||||
@@ -99,6 +104,11 @@ NumberCounter.craft = {
|
||||
labelColor: '#6b7280',
|
||||
numberSize: '48px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
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;
|
||||
bulletType?: 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> = {
|
||||
@@ -61,6 +78,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
anchorId,
|
||||
cardBg,
|
||||
textColor,
|
||||
subColor,
|
||||
featColor,
|
||||
checkColor,
|
||||
btnBg,
|
||||
btnColor,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -69,6 +93,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
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 (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
@@ -95,7 +127,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
style={{
|
||||
flex: '1 1 280px',
|
||||
maxWidth: '360px',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
|
||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
@@ -127,7 +159,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
marginBottom: '8px',
|
||||
marginTop: plan.isFeatured ? '8px' : '0',
|
||||
}}>
|
||||
@@ -137,14 +169,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
<span style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
||||
lineHeight: '1',
|
||||
}}>
|
||||
{plan.price}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
|
||||
}}>
|
||||
{plan.period}
|
||||
</span>
|
||||
@@ -161,12 +193,12 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||
<li key={fi} style={{
|
||||
fontSize: '14px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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}
|
||||
</li>
|
||||
))}
|
||||
@@ -178,8 +210,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
marginTop: 'auto',
|
||||
display: 'inline-block',
|
||||
padding: '14px 32px',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
|
||||
color: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
|
||||
color: plan.isFeatured ? featuredBg : regBtnColor,
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
@@ -207,6 +239,18 @@ PricingTable.craft = {
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
anchorId: '',
|
||||
cardBg: '',
|
||||
textColor: '',
|
||||
subColor: '',
|
||||
featColor: '',
|
||||
checkColor: '',
|
||||
btnBg: '',
|
||||
btnColor: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -228,16 +272,28 @@ PricingTable.craft = {
|
||||
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
||||
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
||||
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 cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
|
||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
|
||||
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);';
|
||||
|
||||
|
||||
@@ -80,3 +80,14 @@ describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-co
|
||||
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;
|
||||
contentBg?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
@@ -114,6 +119,11 @@ Tabs.craft = {
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
starColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
@@ -131,6 +136,11 @@ Testimonials.craft = {
|
||||
cardBg: '#f8fafc',
|
||||
starColor: '#f59e0b',
|
||||
anchorId: '',
|
||||
animation: '',
|
||||
animationDelay: '',
|
||||
hideOnDesktop: false,
|
||||
hideOnTablet: false,
|
||||
hideOnMobile: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
|
||||
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
|
||||
tablet: '768px',
|
||||
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' },
|
||||
];
|
||||
|
||||
+94
-14
@@ -1,13 +1,17 @@
|
||||
import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element } from '@craftjs/core';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
||||
showGuides: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,24 +85,93 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-zone-preview={zone}
|
||||
className="zone-preview-sep"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none',
|
||||
borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
/**
|
||||
* First-run hint shown over the canvas drop area once the current page's
|
||||
* root node exists and has no children yet. Hidden the instant something
|
||||
* is dropped in, and while a drag is in progress (so it never fights the
|
||||
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
|
||||
* editor.css) keeps it from intercepting clicks/drops meant for the
|
||||
* underlying empty canvas.
|
||||
*/
|
||||
export const EmptyCanvasHint: React.FC = () => {
|
||||
const { isEmpty, isDragging } = useEditor((state) => {
|
||||
const root = state.nodes['ROOT'];
|
||||
return {
|
||||
isEmpty: !!root && root.data.nodes.length === 0,
|
||||
isDragging: state.events.dragged.size > 0,
|
||||
};
|
||||
});
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isEmpty || isDragging) return null;
|
||||
|
||||
return (
|
||||
<div className="empty-canvas-hint">
|
||||
<i className="fa fa-cubes" aria-hidden />
|
||||
<span>
|
||||
{isMobile
|
||||
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
|
||||
: 'Drag blocks from the left panel, or pick a Template to start.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
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
|
||||
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
|
||||
: isEditingFooter
|
||||
@@ -108,9 +181,13 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
|
||||
|
||||
return (
|
||||
<div className="editor-canvas">
|
||||
<div
|
||||
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
|
||||
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="canvas-device-frame"
|
||||
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
|
||||
style={{
|
||||
width,
|
||||
maxWidth: '100%',
|
||||
@@ -140,14 +217,17 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
|
||||
<ZonePreview craftState={headerPage.craftState} zone="header" />
|
||||
)}
|
||||
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Frame>
|
||||
<Element
|
||||
is={Container}
|
||||
canvas
|
||||
tag={frameTag}
|
||||
style={frameStyle}
|
||||
/>
|
||||
</Frame>
|
||||
{isEditingRegularPage && <EmptyCanvasHint />}
|
||||
</div>
|
||||
|
||||
{isEditingRegularPage && (
|
||||
<ZonePreview craftState={footerPage.craftState} zone="footer" />
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
import { RightPanel } from '../panels/right/RightPanel';
|
||||
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
||||
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
|
||||
import { Canvas } from './Canvas';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
|
||||
function loadShowGuides(): boolean {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
|
||||
return stored === null ? true : stored === '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
// Phase A: default the canvas to the "mobile" preview width the first
|
||||
// time we detect a mobile viewport, so the frame fits without the user
|
||||
// having to reach for the device switcher (now tucked in TopBar's
|
||||
// overflow menu on mobile). Only fires once, and only if the device is
|
||||
// still at its initial default -- it must not fight a device the user
|
||||
// has already picked (e.g. from the overflow menu) on a later re-render.
|
||||
const mobileDeviceAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isMobile && !mobileDeviceAppliedRef.current) {
|
||||
mobileDeviceAppliedRef.current = true;
|
||||
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
|
||||
}
|
||||
}, [isMobile]);
|
||||
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
|
||||
// so the choice survives a reload. Lifted here (rather than owned by
|
||||
// TopBar or Canvas alone) because the toggle button lives in TopBar but
|
||||
// the `.guides-off` class it drives is applied to Canvas's
|
||||
// `.canvas-device-frame`, mirroring how `device` is already lifted for
|
||||
// the same reason.
|
||||
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
|
||||
const setShowGuides = useCallback((next: boolean) => {
|
||||
setShowGuidesState(next);
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
|
||||
}
|
||||
}, []);
|
||||
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
||||
const { query } = useEditor();
|
||||
|
||||
@@ -33,22 +77,36 @@ export const EditorShell: React.FC = () => {
|
||||
}, [query, showMenu]);
|
||||
|
||||
return (
|
||||
<div className="editor-app">
|
||||
<TopBar device={device} onDeviceChange={setDevice} />
|
||||
<div className="editor-container">
|
||||
<LeftPanel />
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} />
|
||||
// Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
|
||||
// provided around the whole shell so TopBar's Templates/Head Code modal
|
||||
// state and MobilePanelBar's sheet state live in one place. Desktop
|
||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||
// identically to before (same booleans, just sourced from context).
|
||||
<MobileChromeProvider>
|
||||
<div className="editor-app">
|
||||
<TopBar
|
||||
device={device}
|
||||
onDeviceChange={setDevice}
|
||||
showGuides={showGuides}
|
||||
onToggleGuides={() => setShowGuides(!showGuides)}
|
||||
/>
|
||||
<div className="editor-container">
|
||||
{!isMobile && <LeftPanel />}
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
<Canvas device={device} showGuides={showGuides} />
|
||||
</div>
|
||||
{!isMobile && <RightPanel />}
|
||||
</div>
|
||||
<RightPanel />
|
||||
{isMobile && <MobilePanelBar />}
|
||||
{isMobile && <MobileSelectionToolbar />}
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
y={menuState.y}
|
||||
nodeId={menuState.nodeId}
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu
|
||||
visible={menuState.visible}
|
||||
x={menuState.x}
|
||||
y={menuState.y}
|
||||
nodeId={menuState.nodeId}
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</MobileChromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
|
||||
useEditor so we can drive editor state without a real <Editor> tree. */
|
||||
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
|
||||
let mockDraggedSize = 0;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collect: (state: any) => any) =>
|
||||
collect({
|
||||
nodes: mockNodes,
|
||||
events: { dragged: { size: mockDraggedSize } },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { EmptyCanvasHint } from './Canvas';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNodes = {};
|
||||
mockDraggedSize = 0;
|
||||
});
|
||||
|
||||
describe('EmptyCanvasHint', () => {
|
||||
test('renders nothing before ROOT has mounted (no root node yet)', () => {
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('renders the hint once ROOT exists with zero children', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
|
||||
expect(container.textContent).toContain('Drag blocks from the left panel');
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides once the page has content', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test('hides while a drag is in progress, even on an empty root', () => {
|
||||
mockNodes = { ROOT: { data: { nodes: [] } } };
|
||||
mockDraggedSize = 1;
|
||||
render(<EmptyCanvasHint />);
|
||||
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
|
||||
so RenderNode (the <Editor onRender> override) can be driven without a
|
||||
real Editor tree. document.body doubles as the portal target, same as
|
||||
the component itself uses. */
|
||||
let mockNode: {
|
||||
id: string;
|
||||
selected: boolean;
|
||||
hovered: boolean;
|
||||
dom: HTMLElement | null;
|
||||
displayName: string;
|
||||
parent: string | null;
|
||||
isCanvas: boolean;
|
||||
};
|
||||
const selectNodeSpy = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = {
|
||||
events: { selected: mockNode.selected, hovered: mockNode.hovered },
|
||||
dom: mockNode.dom,
|
||||
data: {
|
||||
custom: {},
|
||||
displayName: mockNode.displayName,
|
||||
parent: mockNode.parent,
|
||||
isCanvas: mockNode.isCanvas,
|
||||
},
|
||||
};
|
||||
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
|
||||
},
|
||||
}));
|
||||
|
||||
import { RenderNode } from './RenderNode';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let nodeDom: HTMLElement;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nodeDom = document.createElement('div');
|
||||
document.body.appendChild(nodeDom);
|
||||
mockNode = {
|
||||
id: 'node-1',
|
||||
selected: false,
|
||||
hovered: false,
|
||||
dom: nodeDom,
|
||||
displayName: 'Heading',
|
||||
parent: 'ROOT',
|
||||
isCanvas: false,
|
||||
};
|
||||
selectNodeSpy.mockClear();
|
||||
});
|
||||
|
||||
const rendered = <span data-testid="inner">hello</span>;
|
||||
|
||||
describe('RenderNode (Editor onRender override)', () => {
|
||||
test('passes render through untouched when not selected', () => {
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('shows the badge with the displayName when selected', () => {
|
||||
mockNode.selected = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
const badge = document.querySelector('.component-indicator');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent).toContain('Heading');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('never shows a badge for ROOT even if "selected"', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.id = 'ROOT';
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('chevron click selects the parent node', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = 'parent-42';
|
||||
render(<RenderNode render={rendered} />);
|
||||
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
|
||||
expect(chevron).not.toBeNull();
|
||||
act(() => {
|
||||
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('no chevron when there is no parent', () => {
|
||||
mockNode.selected = true;
|
||||
mockNode.parent = null;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
document.querySelector('.component-indicator')?.remove();
|
||||
});
|
||||
|
||||
test('tags a droppable (isCanvas) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => {
|
||||
mockNode.isCanvas = false;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => {
|
||||
mockNode.id = 'ROOT';
|
||||
mockNode.isCanvas = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
|
||||
test('removes data-craft-hovered once the hovered event clears', () => {
|
||||
mockNode.hovered = true;
|
||||
render(<RenderNode render={rendered} />);
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
|
||||
mockNode.hovered = false;
|
||||
act(() => {
|
||||
root.render(<RenderNode render={rendered} />);
|
||||
});
|
||||
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false);
|
||||
container.remove();
|
||||
nodeDom.remove();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor, useNode } from '@craftjs/core';
|
||||
|
||||
interface RenderNodeProps {
|
||||
render: React.ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
|
||||
* For the currently-selected node it portals a floating badge (component
|
||||
* displayName + a "select parent" chevron) positioned over the node's real
|
||||
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
|
||||
* straight through as a Fragment, so this never touches layout, never
|
||||
* appears in `toHtml` export (that walks the Craft node tree, not this
|
||||
* portal), and doesn't wrap every node in extra DOM.
|
||||
*
|
||||
* It also imperatively tags each node's real DOM element with two
|
||||
* editor-only data attributes (never part of `toHtml` export, which walks
|
||||
* the Craft node tree, not the live DOM):
|
||||
* - `data-craft-node`: set on actual Craft.js droppable containers
|
||||
* (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide"
|
||||
* outlines target this attribute instead of blanket tag selectors
|
||||
* (div/section/header/...), so a component's own internal wrapper markup
|
||||
* no longer picks up a guide outline it isn't a real drop target for.
|
||||
* - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover
|
||||
* event set). Craft's own `connectors.connect()` (called by every
|
||||
* component) wires a native mouseover/mouseleave listener to this event
|
||||
* internally, so a plain mouse hover over any connected node sets it --
|
||||
* this attribute is a real-mouse-hover canvas highlight. (The Layers
|
||||
* panel's row-hover -> canvas-highlight sync, item 12, uses a sibling
|
||||
* `data-layer-hovered` attribute written directly by LayersPanel.tsx
|
||||
* instead of this event, since the action that would drive it here
|
||||
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
|
||||
* API at runtime.) editor.css matches both attributes for the same
|
||||
* outline and suppresses both under `.guides-off`.
|
||||
*/
|
||||
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
|
||||
isSelected: node.events.selected,
|
||||
isHovered: node.events.hovered,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
isCanvas: node.data.isCanvas,
|
||||
}));
|
||||
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
const active = isSelected && id !== 'ROOT' && !!dom;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!dom || !badgeRef.current) return;
|
||||
const rect = dom.getBoundingClientRect();
|
||||
const badgeHeight = 22;
|
||||
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
|
||||
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
|
||||
}, [dom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
document.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
document.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [active, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isCanvas && id !== 'ROOT') {
|
||||
dom.setAttribute('data-craft-node', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-node');
|
||||
}
|
||||
}, [dom, isCanvas, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (isHovered) {
|
||||
dom.setAttribute('data-craft-hovered', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-craft-hovered');
|
||||
}
|
||||
}, [dom, isHovered]);
|
||||
|
||||
if (!active) return <>{render}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{render}
|
||||
{createPortal(
|
||||
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||
<span>{name}</span>
|
||||
{parent && (
|
||||
<button
|
||||
type="button"
|
||||
className="component-indicator-parent-btn"
|
||||
title="Select parent"
|
||||
aria-label={`Select parent of ${name}`}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
actions.selectNode(parent);
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-chevron-up" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
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 { useIsMobile } from './useIsMobile';
|
||||
|
||||
/**
|
||||
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
|
||||
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
|
||||
* from, so it needs direct coverage of: (a) it reflects matchMedia's
|
||||
* `matches`, (b) the `change` listener is registered AND cleaned up on
|
||||
* unmount (a leaked listener would keep re-rendering an unmounted tree /
|
||||
* leak the component instance), and (c) the legacy Safari<14
|
||||
* addListener/removeListener fallback is used when addEventListener isn't
|
||||
* available.
|
||||
*
|
||||
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
|
||||
* (not a project dependency).
|
||||
*/
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let originalMatchMedia: typeof window.matchMedia;
|
||||
|
||||
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 MediaQueryList. `mode` picks which listener API it
|
||||
* exposes, so tests can force the legacy fallback path. */
|
||||
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
|
||||
const listeners: Array<() => void> = [];
|
||||
const mql: any = {
|
||||
matches,
|
||||
media: '(max-width: 768px)',
|
||||
};
|
||||
if (mode === 'modern') {
|
||||
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
|
||||
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i !== -1) listeners.splice(i, 1);
|
||||
});
|
||||
} else {
|
||||
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
|
||||
mql.removeListener = vi.fn((fn: () => void) => {
|
||||
const i = listeners.indexOf(fn);
|
||||
if (i !== -1) listeners.splice(i, 1);
|
||||
});
|
||||
}
|
||||
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
|
||||
}
|
||||
|
||||
let probedValue: boolean | null = null;
|
||||
const Probe: React.FC = () => {
|
||||
probedValue = useIsMobile();
|
||||
return <span data-value={String(probedValue)} />;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalMatchMedia = window.matchMedia;
|
||||
probedValue = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
test('returns true when the mobile media query matches', () => {
|
||||
const { mql } = makeMql(true);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('returns false when the mobile media query does not match', () => {
|
||||
const { mql } = makeMql(false);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('registers the change listener via addEventListener and updates on change', () => {
|
||||
const { mql, fire, listenerCount } = makeMql(false);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
// Flip the query result and fire the registered 'change' listener --
|
||||
// the hook must re-read mql.matches, not just toggle blindly.
|
||||
mql.matches = true;
|
||||
act(() => {
|
||||
fire();
|
||||
});
|
||||
expect(probedValue).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('cleans up the addEventListener listener on unmount', () => {
|
||||
const { mql, listenerCount } = makeMql(true);
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
expect(listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
|
||||
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
|
||||
window.matchMedia = vi.fn(() => mql) as any;
|
||||
|
||||
render(<Probe />);
|
||||
expect(probedValue).toBe(false);
|
||||
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(listenerCount()).toBe(1);
|
||||
|
||||
mql.matches = true;
|
||||
act(() => {
|
||||
fire();
|
||||
});
|
||||
expect(probedValue).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
|
||||
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
|
||||
* CSS and this hook must agree on where mobile chrome kicks in. */
|
||||
export const MOBILE_BREAKPOINT_PX = 768;
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
|
||||
|
||||
function computeIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
try {
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
} catch {
|
||||
// Fall through to the width check below (e.g. some older/embedded
|
||||
// WebViews expose a matchMedia that throws instead of omitting it).
|
||||
}
|
||||
}
|
||||
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the viewport is at or below the mobile editor breakpoint.
|
||||
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
|
||||
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
|
||||
* above the breakpoint, so this is the single source of truth both React
|
||||
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
|
||||
*
|
||||
* Falls back to a `resize` listener + `window.innerWidth` when
|
||||
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
|
||||
* hook degrades gracefully rather than throwing.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
let mql: MediaQueryList | null = null;
|
||||
try {
|
||||
mql = window.matchMedia(MOBILE_QUERY);
|
||||
} catch {
|
||||
mql = null;
|
||||
}
|
||||
|
||||
if (mql) {
|
||||
const handleChange = () => setIsMobile(mql!.matches);
|
||||
handleChange();
|
||||
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', handleChange);
|
||||
return () => mql!.removeEventListener('change', handleChange);
|
||||
}
|
||||
|
||||
// Safari < 14 only supports the deprecated addListener/removeListener pair.
|
||||
const legacyMql = mql as MediaQueryList & {
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
};
|
||||
legacyMql.addListener?.(handleChange);
|
||||
return () => legacyMql.removeListener?.(handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { findDeletableTarget } from '../../utils/craft-helpers';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||||
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
@@ -16,6 +16,8 @@ interface ContextMenuProps {
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
danger?: boolean;
|
||||
@@ -63,19 +65,16 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
}
|
||||
}, [nodeId, query]);
|
||||
|
||||
const duplicate = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
try {
|
||||
const parentId = getParentId();
|
||||
if (!parentId) return;
|
||||
// Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
|
||||
// extracted into `useNodeActions` so the mobile selection toolbar drives
|
||||
// the exact same behavior. Wrapped here purely to also `onClose()` the
|
||||
// menu after each action, same as before the extraction.
|
||||
const nodeActions = useNodeActions(nodeId);
|
||||
|
||||
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
||||
actions.addNodeTree(tree, parentId);
|
||||
} catch (e) {
|
||||
console.error('Duplicate failed:', e);
|
||||
}
|
||||
const duplicate = useCallback(() => {
|
||||
nodeActions.duplicate();
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
}, [nodeActions, onClose]);
|
||||
|
||||
const copyNode = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
@@ -116,47 +115,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
}, [nodeId, actions, query, onClose]);
|
||||
|
||||
const moveUp = useCallback(() => {
|
||||
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);
|
||||
}
|
||||
nodeActions.moveUp();
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
}, [nodeActions, onClose]);
|
||||
|
||||
const moveDown = useCallback(() => {
|
||||
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);
|
||||
}
|
||||
nodeActions.moveDown();
|
||||
onClose();
|
||||
}, [nodeId, actions, query, getParentId, onClose]);
|
||||
}, [nodeActions, onClose]);
|
||||
|
||||
const selectParent = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
const parentId = getParentId();
|
||||
if (parentId) {
|
||||
actions.selectNode(parentId);
|
||||
}
|
||||
nodeActions.selectParent();
|
||||
onClose();
|
||||
}, [nodeId, actions, getParentId, onClose]);
|
||||
}, [nodeActions, onClose]);
|
||||
|
||||
const askSitesmith = useCallback(() => {
|
||||
if (!nodeId || nodeId === 'ROOT') return;
|
||||
@@ -170,18 +141,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
}, [nodeId, query, openSitesmith, onClose]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
const target = findDeletableTarget(query, nodeId);
|
||||
if (!target) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
actions.delete(target);
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
}
|
||||
nodeActions.deleteNode();
|
||||
onClose();
|
||||
}, [nodeId, actions, query, onClose]);
|
||||
}, [nodeActions, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
@@ -189,25 +151,29 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: '✨ Ask Sitesmith',
|
||||
label: 'Ask Sitesmith',
|
||||
icon: 'magic',
|
||||
action: askSitesmith,
|
||||
disabled: isRoot,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
icon: 'clone',
|
||||
shortcut: 'Ctrl+D',
|
||||
action: duplicate,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
icon: 'files-o',
|
||||
shortcut: 'Ctrl+C',
|
||||
action: copyNode,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Paste',
|
||||
icon: 'clipboard',
|
||||
shortcut: 'Ctrl+V',
|
||||
action: pasteNode,
|
||||
disabled: !getClipboardNodeId(),
|
||||
@@ -215,22 +181,31 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
},
|
||||
{
|
||||
label: 'Move Up',
|
||||
icon: 'arrow-up',
|
||||
action: moveUp,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Move Down',
|
||||
icon: 'arrow-down',
|
||||
action: moveDown,
|
||||
disabled: isRoot,
|
||||
},
|
||||
{
|
||||
label: 'Select Parent',
|
||||
icon: 'level-up',
|
||||
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,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'trash',
|
||||
shortcut: 'Del',
|
||||
action: deleteNode,
|
||||
danger: true,
|
||||
@@ -291,7 +266,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
(e.target as HTMLElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>
|
||||
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAssets } from '../../hooks/useAssets';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
|
||||
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
|
||||
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||
|
||||
export const AssetsPanel: React.FC = () => {
|
||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||
const isMobile = useIsMobile();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
@@ -107,23 +109,41 @@ export const AssetsPanel: React.FC = () => {
|
||||
{loading ? 'Uploading...' : 'Upload File'}
|
||||
</button>
|
||||
|
||||
{/* Drop zone */}
|
||||
{/* Drop zone -- a single element that doubles as the empty state.
|
||||
Previously this was a small always-visible dropzone PLUS a
|
||||
separate italic "No assets uploaded yet" line stacked underneath
|
||||
it when empty; merged into one tall dropzone (icon + copy,
|
||||
click-or-drag) so the empty state isn't two redundant messages.
|
||||
Once assets exist it collapses back to a slim persistent drop
|
||||
target above the grid. */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
|
||||
style={{
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: assets.length === 0 ? '36px 20px' : 16,
|
||||
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
|
||||
textAlign: 'center',
|
||||
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
||||
fontSize: 11,
|
||||
cursor: assets.length === 0 ? 'pointer' : 'default',
|
||||
transition: 'all var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
Drop files here to upload
|
||||
{assets.length === 0 && (
|
||||
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
|
||||
)}
|
||||
{assets.length === 0
|
||||
? (isMobile ? 'Tap to upload images' : 'Drag images here or click to upload')
|
||||
: 'Drop files here to upload'}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -143,20 +163,6 @@ export const AssetsPanel: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Asset grid */}
|
||||
{assets.length === 0 && !loading && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 20,
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No assets uploaded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@@ -296,7 +302,7 @@ export const AssetsPanel: React.FC = () => {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -328,7 +334,7 @@ export const AssetsPanel: React.FC = () => {
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-times" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
import { Container } from '../../components/layout/Container';
|
||||
import { Section } from '../../components/layout/Section';
|
||||
import { ColumnLayout } from '../../components/layout/ColumnLayout';
|
||||
@@ -123,7 +125,7 @@ const categories: CategoryDef[] = [
|
||||
label: 'Media',
|
||||
blocks: [
|
||||
{ id: 'image', label: 'Image', icon: 'fa-image',
|
||||
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
|
||||
component: <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
|
||||
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
|
||||
component: <VideoBlock videoUrl="" isBackground={false} /> },
|
||||
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
|
||||
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
|
||||
|
||||
export const BlocksPanel: React.FC = () => {
|
||||
const { connectors, actions, query } = useEditor();
|
||||
const isMobile = useIsMobile();
|
||||
const { closeSheet } = useMobileChrome();
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
categories.forEach((cat, index) => {
|
||||
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
|
||||
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 (
|
||||
<div>
|
||||
{categories.map((category) => {
|
||||
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
|
||||
key={block.id}
|
||||
className="block-item"
|
||||
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
|
||||
onDoubleClick={() => {
|
||||
try {
|
||||
const serialized = JSON.parse(query.serialize());
|
||||
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}`}
|
||||
onDoubleClick={() => addBlockNode(block, false)}
|
||||
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
|
||||
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
|
||||
>
|
||||
<i className={`fa ${block.icon} block-item-icon`} />
|
||||
<span className="block-item-label">{block.label}</span>
|
||||
|
||||
@@ -1,14 +1,74 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
|
||||
/**
|
||||
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
||||
* same human label `LayerNode` already resolves and shows as the row text).
|
||||
* Mirrors the icon choices in `BlocksPanel.tsx` where a block exists for the
|
||||
* type; the remaining entries (Navbar, Container, Section, Columns,
|
||||
* Background Section, Map, Video, Image, form fields, and the richer
|
||||
* section blocks not draggable from the Basic/Sections categories) get a
|
||||
* sensible FA4 icon of their own. Unknown/future types fall back to
|
||||
* `DEFAULT_ICON`.
|
||||
*/
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
// Layout
|
||||
Container: 'fa-square-o',
|
||||
Section: 'fa-window-maximize',
|
||||
Columns: 'fa-th-large',
|
||||
'Background Section': 'fa-picture-o',
|
||||
// Basic
|
||||
Heading: 'fa-header',
|
||||
Text: 'fa-paragraph',
|
||||
Button: 'fa-square',
|
||||
Logo: 'fa-bookmark',
|
||||
Menu: 'fa-bars',
|
||||
Navbar: 'fa-bars',
|
||||
Footer: 'fa-window-minimize',
|
||||
Divider: 'fa-minus',
|
||||
Spacer: 'fa-arrows-v',
|
||||
Icon: 'fa-star',
|
||||
'Star Rating': 'fa-star-half-o',
|
||||
'Social Links': 'fa-share-alt',
|
||||
'Search Bar': 'fa-search',
|
||||
HTML: 'fa-code',
|
||||
// Media
|
||||
Image: 'fa-image',
|
||||
Video: 'fa-play-circle',
|
||||
Map: 'fa-map-marker',
|
||||
// Sections
|
||||
Hero: 'fa-star-o',
|
||||
'Features Grid': 'fa-th-large',
|
||||
'CTA Section': 'fa-bullhorn',
|
||||
'Call to Action': 'fa-bullhorn',
|
||||
Countdown: 'fa-clock-o',
|
||||
Testimonials: 'fa-quote-left',
|
||||
'Content Slider': 'fa-sliders',
|
||||
'Number Counter': 'fa-sort-numeric-asc',
|
||||
Accordion: 'fa-list',
|
||||
Tabs: 'fa-folder-o',
|
||||
'Pricing Table': 'fa-usd',
|
||||
Gallery: 'fa-th',
|
||||
// Forms
|
||||
Form: 'fa-wpforms',
|
||||
Input: 'fa-i-cursor',
|
||||
Textarea: 'fa-align-left',
|
||||
'Submit Button': 'fa-paper-plane',
|
||||
'Contact Form': 'fa-envelope',
|
||||
'Subscribe Form': 'fa-paper-plane',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'fa-cube';
|
||||
const ROOT_ICON = 'fa-desktop';
|
||||
|
||||
interface LayerNodeProps {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const { node, selectedId, actions } = useEditor((state) => {
|
||||
const { node, selectedId, actions, query } = useEditor((state) => {
|
||||
const n = state.nodes[nodeId];
|
||||
const selectedIds = state.events.selected;
|
||||
const selId = selectedIds ? Array.from(selectedIds)[0] : null;
|
||||
@@ -18,28 +78,77 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
};
|
||||
});
|
||||
|
||||
const isSelected = selectedId === nodeId;
|
||||
|
||||
const handleActivate = useCallback(() => {
|
||||
actions.selectNode(nodeId);
|
||||
}, [actions, nodeId]);
|
||||
|
||||
// Layers-panel <-> canvas hover sync (Item 12): tags the target node's own
|
||||
// DOM element (found via `query`) with `data-layer-hovered` directly.
|
||||
// `RenderNode.tsx` mirrors Craft's *own* `hovered` node event onto
|
||||
// `data-craft-hovered` for the (separate, mouse-driven) canvas hover
|
||||
// outline -- editor.css's rule matches both attributes. This panel can't
|
||||
// reuse that path: `setNodeEvent` is a private action that `useEditor()`
|
||||
// strips from its `actions` return at runtime (see
|
||||
// @craftjs/core/dist/esm's `xe()` action stripper, not just its TypeScript
|
||||
// type), so calling it here throws. Writing our own attribute directly is
|
||||
// a supported, version-stable way to get the same highlight.
|
||||
const hoveredDomRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
|
||||
const dom = query.node(nodeId).get()?.dom;
|
||||
if (dom) {
|
||||
dom.setAttribute('data-layer-hovered', '');
|
||||
hoveredDomRef.current = dom;
|
||||
}
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
},
|
||||
[query, nodeId, isSelected],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
|
||||
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
|
||||
hoveredDomRef.current = null;
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
||||
}
|
||||
},
|
||||
[isSelected],
|
||||
);
|
||||
|
||||
// Safety net: if this row unmounts (e.g. the user switches away from the
|
||||
// Layers tab, or the tree re-shuffles) while still hovered, clear the
|
||||
// attribute so it doesn't get stuck highlighted on the canvas forever.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
|
||||
hoveredDomRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const isSelected = selectedId === nodeId;
|
||||
const nodeType = node.data.type;
|
||||
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
||||
? (nodeType as any).resolvedName
|
||||
: typeof nodeType === 'string' ? nodeType : undefined;
|
||||
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
|
||||
const childNodeIds: string[] = node.data.nodes || [];
|
||||
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
||||
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
||||
const isRoot = nodeId === 'ROOT';
|
||||
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
{...clickableProps(handleActivate)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="layer-node-row"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '5px 8px',
|
||||
@@ -56,30 +165,53 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
onFocus={handleMouseEnter}
|
||||
onBlur={handleMouseLeave}
|
||||
>
|
||||
{/* Indentation indicator */}
|
||||
{allChildren.length > 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}>
|
||||
{/* Indent guides: one vertical line per ancestor depth level,
|
||||
aligned under each ancestor's disclosure/icon column so nested
|
||||
rows read as a tree instead of a flat, ever-further-indented list. */}
|
||||
{Array.from({ length: depth }).map((_, level) => (
|
||||
<span
|
||||
key={level}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${8 + level * 16 + 4}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
background: 'var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Indentation/disclosure indicator */}
|
||||
{allChildren.length > 0 ? (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
{allChildren.length === 0 && (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
|
||||
) : (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Component type icon and name */}
|
||||
{/* Component type icon */}
|
||||
<i
|
||||
className={`fa ${icon}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
marginRight: 6,
|
||||
fontSize: 11,
|
||||
width: 12,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
color: isSelected ? 'var(--color-accent)' : 'var(--color-text-dim)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Name */}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{isRoot ? 'Canvas (Root)' : displayName}
|
||||
</span>
|
||||
|
||||
@@ -58,18 +58,23 @@ export const PagesPanel: React.FC = () => {
|
||||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
/* ---------- Zone button style ---------- */
|
||||
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
/* ---------- Zone row style ----------
|
||||
* Same neutral surface language as the page-list rows below (Item 11) --
|
||||
* header/footer are just two more items in the tree, not a louder,
|
||||
* differently-colored category. The active/editing state reuses the same
|
||||
* accent-outline treatment the page list already uses for the active page,
|
||||
* so there's one consistent "this is what's currently open" affordance. */
|
||||
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
padding: '7px 10px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: isActive ? '#f59e0b' : '#fbbf24',
|
||||
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)',
|
||||
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
background: isActive ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
||||
border: `1px solid ${isActive ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition-fast)',
|
||||
@@ -78,57 +83,35 @@ export const PagesPanel: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{/* Header/Footer zone buttons */}
|
||||
{/* Header/Footer zone rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={editHeader}
|
||||
style={zoneButtonStyle(isEditingHeader)}
|
||||
className="zone-row"
|
||||
style={zoneRowStyle(isEditingHeader)}
|
||||
title="Appears on all pages"
|
||||
>
|
||||
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Header</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingHeader && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
<i className="fa fa-window-maximize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1 }}>Header</span>
|
||||
{isEditingHeader ? (
|
||||
<i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
|
||||
) : (
|
||||
<i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={editFooter}
|
||||
style={zoneButtonStyle(isEditingFooter)}
|
||||
className="zone-row"
|
||||
style={zoneRowStyle(isEditingFooter)}
|
||||
title="Appears on all pages"
|
||||
>
|
||||
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>Edit Footer</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}>
|
||||
Appears on all pages
|
||||
</div>
|
||||
</div>
|
||||
{isEditingFooter && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'rgba(245, 158, 11, 0.25)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
Editing
|
||||
</span>
|
||||
<i className="fa fa-window-minimize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1 }}>Footer</span>
|
||||
{isEditingFooter ? (
|
||||
<i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
|
||||
) : (
|
||||
<i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -328,18 +311,21 @@ export const PagesPanel: React.FC = () => {
|
||||
title="This is the landing page — published as the root URL (index.html)"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: '#fbbf24',
|
||||
background: 'rgba(245, 158, 11, 0.15)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.3px',
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-border-light)',
|
||||
padding: '1px 5px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-home" style={{ marginRight: 3 }} />Landing
|
||||
<i className="fa fa-home" aria-hidden="true" style={{ fontSize: 8, color: 'inherit' }} />
|
||||
Landing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -359,7 +345,8 @@ export const PagesPanel: React.FC = () => {
|
||||
>
|
||||
<button
|
||||
onClick={() => startEditing(page)}
|
||||
title="Rename"
|
||||
data-tooltip="Rename"
|
||||
aria-label={`Rename ${page.name}`}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -374,12 +361,13 @@ export const PagesPanel: React.FC = () => {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✎
|
||||
<i className="fa fa-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
{pages.length > 1 && !isLanding && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(page.id)}
|
||||
title="Delete"
|
||||
data-tooltip="Delete"
|
||||
aria-label={`Delete ${page.name}`}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -394,7 +382,7 @@ export const PagesPanel: React.FC = () => {
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
|
||||
|
||||
export interface BottomSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Vertical drag distance (px) past which releasing the handle/header
|
||||
* commits to closing the sheet, rather than snapping back open. */
|
||||
const SWIPE_DISMISS_THRESHOLD_PX = 80;
|
||||
|
||||
/**
|
||||
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
|
||||
* viewport to host one of the existing side-panel components
|
||||
* (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged,
|
||||
* as the mobile replacement for the desktop fixed left/right panel columns.
|
||||
* See `MobilePanelBar`, which owns which sheet (if any) is open.
|
||||
*
|
||||
* Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered
|
||||
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
|
||||
* has its own drag-handle affordance + internally scrollable body, all of
|
||||
* which Modal doesn't need for its centered use cases.
|
||||
*
|
||||
* Phase B adds two more deferred behaviors on top of Phase A's static sheet:
|
||||
* - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle
|
||||
* row + header (not the scrollable body, so it never fights a panel's own
|
||||
* vertical scroll) that follows the finger while dragging down and either
|
||||
* commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back
|
||||
* with a short transition otherwise.
|
||||
* - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how
|
||||
* much the visual viewport has shrunk from the bottom (i.e. the
|
||||
* keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom
|
||||
* property (see editor.css) which both lifts the whole backdrop/sheet
|
||||
* clear of the keyboard AND caps the sheet's own max-height so it never
|
||||
* extends into the space the keyboard occupies. A `focusin` listener also
|
||||
* scrolls the newly-focused input into view once the keyboard has had a
|
||||
* moment to animate in, as a second line of defense for a field that's
|
||||
* still off-screen after the resize alone (e.g. deep in a long panel).
|
||||
*/
|
||||
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const { keyboardInset } = useVisualViewportInsets();
|
||||
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const bodyEl = bodyRef.current;
|
||||
if (!bodyEl) return;
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const tag = target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) {
|
||||
// Give the on-screen keyboard a moment to finish animating in
|
||||
// before scrolling -- doing it immediately measures the pre-keyboard
|
||||
// layout and can undershoot.
|
||||
window.setTimeout(() => {
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
bodyEl.addEventListener('focusin', handleFocusIn);
|
||||
return () => bodyEl.removeEventListener('focusin', handleFocusIn);
|
||||
}, [open]);
|
||||
|
||||
// Swipe-to-dismiss: tracked only on the handle row + header (never the
|
||||
// scrollable `.mobile-sheet-body`, which needs its own vertical touch
|
||||
// scrolling to keep working unimpeded).
|
||||
//
|
||||
// The authoritative drag distance lives in a REF (`dragOffsetRef`),
|
||||
// updated synchronously and imperatively on every touchmove -- NOT solely
|
||||
// in the `dragOffset` state used for rendering the drag transform. Two
|
||||
// touchmoves can fire back-to-back inside the same synchronous event
|
||||
// dispatch (e.g. a fast real swipe, or synthetic touch events fired
|
||||
// without a yield between them), and React batches their `setDragOffset`
|
||||
// calls into a single pending update that hasn't committed yet by the
|
||||
// time `touchend` runs in that same tick -- reading the `dragOffset`
|
||||
// STATE value directly in `handleTouchEnd` would then see a stale
|
||||
// (pre-drag) value and wrongly decide the swipe didn't clear the
|
||||
// threshold. The ref sidesteps that entirely (plain synchronous mutation,
|
||||
// no batching). Relatedly, `onClose()` mutates an ANCESTOR component's
|
||||
// state (MobileChromeContext's `closeSheet`) -- it must be called as a
|
||||
// plain statement in the event handler, never from inside a `setState`
|
||||
// functional updater (that runs during this component's own render phase
|
||||
// and trips React's "Cannot update a component while rendering a
|
||||
// different component" warning).
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
const dragOffsetRef = useRef(0);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
dragStartYRef.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (dragStartYRef.current === null) return;
|
||||
const delta = e.touches[0].clientY - dragStartYRef.current;
|
||||
// Only follow downward drags -- dragging up shouldn't do anything (the
|
||||
// sheet is already fully open; there's no "expand further" state).
|
||||
const next = Math.max(0, delta);
|
||||
dragOffsetRef.current = next;
|
||||
setDragOffset(next);
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
dragStartYRef.current = null;
|
||||
const finalOffset = dragOffsetRef.current;
|
||||
dragOffsetRef.current = 0;
|
||||
setDragOffset(0);
|
||||
if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-sheet-backdrop"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
|
||||
>
|
||||
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
|
||||
while a sheet is open (tapping another tab swaps sheets directly),
|
||||
so the rest of the screen must not be marked inert to assistive
|
||||
tech the way a true modal dialog would be. */}
|
||||
<div
|
||||
className="mobile-sheet"
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
style={{
|
||||
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
|
||||
transition: dragOffset ? 'none' : 'transform 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mobile-sheet-handle-row"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<span className="mobile-sheet-handle" aria-hidden="true" />
|
||||
</div>
|
||||
<div
|
||||
className="mobile-sheet-header"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<span className="mobile-sheet-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sheet-close"
|
||||
onClick={onClose}
|
||||
aria-label={`Close ${title}`}
|
||||
>
|
||||
<i className="fa fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
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';
|
||||
import { MobilePanelBar } from './MobilePanelBar';
|
||||
import { MobileChromeProvider } from '../../state/MobileChromeContext';
|
||||
|
||||
/**
|
||||
* Mobile-A2 (review item 5): the real BlocksPanel/PagesPanel/LayersPanel/
|
||||
* AssetsPanel/GuidedStyles all pull in Craft.js's `useEditor` and other
|
||||
* heavy dependencies not relevant here, so they're stubbed out -- this
|
||||
* suite is only exercising MobilePanelBar's OWN wiring: tapping a tab opens
|
||||
* that tab's sheet with only its panel rendered, tapping the active tab
|
||||
* again closes it, and (mirroring MobileChromeContext.test.tsx) only one
|
||||
* sheet's content is ever mounted at once.
|
||||
*/
|
||||
vi.mock('../left/BlocksPanel', () => ({ BlocksPanel: () => <div data-testid="panel-blocks" /> }));
|
||||
vi.mock('../left/PagesPanel', () => ({ PagesPanel: () => <div data-testid="panel-pages" /> }));
|
||||
vi.mock('../left/LayersPanel', () => ({ LayersPanel: () => <div data-testid="panel-layers" /> }));
|
||||
vi.mock('../left/AssetsPanel', () => ({ AssetsPanel: () => <div data-testid="panel-assets" /> }));
|
||||
vi.mock('../right/GuidedStyles', () => ({ GuidedStyles: () => <div data-testid="panel-styles" /> }));
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function tabButton(label: string): HTMLButtonElement {
|
||||
const buttons = Array.from(container.querySelectorAll('.mobile-tab-btn')) as HTMLButtonElement[];
|
||||
const btn = buttons.find((b) => b.textContent?.includes(label));
|
||||
if (!btn) throw new Error(`No tab button found for "${label}"`);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function openPanelIds(): string[] {
|
||||
return Array.from(container.querySelectorAll('[data-testid^="panel-"]')).map(
|
||||
(el) => (el as HTMLElement).dataset.testid!,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MobilePanelBar', () => {
|
||||
test('no sheet is open initially', () => {
|
||||
render(
|
||||
<MobileChromeProvider>
|
||||
<MobilePanelBar />
|
||||
</MobileChromeProvider>,
|
||||
);
|
||||
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
|
||||
expect(openPanelIds()).toEqual([]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('tapping a tab opens exactly that tab\'s panel', () => {
|
||||
render(
|
||||
<MobileChromeProvider>
|
||||
<MobilePanelBar />
|
||||
</MobileChromeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
tabButton('Blocks').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual(['panel-blocks']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('switching tabs replaces the open sheet -- only one panel mounted at a time', () => {
|
||||
render(
|
||||
<MobileChromeProvider>
|
||||
<MobilePanelBar />
|
||||
</MobileChromeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
tabButton('Blocks').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual(['panel-blocks']);
|
||||
|
||||
act(() => {
|
||||
tabButton('Styles').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual(['panel-styles']);
|
||||
|
||||
act(() => {
|
||||
tabButton('Assets').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual(['panel-assets']);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('tapping the active tab again closes the sheet', () => {
|
||||
render(
|
||||
<MobileChromeProvider>
|
||||
<MobilePanelBar />
|
||||
</MobileChromeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
tabButton('Pages').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual(['panel-pages']);
|
||||
expect(container.querySelector('.mobile-sheet-backdrop')).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
tabButton('Pages').click();
|
||||
});
|
||||
expect(openPanelIds()).toEqual([]);
|
||||
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('active tab button carries aria-pressed=true only for the open sheet', () => {
|
||||
render(
|
||||
<MobileChromeProvider>
|
||||
<MobilePanelBar />
|
||||
</MobileChromeProvider>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
tabButton('Layers').click();
|
||||
});
|
||||
|
||||
expect(tabButton('Layers').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(tabButton('Blocks').getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { BlocksPanel } from '../left/BlocksPanel';
|
||||
import { PagesPanel } from '../left/PagesPanel';
|
||||
import { LayersPanel } from '../left/LayersPanel';
|
||||
import { AssetsPanel } from '../left/AssetsPanel';
|
||||
import { GuidedStyles } from '../right/GuidedStyles';
|
||||
import { useMobileChrome, MobileSheetTab } from '../../state/MobileChromeContext';
|
||||
|
||||
const TABS: { id: MobileSheetTab; label: string; icon: string }[] = [
|
||||
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
|
||||
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' },
|
||||
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
|
||||
{ id: 'assets', label: 'Assets', icon: 'fa-image' },
|
||||
{ id: 'styles', label: 'Styles', icon: 'fa-paint-brush' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mobile replacement for the desktop LeftPanel/RightPanel column layout
|
||||
* (Phase A). A fixed bottom tab bar (Blocks/Pages/Layers/Assets/Styles)
|
||||
* toggles a single `BottomSheet` at a time, hosting the SAME panel
|
||||
* component the desktop side panels use -- unchanged, so behavior/state
|
||||
* stays identical, only the chrome around it differs. Tapping the active
|
||||
* tab again, tapping the backdrop, the sheet's close button, or Escape all
|
||||
* close the open sheet.
|
||||
*
|
||||
* "Styles" hosts `GuidedStyles`, which already falls back to the
|
||||
* site-design panel when nothing is selected and switches to the
|
||||
* per-type style panel once something is -- this is the natural place to
|
||||
* edit a tapped element's styling (Phase B deepens the tap-to-select flow;
|
||||
* this component only wires up the sheet, per the Phase A brief).
|
||||
*
|
||||
* Sheet-open state itself lives in `MobileChromeContext` (Mobile-A2), not
|
||||
* a private `useState`, so Phase B can open/close a sheet from elsewhere
|
||||
* (e.g. a canvas selection toolbar). The toggle-to-close-on-repeat-tap
|
||||
* behavior is local to this click handler -- `openSheet` always opens.
|
||||
*/
|
||||
export const MobilePanelBar: React.FC = () => {
|
||||
const { activeSheet, openSheet, closeSheet } = useMobileChrome();
|
||||
|
||||
const handleTabClick = (tab: MobileSheetTab) => {
|
||||
if (activeSheet === tab) closeSheet();
|
||||
else openSheet(tab);
|
||||
};
|
||||
|
||||
const activeLabel = TABS.find((t) => t.id === activeSheet)?.label ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet open={activeSheet !== null} onClose={closeSheet} title={activeLabel}>
|
||||
{activeSheet === 'blocks' && <BlocksPanel />}
|
||||
{activeSheet === 'pages' && <PagesPanel />}
|
||||
{activeSheet === 'layers' && <LayersPanel />}
|
||||
{activeSheet === 'assets' && <AssetsPanel />}
|
||||
{activeSheet === 'styles' && <GuidedStyles />}
|
||||
</BottomSheet>
|
||||
|
||||
<nav className="mobile-tab-bar" aria-label="Editor panels">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`mobile-tab-btn${activeSheet === tab.id ? ' active' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
aria-pressed={activeSheet === tab.id}
|
||||
>
|
||||
<i className={`fa ${tab.icon}`} aria-hidden="true" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
|
||||
/**
|
||||
* Phase B mobile selection toolbar -- the on-canvas action bar that makes a
|
||||
* tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
|
||||
* select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
|
||||
* Both share the exact same behavior via `useNodeActions` (item 1).
|
||||
*
|
||||
* Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
|
||||
* selection from `useEditor` directly -- NOT per-node -- and positioned
|
||||
* bottom-fixed just above the tab bar (simplest, robust; a node-anchored
|
||||
* floating toolbar would have to constantly reposition as the canvas
|
||||
* scrolls, and risks being clipped at the viewport edge for a node near the
|
||||
* top or bottom of a tall page). This is a deliberate design call: see the
|
||||
* report for the alternative (floating over the node) that was passed over.
|
||||
*
|
||||
* Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
|
||||
* sheet supersedes it, and the two must never visually stack.
|
||||
*
|
||||
* Delete is a two-tap in-app confirm (tap once -> the button becomes
|
||||
* "Confirm?" for a few seconds; tap again within that window commits the
|
||||
* delete; selecting something else cancels it) rather than a native
|
||||
* `confirm()`, matching the project's no-native-dialogs rule.
|
||||
*/
|
||||
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
|
||||
|
||||
export const MobileSelectionToolbar: React.FC = () => {
|
||||
const { activeSheet, openSheet } = 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 };
|
||||
});
|
||||
// Collector-free -- an always-live reference (same pattern as
|
||||
// GuidedStyles.tsx/BlocksPanel.tsx), used only for imperative DOM lookups
|
||||
// below (scroll-into-view), never for anything that needs to react to
|
||||
// store changes on its own.
|
||||
const { query } = useEditor();
|
||||
|
||||
const nodeActions = useNodeActions(selectedId);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
|
||||
// from a live (never-cached) Craft.js query -- see that hook's doc comment
|
||||
// for why it deliberately does NOT hold its own store subscription to force
|
||||
// a refresh. Moving/duplicating/deleting via THIS toolbar changes the
|
||||
// selected node's position/siblings WITHOUT changing `selectedId` itself
|
||||
// (the node stays selected), so nothing else would otherwise trigger a
|
||||
// re-render of this component to pick up the new boundary flags after,
|
||||
// say, tapping "Move Up" until the node reaches the top. Bumping a plain
|
||||
// local tick from these buttons' own click handlers -- an ordinary,
|
||||
// locally-owned re-render -- covers exactly that, without the broader
|
||||
// store-wide subscription that was found to trip React's "setState while
|
||||
// rendering a different component" warning whenever some unrelated
|
||||
// container mounts (see useNodeActions.ts).
|
||||
const [, forceRefresh] = useState(0);
|
||||
const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
|
||||
|
||||
const clearConfirmTimeout = useCallback(() => {
|
||||
if (confirmTimeoutRef.current) {
|
||||
clearTimeout(confirmTimeoutRef.current);
|
||||
confirmTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Reset the two-tap delete confirmation whenever the selection changes
|
||||
// (including being cleared) so a stale "Confirm?" state never lingers onto
|
||||
// a different node.
|
||||
const prevSelectedRef = useRef(selectedId);
|
||||
useEffect(() => {
|
||||
if (prevSelectedRef.current !== selectedId) {
|
||||
prevSelectedRef.current = selectedId;
|
||||
clearConfirmTimeout();
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
}, [selectedId, clearConfirmTimeout]);
|
||||
|
||||
useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
|
||||
|
||||
// Fast-follow item 3: the Styles bottom sheet covers the bottom ~65dvh of
|
||||
// the canvas, so a selected node sitting anywhere in that band goes
|
||||
// invisible the instant the sheet opens -- live style edits then have no
|
||||
// visible effect until the user closes the sheet to check. Whenever the
|
||||
// sheet becomes 'styles' (however it got opened -- currently only this
|
||||
// toolbar's own "Style" button, but this is keyed on the sheet/selection
|
||||
// state rather than the button tap so it stays correct if another entry
|
||||
// point is added later) scroll the selected node to the top of the
|
||||
// canvas's scroll area, into the sliver of screen the sheet leaves clear.
|
||||
useEffect(() => {
|
||||
if (activeSheet !== 'styles' || !selectedId) return;
|
||||
try {
|
||||
query.node(selectedId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} catch {
|
||||
// Best-effort -- not fatal if the node/DOM isn't found.
|
||||
}
|
||||
}, [activeSheet, selectedId, query]);
|
||||
|
||||
const handleDeleteTap = useCallback(() => {
|
||||
if (confirmingDelete) {
|
||||
clearConfirmTimeout();
|
||||
setConfirmingDelete(false);
|
||||
nodeActions.deleteNode();
|
||||
bumpTick();
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(true);
|
||||
clearConfirmTimeout();
|
||||
confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
|
||||
}, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
|
||||
|
||||
const handleEditStyles = useCallback(() => {
|
||||
openSheet('styles');
|
||||
}, [openSheet]);
|
||||
|
||||
const handleMoveUp = useCallback(() => {
|
||||
nodeActions.moveUp();
|
||||
bumpTick();
|
||||
}, [nodeActions, bumpTick]);
|
||||
|
||||
const handleMoveDown = useCallback(() => {
|
||||
nodeActions.moveDown();
|
||||
bumpTick();
|
||||
}, [nodeActions, bumpTick]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
const newId = nodeActions.duplicate();
|
||||
bumpTick();
|
||||
if (!newId) return;
|
||||
// Mirror BlocksPanel's tap-to-add pattern: the new node's real DOM
|
||||
// element isn't attached yet the instant `duplicate()` returns (Craft.js
|
||||
// mounts it asynchronously after this handler's synchronous state
|
||||
// update), so defer two animation frames before reading `.dom` off the
|
||||
// query, same as `handleTapToAdd` does.
|
||||
requestAnimationFrame(() => {
|
||||
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.
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [nodeActions, bumpTick, query]);
|
||||
|
||||
if (!selectedId || activeSheet !== null) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-selection-toolbar-btn"
|
||||
onClick={handleMoveUp}
|
||||
disabled={!nodeActions.canMoveUp}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<i className="fa fa-arrow-up" aria-hidden="true" />
|
||||
<span>Up</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-selection-toolbar-btn"
|
||||
onClick={handleMoveDown}
|
||||
disabled={!nodeActions.canMoveDown}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||
<span>Down</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-selection-toolbar-btn"
|
||||
onClick={handleDuplicate}
|
||||
aria-label="Duplicate"
|
||||
>
|
||||
<i className="fa fa-clone" aria-hidden="true" />
|
||||
<span>Copy</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-selection-toolbar-btn"
|
||||
onClick={nodeActions.selectParent}
|
||||
disabled={!nodeActions.canSelectParent}
|
||||
aria-label="Select parent"
|
||||
>
|
||||
<i className="fa fa-level-up" aria-hidden="true" />
|
||||
<span>Parent</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-selection-toolbar-btn"
|
||||
onClick={handleEditStyles}
|
||||
aria-label="Edit styles"
|
||||
>
|
||||
<i className="fa fa-paint-brush" aria-hidden="true" />
|
||||
<span>Style</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
|
||||
onClick={handleDeleteTap}
|
||||
disabled={!nodeActions.canDelete}
|
||||
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
|
||||
>
|
||||
<i className="fa fa-trash" aria-hidden="true" />
|
||||
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BG_COLORS,
|
||||
RADIUS_PRESETS,
|
||||
SPACING_PRESETS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -11,16 +12,49 @@ import {
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
ColorPickerField,
|
||||
CollapsibleSection,
|
||||
SpacingControl,
|
||||
SpacingSide,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
sectionGap,
|
||||
labelStyle,
|
||||
autoTextColor,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
||||
* the {width,style,color} shape BorderControl edits. Only needs to
|
||||
* round-trip values this same panel produced via buildBorderShorthand. */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
|
||||
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
|
||||
function opacityPercent(v: unknown): number {
|
||||
if (v === undefined || v === null || v === '') return 100;
|
||||
const n = parseFloat(String(v));
|
||||
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
||||
}
|
||||
|
||||
/* ---------- BUTTON ---------- */
|
||||
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const { setPropStyle } = useNodeProp(selectedId);
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
const setButtonColor = useCallback(
|
||||
(bgColor: string) => {
|
||||
@@ -61,6 +95,16 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
||||
}}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nodeProps.target === '_blank'}
|
||||
onChange={(e) => setProp('target', e.target.checked ? '_blank' : '_self')}
|
||||
/>
|
||||
Open in new tab
|
||||
</label>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
@@ -77,6 +121,70 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hover state -- rendered into a scoped <style>...:hover{} block by
|
||||
ButtonLink.toHtml (published export only; not shown live in the
|
||||
editor canvas beyond the hover preview ButtonLink itself does). */}
|
||||
<CollapsibleSection title="Hover State" defaultOpen={false}>
|
||||
<ColorPickerField
|
||||
label="Hover Background"
|
||||
value={nodeProps.hoverBg || ''}
|
||||
onChange={(v) => setProp('hoverBg', v)}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="Hover Text Color"
|
||||
value={nodeProps.hoverColor || ''}
|
||||
onChange={(v) => setProp('hoverColor', v)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
|
||||
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border as string)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacityPercent(style.opacity)}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: nodeProps.hideOnDesktop,
|
||||
hideOnTablet: nodeProps.hideOnTablet,
|
||||
hideOnMobile: nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -10,21 +12,152 @@ import {
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
ArrayPropEditor,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
buildBorderShorthand,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
|
||||
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
|
||||
fake "types" (textarea/select) that take their own ContactForm render
|
||||
branch instead of an <input type>. Kept as a local list (rather than
|
||||
importing the runtime array from utils/escape.ts) since this is
|
||||
presentation-only -- the actual security boundary is enforced in
|
||||
ContactForm.toHtml via sanitizeInputType, not here. */
|
||||
const CONTACT_FIELD_TYPES = [
|
||||
'text', 'email', 'tel', 'number', 'password', 'url', 'search', 'date',
|
||||
'checkbox', 'radio', 'textarea', 'select',
|
||||
];
|
||||
|
||||
const moveBtnStyle: React.CSSProperties = {
|
||||
flex: 1, padding: '3px 6px', fontSize: 10, background: '#27272a', color: '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
};
|
||||
|
||||
function parseBorderValue(v: unknown): BorderValue {
|
||||
const s = typeof v === 'string' ? v.trim() : '';
|
||||
if (!s || s === 'none') return { width: '', style: 'none', color: '' };
|
||||
const m = s.match(/^(\S+)\s+(\S+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'Top' | 'Right' | 'Bottom' | 'Left' }[] = [
|
||||
{ side: 'top', suffix: 'Top' },
|
||||
{ side: 'right', suffix: 'Right' },
|
||||
{ side: 'bottom', suffix: 'Bottom' },
|
||||
{ side: 'left', suffix: 'Left' },
|
||||
];
|
||||
|
||||
/* ---------- FORM ---------- */
|
||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
const updateField = (index: number, patch: Record<string, any>) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.fields || [])];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
props.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const moveField = (index: number, direction: -1 | 1) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.fields || [])];
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= updated.length) return;
|
||||
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
|
||||
props.fields = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ContactForm field editor: add/remove/reorder fields, set each
|
||||
field's label, name, type (full allowlist + textarea/select),
|
||||
options (select only), and required flag. */}
|
||||
{nodeProps.fields !== undefined && Array.isArray(nodeProps.fields) && (
|
||||
<CollapsibleSection title="Fields">
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
propKey="fields"
|
||||
items={nodeProps.fields}
|
||||
renderItem={(item: any, index: number) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={item.label || ''}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
placeholder="Label"
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.name || ''}
|
||||
onChange={(e) => updateField(index, { name: e.target.value })}
|
||||
placeholder="Field name (e.g. email)"
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.placeholder || ''}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
placeholder="Placeholder"
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={item.type || 'text'}
|
||||
onChange={(e) => updateField(index, { type: e.target.value })}
|
||||
style={{ ...smallInputStyle, cursor: 'pointer' }}
|
||||
>
|
||||
{CONTACT_FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{item.type === 'select' && (
|
||||
<input
|
||||
type="text"
|
||||
value={(item.options || []).join(', ')}
|
||||
onChange={(e) => updateField(index, {
|
||||
options: e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean),
|
||||
})}
|
||||
placeholder="Options (comma-separated)"
|
||||
style={smallInputStyle}
|
||||
/>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!item.required}
|
||||
onChange={(e) => updateField(index, { required: e.target.checked })}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button disabled={index === 0} onClick={() => moveField(index, -1)} style={{ ...moveBtnStyle, opacity: index === 0 ? 0.4 : 1 }} title="Move up">
|
||||
<i className="fa fa-arrow-up" />
|
||||
</button>
|
||||
<button disabled={index === nodeProps.fields.length - 1} onClick={() => moveField(index, 1)} style={{ ...moveBtnStyle, opacity: index === nodeProps.fields.length - 1 ? 0.4 : 1 }} title="Move down">
|
||||
<i className="fa fa-arrow-down" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
emptyItem={{ type: 'text', label: 'New Field', name: 'field', placeholder: '', required: false }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
|
||||
and FormContainer (both have recipientEmail/thankYouUrl props). */}
|
||||
{nodeProps.recipientEmail !== undefined && (
|
||||
@@ -43,13 +176,35 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form action/method */}
|
||||
{nodeProps.action !== undefined && (
|
||||
{/* Form action/method (FormContainer). SearchBar also has an `action`
|
||||
prop but is distinguished via its unique `showButton` prop -- see
|
||||
the dedicated Search block below -- so it doesn't get this label. */}
|
||||
{nodeProps.action !== undefined && nodeProps.showButton === undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Form Action URL</label>
|
||||
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SearchBar: where the GET search request is submitted. */}
|
||||
{nodeProps.showButton !== undefined && nodeProps.action !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Search Results Page</label>
|
||||
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
|
||||
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||
Submits a GET request with the query as ?q=... to this URL.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.showButton !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={nodeProps.showButton !== false} onChange={(e) => setProp('showButton', e.target.checked)} />
|
||||
Show Search Button
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeProps.method !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Method</label>
|
||||
@@ -142,6 +297,64 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin/padding (per-side), border, shadow, opacity --
|
||||
common enh-batch rollout, applies to the whole form family since
|
||||
they all spread `style` onto their root element. */}
|
||||
<CollapsibleSection title="Spacing, Border & Effects" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={(side, v) => setPropStyle(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding (per side)"
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={(side, v) => setPropStyle(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
|
||||
/>
|
||||
<BorderControl
|
||||
value={parseBorderValue(style.border)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Box Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Opacity</SectionLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={style.opacity !== undefined && style.opacity !== '' ? Math.round(Number(style.opacity) * 100) : 100}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Animation & Visibility -- gated on the blank/false defaults added to
|
||||
each owned component's craft.props (ContactForm, FormContainer,
|
||||
InputField, TextareaField, FormButton, SubscribeForm, SearchBar).
|
||||
No toHtml change needed: the export's buildDataAttrs() already
|
||||
emits data-animation/data-hide-* from these exact prop names for
|
||||
every node. */}
|
||||
{nodeProps.animation !== undefined && (
|
||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
<VisibilityControl
|
||||
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', v.hideOnDesktop);
|
||||
setProp('hideOnTablet', v.hideOnTablet);
|
||||
setProp('hideOnMobile', v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
BG_COLORS,
|
||||
@@ -17,12 +17,85 @@ import {
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||
import { Modal } from '../../../ui/Modal';
|
||||
import { CodeEditor } from '../../../ui/CodeEditor';
|
||||
|
||||
/* ---------- "Edit HTML" modal for the HtmlBlock `code` prop ----------
|
||||
`code` is raw HTML (potentially many lines, embedded <style>/<script>),
|
||||
so it gets a dedicated syntax-highlighted CodeEditor in a modal instead
|
||||
of falling into the generic single-line/textarea string-prop rendering
|
||||
below (see GenericPropsEditor's SKIP of the `code` key). */
|
||||
const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<CollapsibleSection title="HTML Code">
|
||||
<div style={sectionGap}>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
||||
borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-code" /> Edit HTML
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: 16 }}>
|
||||
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||
selectedId, nodeProps, typeName,
|
||||
}) => {
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass', 'code']);
|
||||
|
||||
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
|
||||
|
||||
@@ -35,9 +108,15 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
|
||||
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
const hasCodeProp = typeof nodeProps.code === 'string';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Raw HTML (HtmlBlock's `code` prop) -- dedicated CodeEditor modal */}
|
||||
{hasCodeProp && (
|
||||
<HtmlCodeField value={nodeProps.code} onChange={(v) => setPropValue('code', v)} />
|
||||
)}
|
||||
|
||||
{/* String props */}
|
||||
{stringProps.length > 0 && (
|
||||
<CollapsibleSection title="Properties">
|
||||
|
||||
@@ -2,29 +2,28 @@ import React, { CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
IMAGE_RADIUS_PRESETS,
|
||||
OBJECT_FIT,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
TextInputField,
|
||||
SizeControl,
|
||||
AspectRatioControl,
|
||||
FocalPointGrid,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
|
||||
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
|
||||
|
||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const { setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
const maxWidthPresets = [
|
||||
{ label: '25%', value: '25%' },
|
||||
{ label: '50%', value: '50%' },
|
||||
{ label: '75%', value: '75%' },
|
||||
{ label: '100%', value: '100%' },
|
||||
];
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,7 +32,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
<SectionLabel>Image Source</SectionLabel>
|
||||
<AssetPicker
|
||||
value={nodeProps.src || ''}
|
||||
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url; })}
|
||||
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url || PLACEHOLDER_SRC; })}
|
||||
variant="full"
|
||||
/>
|
||||
</div>
|
||||
@@ -48,6 +47,39 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Size -- Width absorbs the historical maxWidth presets (25/50/75/100%
|
||||
are a subset of SizeControl's default preset row), Height is new. */}
|
||||
<SizeControl
|
||||
label="Width"
|
||||
value={(style.maxWidth as string) || ''}
|
||||
onChange={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
<SizeControl
|
||||
label="Height"
|
||||
value={(style.height as string) || ''}
|
||||
onChange={(v) => setPropStyle('height', v)}
|
||||
/>
|
||||
|
||||
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
||||
<img> itself is a CSS framing crop (no server-side image processing
|
||||
needed). */}
|
||||
<AspectRatioControl
|
||||
value={(style.aspectRatio as string) || ''}
|
||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Object Fit</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={OBJECT_FIT}
|
||||
activeValue={(style.objectFit as string) || ''}
|
||||
onSelect={(v) => setPropStyle('objectFit', v)}
|
||||
/>
|
||||
</div>
|
||||
<FocalPointGrid
|
||||
value={(style.objectPosition as string) || ''}
|
||||
onChange={(v) => setPropStyle('objectPosition', v)}
|
||||
/>
|
||||
|
||||
{/* Border Radius */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
@@ -58,15 +90,10 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Width */}
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Max Width</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={maxWidthPresets}
|
||||
activeValue={style.maxWidth as string}
|
||||
onSelect={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
</div>
|
||||
{/* Box model + animation/visibility rollout */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyle} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
ArrayPropEditor,
|
||||
SizeControl,
|
||||
AspectRatioControl,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
@@ -20,6 +21,14 @@ import {
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
|
||||
|
||||
const GALLERY_COLUMN_PRESETS = [
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '5', value: '5' },
|
||||
];
|
||||
|
||||
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
|
||||
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
@@ -30,11 +39,48 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Video URL */}
|
||||
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
||||
YouTube/Vimeo; upload/browse handle files via detectVideoType's
|
||||
serve_asset resolution). */}
|
||||
{nodeProps.videoUrl !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Video URL</label>
|
||||
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} />
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Video Source</SectionLabel>
|
||||
<AssetPicker
|
||||
mediaType="video"
|
||||
value={nodeProps.videoUrl || ''}
|
||||
onChange={(url) => setProp('videoUrl', url)}
|
||||
variant="full"
|
||||
placeholder="Or paste a YouTube/Vimeo/.mp4 URL..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video size -- width + aspect ratio for the video frame. */}
|
||||
{nodeProps.videoUrl !== undefined && (
|
||||
<>
|
||||
<SizeControl
|
||||
label="Width"
|
||||
value={(style.width as string) || ''}
|
||||
onChange={(v) => setPropStyle('width', v)}
|
||||
/>
|
||||
<AspectRatioControl
|
||||
value={(style.aspectRatio as string) || ''}
|
||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Poster image (optional, file-type videos only in practice but
|
||||
harmless to offer whenever the component has a poster prop). */}
|
||||
{nodeProps.poster !== undefined && (
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Poster Image (optional)</SectionLabel>
|
||||
<AssetPicker
|
||||
mediaType="image"
|
||||
value={nodeProps.poster || ''}
|
||||
onChange={(url) => setProp('poster', url)}
|
||||
variant="full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -80,6 +126,26 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery layout -- columns + lightbox (existing-but-previously-unexposed props). */}
|
||||
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
||||
<>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Columns</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={GALLERY_COLUMN_PRESETS}
|
||||
activeValue={String(nodeProps.columns ?? 3)}
|
||||
onSelect={(v) => setProp('columns', Number(v))}
|
||||
/>
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!nodeProps.lightbox} onChange={(e) => setProp('lightbox', e.target.checked)} />
|
||||
Enable Lightbox
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Gallery items */}
|
||||
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
|
||||
<CollapsibleSection title="Images">
|
||||
@@ -178,21 +244,23 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
{/* Background & radius */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
|
||||
all carry these props now, so it's safe to render unconditionally). */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyle} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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';
|
||||
|
||||
/* Same DOM-harness pattern as MediaStylePanel.slides.test.tsx -- mock
|
||||
@craftjs/core's useEditor so setProp calls can be observed without
|
||||
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
|
||||
doesn't hit the network. */
|
||||
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||
updater(lastProps);
|
||||
});
|
||||
let lastProps: any;
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/assets', () => ({
|
||||
uploadAsset: vi.fn(),
|
||||
listAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
import { MediaStylePanel } from './MediaStylePanel';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function q<T extends Element = Element>(testId: string): T | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
function qAll<T extends Element = Element>(testId: string): T[] {
|
||||
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
|
||||
}
|
||||
|
||||
function setValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPropSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container) unmount();
|
||||
});
|
||||
|
||||
describe('MediaStylePanel Video source uses AssetPicker (upload/browse/paste-URL), not a plain text input', () => {
|
||||
test('renders a full-variant AssetPicker (not the old plain input) for videoUrl', () => {
|
||||
lastProps = { videoUrl: '', poster: '', style: {} };
|
||||
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||
expect(q('asset-picker-full')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('applying a pasted URL via the AssetPicker writes videoUrl (not some other key)', () => {
|
||||
lastProps = { videoUrl: '', poster: '', style: {} };
|
||||
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||
|
||||
// The Video Source AssetPicker is the first url-input on the page (the
|
||||
// Poster picker is a second, separate AssetPicker instance below it).
|
||||
const urlInput = qAll<HTMLInputElement>('asset-picker-url-input')[0];
|
||||
expect(urlInput).toBeTruthy();
|
||||
setValue(urlInput, 'https://example.com/clip.mp4');
|
||||
|
||||
const applyBtn = qAll<HTMLButtonElement>('asset-picker-apply')[0];
|
||||
act(() => {
|
||||
applyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setPropSpy).toHaveBeenCalled();
|
||||
expect(lastProps.videoUrl).toBe('https://example.com/clip.mp4');
|
||||
});
|
||||
|
||||
test('a video-shaped selection also renders a Poster AssetPicker (image) that writes `poster`', () => {
|
||||
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
|
||||
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||
|
||||
const pickers = qAll('asset-picker-full');
|
||||
expect(pickers.length).toBe(2); // Video Source + Poster Image
|
||||
|
||||
const urlInputs = qAll<HTMLInputElement>('asset-picker-url-input');
|
||||
const posterInput = urlInputs[1];
|
||||
setValue(posterInput, 'https://example.com/poster.jpg');
|
||||
const applyBtns = qAll<HTMLButtonElement>('asset-picker-apply');
|
||||
act(() => {
|
||||
applyBtns[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(lastProps.poster).toBe('https://example.com/poster.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated on videoUrl', () => {
|
||||
test('a video-shaped selection renders SizeControl + AspectRatioControl', () => {
|
||||
lastProps = { videoUrl: '', poster: '', style: {} };
|
||||
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||
expect(q('size-control')).not.toBeNull();
|
||||
expect(q('aspect-ratio-control')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a Map-shaped selection (no videoUrl) does NOT render the video size controls', () => {
|
||||
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {} };
|
||||
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
|
||||
expect(q('size-control')).toBeNull();
|
||||
expect(q('aspect-ratio-control')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function openCollapsibleByTitle(title: string) {
|
||||
const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes(title));
|
||||
expect(btn).toBeTruthy();
|
||||
act(() => {
|
||||
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('MediaStylePanel box-model + animation/visibility rollout renders for every media type', () => {
|
||||
test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (collapsed by default, open on click)', () => {
|
||||
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {}, animation: '', hideOnDesktop: false };
|
||||
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
|
||||
|
||||
openCollapsibleByTitle('Spacing');
|
||||
expect(q('spacing-control')).not.toBeNull();
|
||||
|
||||
openCollapsibleByTitle('Border & Effects');
|
||||
expect(q('border-control')).not.toBeNull();
|
||||
|
||||
openCollapsibleByTitle('Animation & Visibility');
|
||||
expect(q('animation-control')).not.toBeNull();
|
||||
expect(q('visibility-control')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defaultPresetGridColumns } from './shared';
|
||||
import { RADIUS_PRESETS, SPACING_PRESETS, IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, TEXT_SIZES, FONT_FAMILIES } from '../../../constants/presets';
|
||||
|
||||
/*
|
||||
* Regression: 5-item preset sets (RADIUS_PRESETS, SPACING_PRESETS,
|
||||
* IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, and NavStylePanel's ad-hoc
|
||||
* GAP_PRESETS) left a lone orphan button on its own row under the old
|
||||
* fixed 4-column .preset-grid. PresetButtonGrid now derives a column
|
||||
* count from the preset length instead.
|
||||
*/
|
||||
describe('defaultPresetGridColumns', () => {
|
||||
it('gives 5-item sets their own single row (no orphan)', () => {
|
||||
expect(defaultPresetGridColumns(RADIUS_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(SPACING_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(IMAGE_RADIUS_PRESETS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(FONT_WEIGHTS.length)).toBe(5);
|
||||
expect(defaultPresetGridColumns(5)).toBe(5); // NavStylePanel's GAP_PRESETS
|
||||
});
|
||||
|
||||
it('splits 6-item sets into two even rows of 3 (was 4+2 uneven)', () => {
|
||||
expect(defaultPresetGridColumns(TEXT_SIZES.length)).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps the classic 4-column grid for sets that already divide evenly', () => {
|
||||
expect(defaultPresetGridColumns(FONT_FAMILIES.length)).toBe(4); // 8 items
|
||||
expect(defaultPresetGridColumns(4)).toBe(4);
|
||||
expect(defaultPresetGridColumns(3)).toBe(4);
|
||||
});
|
||||
|
||||
it('never leaves a single orphan on the final row for any count 1-12', () => {
|
||||
for (let n = 1; n <= 12; n++) {
|
||||
const cols = defaultPresetGridColumns(n);
|
||||
const isLoneOrphan = n > cols && n % cols === 1;
|
||||
expect(isLoneOrphan).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,38 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { SHADOW_PRESETS } from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
CollapsibleSection,
|
||||
ColorPickerField,
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
labelStyle,
|
||||
inputStyle,
|
||||
smallInputStyle,
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
|
||||
/** Parses a border shorthand string ("2px solid #hex") produced by
|
||||
* buildBorderShorthand() back into its parts for round-tripping through
|
||||
* BorderControl. See SectionTypePanel.tsx for the identical helper. */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const bulletOptions = [
|
||||
{ label: '✓', value: 'check' },
|
||||
{ label: '●', value: 'dot' },
|
||||
@@ -26,10 +48,12 @@ const bulletChar: Record<string, string> = {
|
||||
|
||||
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
const [expandedPlan, setExpandedPlan] = useState<number>(0);
|
||||
|
||||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||||
const currentBullet = nodeProps.bulletType || 'check';
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
@@ -204,6 +228,79 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
{/* Colors */}
|
||||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
|
||||
{/* Regular (non-featured) card colors -- built into PricingTable's
|
||||
render/toHtml but previously hard-coded literals with no control
|
||||
surfacing them. Each falls back to the prior literal when blank. */}
|
||||
<ColorPickerField label="Card Background" value={nodeProps.cardBg || '#ffffff'} onChange={(v) => setProp('cardBg', v)} />
|
||||
<ColorPickerField label="Heading / Price Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
||||
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setProp('subColor', v)} />
|
||||
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setProp('featColor', v)} />
|
||||
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setProp('checkColor', v)} />
|
||||
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setProp('btnBg', v)} />
|
||||
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setProp('btnColor', v)} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
|
||||
/>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border as string)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Opacity</SectionLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Entrance animation */}
|
||||
<CollapsibleSection title="Animation" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Responsive visibility */}
|
||||
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: !!nodeProps.hideOnDesktop,
|
||||
hideOnTablet: !!nodeProps.hideOnTablet,
|
||||
hideOnMobile: !!nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -15,10 +16,31 @@ import {
|
||||
inputStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
import { FeaturesEditor } from './FeaturesEditor';
|
||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||
|
||||
/** Parses a border shorthand string ("2px solid #hex") produced by
|
||||
* buildBorderShorthand() back into its parts for round-tripping through
|
||||
* BorderControl. Anything that doesn't match (undefined/'none'/legacy
|
||||
* hand-authored values) falls back to an empty/none border rather than
|
||||
* throwing, since this is display-only -- the next edit always re-emits a
|
||||
* clean shorthand via buildBorderShorthand(). */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
@@ -26,7 +48,12 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
const style = nodeProps.style || {};
|
||||
|
||||
// Find all string/number/boolean props
|
||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||
const SKIP_PROPS = new Set([
|
||||
'style', 'children', 'cssId', 'cssClass',
|
||||
// Rendered via dedicated controls below (Animation/Visibility), not the
|
||||
// generic Content field dump -- otherwise they'd show up twice.
|
||||
'animation', 'animationDelay', 'hideOnDesktop', 'hideOnTablet', 'hideOnMobile',
|
||||
]);
|
||||
const scalarProps = Object.entries(nodeProps).filter(
|
||||
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
|
||||
);
|
||||
@@ -126,6 +153,69 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
|
||||
/>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border as string)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Opacity</SectionLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Entrance animation */}
|
||||
<CollapsibleSection title="Animation" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Responsive visibility */}
|
||||
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: !!nodeProps.hideOnDesktop,
|
||||
hideOnTablet: !!nodeProps.hideOnTablet,
|
||||
hideOnMobile: !!nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
@@ -17,8 +18,33 @@ import {
|
||||
btnActiveStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
|
||||
/** Parses a border shorthand string ("2px solid #hex") produced by
|
||||
* buildBorderShorthand() back into its parts for round-tripping through
|
||||
* BorderControl. See SectionTypePanel.tsx for the identical helper. */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const ICON_SHAPES = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Circle', value: 'circle' },
|
||||
{ label: 'Square', value: 'square' },
|
||||
{ label: 'Rounded', value: 'rounded' },
|
||||
];
|
||||
|
||||
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
|
||||
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
@@ -111,6 +137,72 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon shape (SocialLinks) -- exists on the component (iconShape prop
|
||||
drives the circle/square/rounded background box) but previously had
|
||||
no control. */}
|
||||
{nodeProps.iconShape !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Icon Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{ICON_SHAPES.map((s) => (
|
||||
<button key={s.value} onClick={() => setProp('iconShape', s.value)} style={{ ...btnActiveStyle(nodeProps.iconShape === s.value), flex: 1 }}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Gap between links (SocialLinks) -- exists on the component but
|
||||
previously had no control. */}
|
||||
{nodeProps.gap !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<input type="text" value={nodeProps.gap || '10px'} onChange={(e) => setProp('gap', e.target.value)} placeholder="10px" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background shape / size / link (Icon component) -- bgColor, bgShape,
|
||||
bgSize, link all already exist on Icon.craft.props but had no
|
||||
control; the generic `color`/`iconColor` checks above never matched
|
||||
Icon's actual prop names. */}
|
||||
{nodeProps.bgShape !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Shape</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{ICON_SHAPES.map((s) => (
|
||||
<button key={s.value} onClick={() => setProp('bgShape', s.value)} style={{ ...btnActiveStyle(nodeProps.bgShape === s.value), flex: 1 }}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.bgColor !== undefined && (
|
||||
<ColorPickerField label="Background Color" value={nodeProps.bgColor === 'transparent' ? '' : nodeProps.bgColor} onChange={(v) => setProp('bgColor', v)} />
|
||||
)}
|
||||
{nodeProps.bgSize !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Background Size</label>
|
||||
<input type="text" value={nodeProps.bgSize || '56px'} onChange={(e) => setProp('bgSize', e.target.value)} placeholder="56px" style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
{nodeProps.link !== undefined && (
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Link URL</label>
|
||||
<input type="text" value={nodeProps.link || ''} onChange={(e) => setProp('link', e.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star colors (StarRating) -- exist on the component as filledColor /
|
||||
emptyColor but had no control (the generic `starColor` check above
|
||||
never matched StarRating's actual prop names). */}
|
||||
{nodeProps.filledColor !== undefined && (
|
||||
<ColorPickerField label="Filled Star Color" value={nodeProps.filledColor || '#f59e0b'} onChange={(v) => setProp('filledColor', v)} />
|
||||
)}
|
||||
{nodeProps.emptyColor !== undefined && (
|
||||
<ColorPickerField label="Empty Star Color" value={nodeProps.emptyColor || '#d1d5db'} onChange={(v) => setProp('emptyColor', v)} />
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{nodeProps.iconColor !== undefined && (
|
||||
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
|
||||
@@ -164,6 +256,69 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
}}
|
||||
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
|
||||
/>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border as string)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Opacity</SectionLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Entrance animation */}
|
||||
<CollapsibleSection title="Animation" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Responsive visibility */}
|
||||
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: !!nodeProps.hideOnDesktop,
|
||||
hideOnTablet: !!nodeProps.hideOnTablet,
|
||||
hideOnMobile: !!nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,20 +4,69 @@ import {
|
||||
FONT_FAMILIES,
|
||||
TEXT_SIZES,
|
||||
FONT_WEIGHTS,
|
||||
LINE_HEIGHTS,
|
||||
LETTER_SPACINGS,
|
||||
SHADOW_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
StylePanelProps,
|
||||
SectionLabel,
|
||||
ColorSwatchGrid,
|
||||
PresetButtonGrid,
|
||||
NumericUnitInput,
|
||||
CollapsibleSection,
|
||||
SpacingControl,
|
||||
SpacingSide,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
sectionGap,
|
||||
labelStyle,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
|
||||
/* Text-transform is a small fixed enum with no natural home in the shared
|
||||
foundation presets (constants/presets.ts is import-only for this
|
||||
package), so it lives here as a package-local preset list. */
|
||||
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'UPPER', value: 'uppercase' },
|
||||
{ label: 'lower', value: 'lowercase' },
|
||||
{ label: 'Capitalize', value: 'capitalize' },
|
||||
];
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
||||
* the {width,style,color} shape BorderControl edits. Only needs to
|
||||
* round-trip values this same panel produced via buildBorderShorthand. */
|
||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
|
||||
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
|
||||
function opacityPercent(v: unknown): number {
|
||||
if (v === undefined || v === null || v === '') return 100;
|
||||
const n = parseFloat(String(v));
|
||||
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
||||
}
|
||||
|
||||
/* ---------- TEXT ---------- */
|
||||
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const { setPropStyle } = useNodeProp(selectedId);
|
||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||
|
||||
const isItalic = style.fontStyle === 'italic';
|
||||
const isUnderline = style.textDecoration === 'underline';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,6 +93,15 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
activeValue={style.fontSize as string}
|
||||
onSelect={(v) => setPropStyle('fontSize', v)}
|
||||
/>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput
|
||||
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
|
||||
onChange={(v) => setPropStyle('fontSize', v)}
|
||||
units={['px', 'em', 'rem', '%']}
|
||||
placeholder="custom"
|
||||
testId="text-fontsize-custom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Font Weight</SectionLabel>
|
||||
@@ -53,6 +111,53 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
onSelect={(v) => setPropStyle('fontWeight', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Style</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`preset-btn ${isItalic ? 'active' : ''}`}
|
||||
style={{ flex: 1, fontStyle: 'italic' }}
|
||||
onClick={() => setPropStyle('fontStyle', isItalic ? 'normal' : 'italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<i className="fa fa-italic" /> Italic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`preset-btn ${isUnderline ? 'active' : ''}`}
|
||||
style={{ flex: 1, textDecoration: 'underline' }}
|
||||
onClick={() => setPropStyle('textDecoration', isUnderline ? 'none' : 'underline')}
|
||||
title="Underline"
|
||||
>
|
||||
<i className="fa fa-underline" /> Underline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Transform</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={TEXT_TRANSFORMS}
|
||||
activeValue={(style.textTransform as string) || 'none'}
|
||||
onSelect={(v) => setPropStyle('textTransform', v === 'none' ? '' : v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Line Height</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={LINE_HEIGHTS}
|
||||
activeValue={String(style.lineHeight || '')}
|
||||
onSelect={(v) => setPropStyle('lineHeight', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Letter Spacing</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={LETTER_SPACINGS}
|
||||
activeValue={String(style.letterSpacing || '')}
|
||||
onSelect={(v) => setPropStyle('letterSpacing', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Alignment</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
@@ -68,6 +173,59 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
|
||||
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{ top: style.paddingTop as string, right: style.paddingRight as string, bottom: style.paddingBottom as string, left: style.paddingLeft as string }}
|
||||
onChange={(side: SpacingSide, v: string) => setPropStyle(`padding${capitalize(side)}`, v)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border as string)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacityPercent(style.opacity)}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: nodeProps.hideOnDesktop,
|
||||
hideOnTablet: nodeProps.hideOnTablet,
|
||||
hideOnMobile: nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { SHADOW_PRESETS } from '../../../constants/presets';
|
||||
import {
|
||||
SectionLabel,
|
||||
PresetButtonGrid,
|
||||
CollapsibleSection,
|
||||
SpacingControl,
|
||||
SpacingSide,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
sectionGap,
|
||||
labelStyle,
|
||||
} from './shared';
|
||||
|
||||
/* ==========================================================================
|
||||
Shared box-model / border+effects / animation+visibility sections for the
|
||||
MEDIA package's panels (ImageStylePanel + MediaStylePanel). Kept local to
|
||||
this package (not in shared.tsx, which is foundation/import-only) since
|
||||
it's just DRY-ing the identical JSX block across ImageBlock/VideoBlock/
|
||||
Gallery/ContentSlider/MapEmbed rather than a genuinely cross-package
|
||||
reusable control.
|
||||
========================================================================== */
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
||||
* the {width,style,color} shape BorderControl edits. Only needs to
|
||||
* round-trip values this same panel produced via buildBorderShorthand --
|
||||
* not arbitrary author-supplied CSS. */
|
||||
export function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
export interface BoxModelSectionProps {
|
||||
style: Record<string, any>;
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
}
|
||||
|
||||
/** Margin + Padding, per-side, via the shared SpacingControl. */
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
|
||||
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
|
||||
setPropStyle(`${kind}${capitalize(side)}`, value);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={sideSetter('margin')}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={sideSetter('padding')}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
|
||||
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
|
||||
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
|
||||
* integer for the range input / label. */
|
||||
function opacityPercent(v: unknown): number {
|
||||
if (v === undefined || v === null || v === '') return 100;
|
||||
const n = parseFloat(String(v));
|
||||
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
||||
}
|
||||
|
||||
export interface BorderEffectsSectionProps {
|
||||
style: Record<string, any>;
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
}
|
||||
|
||||
/** Border (width/style/color) + box-shadow preset + opacity slider. */
|
||||
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border)}
|
||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacityPercent(style.opacity)}
|
||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
|
||||
export interface AnimVisSectionProps {
|
||||
nodeProps: Record<string, any>;
|
||||
setProp: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
/** Entrance animation + responsive hide toggles -- top-level props consumed
|
||||
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
|
||||
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
|
||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
||||
<AnimationControl
|
||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||
/>
|
||||
<VisibilityControl
|
||||
value={{
|
||||
hideOnDesktop: nodeProps.hideOnDesktop,
|
||||
hideOnTablet: nodeProps.hideOnTablet,
|
||||
hideOnMobile: nodeProps.hideOnMobile,
|
||||
}}
|
||||
onChange={(v) => {
|
||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||
}}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
@@ -0,0 +1,340 @@
|
||||
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';
|
||||
import {
|
||||
NumericUnitInput,
|
||||
parseCssLength,
|
||||
SizeControl,
|
||||
AspectRatioControl,
|
||||
parseAspectRatioInput,
|
||||
FocalPointGrid,
|
||||
FOCAL_POINTS,
|
||||
SpacingControl,
|
||||
BorderControl,
|
||||
buildBorderShorthand,
|
||||
AnimationControl,
|
||||
ANIMATIONS,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
|
||||
/* Same DOM-harness pattern as AssetPicker.test.tsx / ImageBlock.render.test.tsx
|
||||
(no @testing-library/react in this repo). */
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
function click(el: Element | null) {
|
||||
if (!el) throw new Error('element not found');
|
||||
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
}
|
||||
|
||||
function setValue(input: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||
const proto = input instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function blur(el: Element) {
|
||||
act(() => { el.dispatchEvent(new FocusEvent('blur', { bubbles: true })); el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
|
||||
}
|
||||
|
||||
function q<T extends Element = Element>(testId: string): T | null {
|
||||
return container.querySelector(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
/* ---------- NumericUnitInput / parseCssLength ---------- */
|
||||
describe('parseCssLength', () => {
|
||||
test('parses "16px" into number 16 + unit px', () => {
|
||||
expect(parseCssLength('16px')).toEqual({ num: '16', unit: 'px' });
|
||||
});
|
||||
test('parses "50%" into number 50 + unit %', () => {
|
||||
expect(parseCssLength('50%', ['px', '%'])).toEqual({ num: '50', unit: '%' });
|
||||
});
|
||||
test('treats "auto" and "" as empty', () => {
|
||||
expect(parseCssLength('auto')).toEqual({ num: '', unit: 'px' });
|
||||
expect(parseCssLength('')).toEqual({ num: '', unit: 'px' });
|
||||
expect(parseCssLength(undefined)).toEqual({ num: '', unit: 'px' });
|
||||
});
|
||||
test('falls back to the first configured unit for an unrecognized unit', () => {
|
||||
expect(parseCssLength('16vh', ['px', '%'])).toEqual({ num: '16', unit: 'px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NumericUnitInput', () => {
|
||||
test('renders the parsed number and unit from a "16px" value', () => {
|
||||
render(<NumericUnitInput value="16px" onChange={vi.fn()} />);
|
||||
expect(q<HTMLInputElement>('numeric-unit-number')!.value).toBe('16');
|
||||
expect(q<HTMLSelectElement>('numeric-unit-unit')!.value).toBe('px');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('changing the number emits the recombined string', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||||
setValue(q<HTMLInputElement>('numeric-unit-number')!, '32');
|
||||
expect(onChange).toHaveBeenCalledWith('32px');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('changing the unit re-emits with the current number', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||||
setValue(q<HTMLSelectElement>('numeric-unit-unit')!, '%');
|
||||
expect(onChange).toHaveBeenCalledWith('16%');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('clearing the number emits empty string, not "0px"', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumericUnitInput value="16px" onChange={onChange} />);
|
||||
setValue(q<HTMLInputElement>('numeric-unit-number')!, '');
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('omits the unit <select> when only one unit is configured', () => {
|
||||
render(<NumericUnitInput value="1px" onChange={vi.fn()} units={['px']} />);
|
||||
expect(q('numeric-unit-unit')).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- SizeControl ---------- */
|
||||
describe('SizeControl', () => {
|
||||
test('clicking a preset button emits its value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SizeControl label="Width" value="" onChange={onChange} />);
|
||||
const buttons = container.querySelectorAll('[data-testid="size-control"] .preset-btn');
|
||||
const hundred = Array.from(buttons).find((b) => b.textContent === '100%')!;
|
||||
click(hundred);
|
||||
expect(onChange).toHaveBeenCalledWith('100%');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('custom numeric input emits a non-preset value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SizeControl label="Width" value="" onChange={onChange} />);
|
||||
setValue(q<HTMLInputElement>('size-control-custom-number')!, '400');
|
||||
expect(onChange).toHaveBeenCalledWith('400px');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- AspectRatioControl / parseAspectRatioInput ---------- */
|
||||
describe('parseAspectRatioInput', () => {
|
||||
test('parses "16:9" into "16 / 9"', () => {
|
||||
expect(parseAspectRatioInput('16:9')).toBe('16 / 9');
|
||||
});
|
||||
test('accepts "16/9" and "16x9" separators too', () => {
|
||||
expect(parseAspectRatioInput('16/9')).toBe('16 / 9');
|
||||
expect(parseAspectRatioInput('16x9')).toBe('16 / 9');
|
||||
});
|
||||
test('returns empty string for unparseable input', () => {
|
||||
expect(parseAspectRatioInput('not-a-ratio')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AspectRatioControl', () => {
|
||||
test('clicking the 16:9 preset emits "16 / 9"', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AspectRatioControl value="" onChange={onChange} />);
|
||||
const buttons = container.querySelectorAll('[data-testid="aspect-ratio-control"] .preset-btn');
|
||||
const btn = Array.from(buttons).find((b) => b.textContent === '16:9')!;
|
||||
click(btn);
|
||||
expect(onChange).toHaveBeenCalledWith('16 / 9');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('clicking Original emits empty string', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AspectRatioControl value="1 / 1" onChange={onChange} />);
|
||||
const buttons = container.querySelectorAll('[data-testid="aspect-ratio-control"] .preset-btn');
|
||||
const btn = Array.from(buttons).find((b) => b.textContent === 'Original')!;
|
||||
click(btn);
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('typing a custom "16:9" and blurring emits "16 / 9"', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AspectRatioControl value="" onChange={onChange} />);
|
||||
const input = q<HTMLInputElement>('aspect-ratio-custom-input')!;
|
||||
setValue(input, '16:9');
|
||||
blur(input);
|
||||
expect(onChange).toHaveBeenCalledWith('16 / 9');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- FocalPointGrid ---------- */
|
||||
describe('FocalPointGrid', () => {
|
||||
test('has 9 cells matching FOCAL_POINTS', () => {
|
||||
render(<FocalPointGrid value="center" onChange={vi.fn()} />);
|
||||
const cells = container.querySelectorAll('[data-testid="focal-point-grid"] button');
|
||||
expect(cells.length).toBe(9);
|
||||
expect(FOCAL_POINTS.length).toBe(9);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('clicking a cell emits its object-position value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FocalPointGrid value="center" onChange={onChange} />);
|
||||
click(q('focal-point-left-top'));
|
||||
expect(onChange).toHaveBeenCalledWith('left top');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('the active cell is visually highlighted (distinct background)', () => {
|
||||
render(<FocalPointGrid value="right bottom" onChange={vi.fn()} />);
|
||||
const active = q<HTMLButtonElement>('focal-point-right-bottom')!;
|
||||
const inactive = q<HTMLButtonElement>('focal-point-center')!;
|
||||
expect(active.style.background).not.toBe(inactive.style.background);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('defaults the highlight to center when value is empty', () => {
|
||||
render(<FocalPointGrid value="" onChange={vi.fn()} />);
|
||||
const center = q<HTMLButtonElement>('focal-point-center')!;
|
||||
const other = q<HTMLButtonElement>('focal-point-left-top')!;
|
||||
expect(center.style.background).not.toBe(other.style.background);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- SpacingControl ---------- */
|
||||
describe('SpacingControl', () => {
|
||||
test('linked mode: editing the shared field emits onChange for all 4 sides with the same value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SpacingControl label="Margin" value={{}} onChange={onChange} />);
|
||||
setValue(q<HTMLInputElement>('spacing-linked-number')!, '16');
|
||||
expect(onChange).toHaveBeenCalledWith('top', '16px');
|
||||
expect(onChange).toHaveBeenCalledWith('right', '16px');
|
||||
expect(onChange).toHaveBeenCalledWith('bottom', '16px');
|
||||
expect(onChange).toHaveBeenCalledWith('left', '16px');
|
||||
expect(onChange).toHaveBeenCalledTimes(4);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('a preset fills all sides with the preset value', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SpacingControl label="Padding" value={{}} onChange={onChange} />);
|
||||
const buttons = container.querySelectorAll('[data-testid="spacing-control"] .preset-btn');
|
||||
const medium = Array.from(buttons).find((b) => b.textContent === 'M')!;
|
||||
click(medium);
|
||||
expect(onChange).toHaveBeenCalledWith('top', '16px');
|
||||
expect(onChange).toHaveBeenCalledWith('left', '16px');
|
||||
expect(onChange).toHaveBeenCalledTimes(4);
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('unlinking lets each side emit independently', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SpacingControl label="Margin" value={{ top: '4px', right: '4px', bottom: '4px', left: '4px' }} onChange={onChange} />);
|
||||
click(q('spacing-link-toggle'));
|
||||
setValue(q<HTMLInputElement>('spacing-top-number')!, '20');
|
||||
expect(onChange).toHaveBeenCalledWith('top', '20px');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- BorderControl ---------- */
|
||||
describe('BorderControl', () => {
|
||||
test('changing width/style/color merges into the full BorderValue object', () => {
|
||||
const onChange = vi.fn();
|
||||
const value = { width: '1px', style: 'solid', color: '#000000' };
|
||||
render(<BorderControl value={value} onChange={onChange} />);
|
||||
setValue(q<HTMLSelectElement>('border-style-select')!, 'dashed');
|
||||
expect(onChange).toHaveBeenCalledWith({ width: '1px', style: 'dashed', color: '#000000' });
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('buildBorderShorthand builds a CSS border shorthand', () => {
|
||||
expect(buildBorderShorthand({ width: '2px', style: 'solid', color: '#3b82f6' })).toBe('2px solid #3b82f6');
|
||||
});
|
||||
|
||||
test('buildBorderShorthand returns "none" when style is none or width is empty', () => {
|
||||
expect(buildBorderShorthand({ width: '2px', style: 'none', color: '#000' })).toBe('none');
|
||||
expect(buildBorderShorthand({ width: '', style: 'solid', color: '#000' })).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- AnimationControl ---------- */
|
||||
describe('AnimationControl', () => {
|
||||
test('ANIMATIONS matches the exact data-animation values html-export.ts supports', () => {
|
||||
expect(ANIMATIONS.map((a) => a.value)).toEqual([
|
||||
'none', 'fade-in', 'slide-up', 'slide-left', 'slide-right', 'zoom-in', 'bounce',
|
||||
]);
|
||||
});
|
||||
|
||||
test('selecting an animation emits { animation, animationDelay }', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AnimationControl value={{ animation: 'none' }} onChange={onChange} />);
|
||||
const buttons = container.querySelectorAll('[data-testid="animation-control"] .preset-btn');
|
||||
const fadeIn = Array.from(buttons).find((b) => b.textContent === 'Fade In')!;
|
||||
click(fadeIn);
|
||||
expect(onChange).toHaveBeenCalledWith({ animation: 'fade-in', animationDelay: '0' });
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('the delay input is hidden when animation is "none" and shown otherwise', () => {
|
||||
render(<AnimationControl value={{ animation: 'none' }} onChange={vi.fn()} />);
|
||||
expect(q('animation-delay-input')).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<AnimationControl value={{ animation: 'bounce', animationDelay: '0.5' }} onChange={vi.fn()} />);
|
||||
expect(q<HTMLInputElement>('animation-delay-input')!.value).toBe('0.5');
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('changing the delay emits the current animation with the new delay', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<AnimationControl value={{ animation: 'zoom-in', animationDelay: '0' }} onChange={onChange} />);
|
||||
setValue(q<HTMLInputElement>('animation-delay-input')!, '0.3');
|
||||
expect(onChange).toHaveBeenCalledWith({ animation: 'zoom-in', animationDelay: '0.3' });
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- VisibilityControl ---------- */
|
||||
describe('VisibilityControl', () => {
|
||||
test('emits the exact hideOnDesktop/hideOnTablet/hideOnMobile prop names', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<VisibilityControl value={{}} onChange={onChange} />);
|
||||
click(q('visibility-hideOnDesktop'));
|
||||
expect(onChange).toHaveBeenCalledWith({ hideOnDesktop: true });
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('toggling reflects existing true values back to false', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<VisibilityControl value={{ hideOnMobile: true }} onChange={onChange} />);
|
||||
click(q('visibility-hideOnMobile'));
|
||||
expect(onChange).toHaveBeenCalledWith({ hideOnMobile: false });
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('checkboxes reflect the initial value', () => {
|
||||
render(<VisibilityControl value={{ hideOnTablet: true }} onChange={vi.fn()} />);
|
||||
expect(q<HTMLInputElement>('visibility-hideOnDesktop')!.checked).toBe(false);
|
||||
expect(q<HTMLInputElement>('visibility-hideOnTablet')!.checked).toBe(true);
|
||||
expect(q<HTMLInputElement>('visibility-hideOnMobile')!.checked).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import React, { useState, useCallback, CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
GRADIENTS,
|
||||
SIZE_PRESETS,
|
||||
ASPECT_RATIOS,
|
||||
SPACING_PRESETS,
|
||||
BORDER_STYLES,
|
||||
} from '../../../constants/presets';
|
||||
import { uploadAsset } from '../../../utils/assets';
|
||||
|
||||
@@ -84,20 +88,42 @@ interface PresetButtonGridProps {
|
||||
presets: { label: string; value: string }[];
|
||||
activeValue: string | undefined;
|
||||
onSelect: (value: string) => void;
|
||||
/** Explicit column count. When omitted, a column count is derived from
|
||||
* `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset
|
||||
* sets (5, 6, ...) don't leave a lone orphan button dangling on its own
|
||||
* row under the fixed 4-column grid. */
|
||||
columns?: number;
|
||||
}
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect }) => (
|
||||
<div className="preset-grid">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||||
onClick={() => onSelect(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Picks a column count that avoids a single orphan on the last row.
|
||||
* 4-or-fewer presets keep the classic single row of 4. 5 gets its own
|
||||
* row (5 cols). 6 splits into two even rows of 3. Anything else falls
|
||||
* back to a 4- or 3-column grid depending on which divides evenly. */
|
||||
export function defaultPresetGridColumns(count: number): number {
|
||||
if (count <= 4) return 4;
|
||||
if (count === 5) return 5;
|
||||
if (count === 6) return 3;
|
||||
if (count % 4 === 0) return 4;
|
||||
if (count % 3 === 0) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
export const PresetButtonGrid: React.FC<PresetButtonGridProps> = ({ presets, activeValue, onSelect, columns }) => {
|
||||
const cols = columns ?? defaultPresetGridColumns(presets.length);
|
||||
return (
|
||||
<div className="preset-grid" style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
className={`preset-btn ${String(activeValue) === p.value ? 'active' : ''}`}
|
||||
onClick={() => onSelect(p.value)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GradientSwatchGridProps {
|
||||
activeValue: string | undefined;
|
||||
@@ -350,3 +376,379 @@ export const ArrayPropEditor: React.FC<ArrayPropEditorProps> = ({ selectedId, pr
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* =========================================================================
|
||||
ENH-Foundation: reusable StylePanel controls
|
||||
---------------------------------------------------------------------
|
||||
Presentational (value + onChange) controls shared by upcoming feature
|
||||
panels (size/spacing/border/typography/animation/visibility rollout).
|
||||
Nothing below is wired into any feature panel yet -- that's the job of
|
||||
the downstream feature branches. Each control's onChange API is
|
||||
documented on its props interface; see
|
||||
.superpowers/sdd/task-enh-foundation-report.md for the consolidated list.
|
||||
========================================================================= */
|
||||
|
||||
/* ---------- NumericUnitInput ----------
|
||||
A number field + unit dropdown that parses/holds a CSS length string
|
||||
("16px", "50%", "auto", "") and calls onChange(nextString).
|
||||
- Clearing the number field emits '' (not '0px') -- lets the consumer
|
||||
treat '' as "unset/inherit" instead of forcing a 0 value.
|
||||
- value === 'auto' or '' both display as a blank number field (with the
|
||||
`placeholder` -- default "auto" -- shown as a hint).
|
||||
- Changing the unit re-emits with the current number and the new unit;
|
||||
if the number is blank, changing the unit is a no-op (still emits '').
|
||||
- `units` is configurable (default ['px','%']); the unit <select> is
|
||||
omitted entirely when only one unit is passed. */
|
||||
export function parseCssLength(value: string | undefined, units: string[] = ['px', '%']): { num: string; unit: string } {
|
||||
if (!value || value === 'auto') return { num: '', unit: units[0] };
|
||||
const m = String(value).trim().match(/^(-?\d*\.?\d+)\s*([a-zA-Z%]*)$/);
|
||||
if (!m) return { num: '', unit: units[0] };
|
||||
const unit = m[2] || units[0];
|
||||
return { num: m[1], unit: units.includes(unit) ? unit : units[0] };
|
||||
}
|
||||
|
||||
export interface NumericUnitInputProps {
|
||||
/** CSS length string, e.g. "16px" / "50%" / "auto" / "". */
|
||||
value: string;
|
||||
/** Called with the recombined string, e.g. "16px". Called with '' when the
|
||||
* number field is cleared. */
|
||||
onChange: (value: string) => void;
|
||||
/** Allowed unit list; default ['px', '%']. Unit <select> is hidden when
|
||||
* this has length 1. */
|
||||
units?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
/** Base for the data-testid attrs (`${testId}`, `${testId}-number`,
|
||||
* `${testId}-unit`); default 'numeric-unit'. */
|
||||
testId?: string;
|
||||
}
|
||||
export const NumericUnitInput: React.FC<NumericUnitInputProps> = ({
|
||||
value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit',
|
||||
}) => {
|
||||
const { num, unit } = parseCssLength(value, units);
|
||||
const emit = (nextNum: string, nextUnit: string) => {
|
||||
if (nextNum === '') { onChange(''); return; }
|
||||
onChange(`${nextNum}${nextUnit}`);
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4 }} data-testid={testId}>
|
||||
<input
|
||||
type="number"
|
||||
data-testid={`${testId}-number`}
|
||||
value={num}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => emit(e.target.value, unit)}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
{units.length > 1 && (
|
||||
<select
|
||||
data-testid={`${testId}-unit`}
|
||||
value={unit}
|
||||
onChange={(e) => emit(num, e.target.value)}
|
||||
style={{ ...inputStyle, width: 60, flex: 'none' }}
|
||||
>
|
||||
{units.map((u) => <option key={u} value={u}>{u}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- SizeControl ----------
|
||||
Width (or height) editor: preset buttons (25/50/75/100%, Auto, Full) +
|
||||
a custom NumericUnitInput. One dimension per instance -- render two
|
||||
(label="Width" / label="Height") for components needing both, e.g.
|
||||
ImageBlock / VideoBlock sizing. */
|
||||
export interface SizeControlProps {
|
||||
label: string;
|
||||
/** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SizeControl: React.FC<SizeControlProps> = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => (
|
||||
<div className="guided-section" data-testid="size-control">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<PresetButtonGrid presets={presets} activeValue={value} onSelect={onChange} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput
|
||||
value={presets.some((p) => p.value === value) ? '' : value}
|
||||
onChange={onChange}
|
||||
units={units}
|
||||
testId="size-control-custom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- AspectRatioControl ----------
|
||||
Preset buttons + a custom "W:H" text entry. Emits a CSS `aspect-ratio`
|
||||
value, e.g. "16 / 9". 'Original' emits ''. Custom entry accepts
|
||||
"16:9" / "16/9" / "16x9" and is parsed on blur or Enter. */
|
||||
export function parseAspectRatioInput(raw: string): string {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$/);
|
||||
if (!m) return '';
|
||||
return `${m[1]} / ${m[2]}`;
|
||||
}
|
||||
function formatAspectRatioForInput(value: string): string {
|
||||
const m = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
}
|
||||
export interface AspectRatioControlProps {
|
||||
/** CSS `aspect-ratio` value, e.g. "16 / 9", or '' for Original/unset. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
}
|
||||
export const AspectRatioControl: React.FC<AspectRatioControlProps> = ({ value, onChange, presets = ASPECT_RATIOS }) => {
|
||||
const [custom, setCustom] = useState(() => formatAspectRatioForInput(value));
|
||||
|
||||
const applyCustom = () => onChange(parseAspectRatioInput(custom));
|
||||
|
||||
return (
|
||||
<div className="guided-section" data-testid="aspect-ratio-control">
|
||||
<SectionLabel>Aspect Ratio</SectionLabel>
|
||||
<PresetButtonGrid presets={presets} activeValue={value} onSelect={(v) => { onChange(v); setCustom(''); }} />
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
data-testid="aspect-ratio-custom-input"
|
||||
value={custom}
|
||||
placeholder="W:H e.g. 16:9"
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
onBlur={applyCustom}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }}
|
||||
style={{ ...inputStyle }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- FocalPointGrid ----------
|
||||
3x3 grid of buttons mapping to CSS `object-position` keyword pairs.
|
||||
Used for image crop framing (object-position on an ImageBlock/VideoBlock
|
||||
with object-fit: cover). Highlights the active cell; defaults the
|
||||
highlight to 'center' when value is empty. */
|
||||
export const FOCAL_POINTS: { label: string; value: string }[] = [
|
||||
{ label: '↖', value: 'left top' }, { label: '↑', value: 'center top' }, { label: '↗', value: 'right top' },
|
||||
{ label: '←', value: 'left center' }, { label: '•', value: 'center' }, { label: '→', value: 'right center' },
|
||||
{ label: '↙', value: 'left bottom' }, { label: '↓', value: 'center bottom' }, { label: '↘', value: 'right bottom' },
|
||||
];
|
||||
export interface FocalPointGridProps {
|
||||
/** CSS `object-position` value, e.g. "center" / "left top". */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
export const FocalPointGrid: React.FC<FocalPointGridProps> = ({ value, onChange }) => {
|
||||
const active = value || 'center';
|
||||
return (
|
||||
<div className="guided-section" data-testid="focal-point-grid">
|
||||
<SectionLabel>Focal Point</SectionLabel>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, width: 96 }}>
|
||||
{FOCAL_POINTS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
data-testid={`focal-point-${p.value.replace(/\s+/g, '-')}`}
|
||||
onClick={() => onChange(p.value)}
|
||||
title={p.value}
|
||||
style={{
|
||||
aspectRatio: '1', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
|
||||
background: active === p.value ? '#3b82f6' : '#27272a',
|
||||
color: active === p.value ? '#fff' : '#71717a', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- SpacingControl ----------
|
||||
Margin OR padding, per-side, with a link/unlink toggle.
|
||||
API: onChange(side, value) -- called once per side. In linked mode
|
||||
(default), editing the single shared field or clicking a preset calls
|
||||
onChange for all four sides ('top','right','bottom','left') with the
|
||||
same value so the consumer can setPropStyle each side independently
|
||||
(e.g. marginTop/marginRight/marginBottom/marginLeft). In unlinked mode
|
||||
each side's NumericUnitInput calls onChange for just that side. */
|
||||
export type SpacingSide = 'top' | 'right' | 'bottom' | 'left';
|
||||
export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; }
|
||||
const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
|
||||
export interface SpacingControlProps {
|
||||
label: string;
|
||||
value: SpacingSideValues;
|
||||
/** Called per-side. Linked edits/presets call this once for each of the
|
||||
* 4 sides (in top/right/bottom/left order) with the same value. */
|
||||
onChange: (side: SpacingSide, value: string) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
const [linked, setLinked] = useState(true);
|
||||
|
||||
const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v));
|
||||
const linkedValue = value.top ?? '';
|
||||
const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue);
|
||||
|
||||
return (
|
||||
<div className="guided-section" data-testid="spacing-control">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<button
|
||||
data-testid="spacing-link-toggle"
|
||||
onClick={() => setLinked(!linked)}
|
||||
title={linked ? 'Unlink sides' : 'Link sides'}
|
||||
style={{ background: 'none', border: 'none', color: linked ? '#3b82f6' : '#71717a', cursor: 'pointer', padding: 2 }}
|
||||
>
|
||||
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
|
||||
{linked ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginTop: 6 }}>
|
||||
{SPACING_SIDES.map((s) => (
|
||||
<div key={s}>
|
||||
<label style={{ ...labelStyle, fontSize: 9 }}>{s}</label>
|
||||
<NumericUnitInput value={value[s] ?? ''} onChange={(v) => onChange(s, v)} units={units} testId={`spacing-${s}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- BorderControl ----------
|
||||
Width (px) + style (none/solid/dashed/dotted) + color. Single onChange
|
||||
receives the merged { width, style, color } object so the consumer can
|
||||
either setProp each part or build a shorthand via buildBorderShorthand()
|
||||
(returns 'none' when width is falsy or style is 'none'). */
|
||||
export interface BorderValue { width: string; style: string; color: string; }
|
||||
export interface BorderControlProps {
|
||||
value: BorderValue;
|
||||
onChange: (value: BorderValue) => void;
|
||||
label?: string;
|
||||
}
|
||||
export const BorderControl: React.FC<BorderControlProps> = ({ value, onChange, label = 'Border' }) => {
|
||||
const set = (patch: Partial<BorderValue>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<div className="guided-section" data-testid="border-control">
|
||||
<SectionLabel>{label}</SectionLabel>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<NumericUnitInput value={value.width} onChange={(v) => set({ width: v })} units={['px']} placeholder="0" testId="border-width" />
|
||||
</div>
|
||||
<select
|
||||
data-testid="border-style-select"
|
||||
value={value.style}
|
||||
onChange={(e) => set({ style: e.target.value })}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
>
|
||||
{BORDER_STYLES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<ColorPickerField label="Border Color" value={value.color} onChange={(v) => set({ color: v })} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export function buildBorderShorthand(value: BorderValue): string {
|
||||
if (!value.width || !value.style || value.style === 'none') return 'none';
|
||||
return `${value.width} ${value.style} ${value.color || '#000000'}`;
|
||||
}
|
||||
|
||||
/* ---------- AnimationControl ----------
|
||||
"Entrance animation" picker + optional delay. Emits the EXACT prop names
|
||||
the export machinery already consumes (verified against buildDataAttrs()
|
||||
in src/utils/html-export.ts): `animation` and `animationDelay`.
|
||||
NOTE: the export supports fade-in / slide-up / slide-left / slide-right /
|
||||
zoom-in / bounce (no "slide-down" -- html-export.ts has no such variant;
|
||||
slide-left/slide-right exist instead), so ANIMATIONS reflects that exact
|
||||
set rather than guessing. animationDelay is a plain seconds string (e.g.
|
||||
"0.3") -- html-export.ts's inline script assigns it straight to
|
||||
el.style.animationDelay, so consumers should suffix/accept a unit-bearing
|
||||
string like "0.3s" if that's what they choose to store; this control
|
||||
stores/edits the raw numeric string and leaves unit formatting to the
|
||||
caller (kept as the simplest contract; see report for the exact
|
||||
behavior verified against html-export.ts). */
|
||||
export const ANIMATIONS: { label: string; value: string }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Fade In', value: 'fade-in' },
|
||||
{ label: 'Slide Up', value: 'slide-up' },
|
||||
{ label: 'Slide Left', value: 'slide-left' },
|
||||
{ label: 'Slide Right', value: 'slide-right' },
|
||||
{ label: 'Zoom In', value: 'zoom-in' },
|
||||
{ label: 'Bounce', value: 'bounce' },
|
||||
];
|
||||
export interface AnimationValue { animation: string; animationDelay?: string; }
|
||||
export interface AnimationControlProps {
|
||||
value: AnimationValue;
|
||||
onChange: (value: AnimationValue) => void;
|
||||
}
|
||||
export const AnimationControl: React.FC<AnimationControlProps> = ({ value, onChange }) => {
|
||||
const animation = value.animation || 'none';
|
||||
const animationDelay = value.animationDelay ?? '0';
|
||||
return (
|
||||
<div className="guided-section" data-testid="animation-control">
|
||||
<SectionLabel>Entrance Animation</SectionLabel>
|
||||
<PresetButtonGrid presets={ANIMATIONS} activeValue={animation} onSelect={(v) => onChange({ animation: v, animationDelay })} />
|
||||
{animation !== 'none' && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<label style={labelStyle}>Delay (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
data-testid="animation-delay-input"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={animationDelay}
|
||||
onChange={(e) => onChange({ animation, animationDelay: e.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- VisibilityControl ----------
|
||||
Hide on Desktop / Tablet / Mobile toggles. Emits the EXACT prop names
|
||||
the export machinery already consumes (verified against buildDataAttrs()
|
||||
in src/utils/html-export.ts): `hideOnDesktop`, `hideOnTablet`,
|
||||
`hideOnMobile` (each rendered as data-hide-desktop/tablet/mobile when
|
||||
truthy). Single onChange receives the merged object. */
|
||||
export interface VisibilityValue { hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; }
|
||||
export interface VisibilityControlProps {
|
||||
value: VisibilityValue;
|
||||
onChange: (value: VisibilityValue) => void;
|
||||
}
|
||||
const VISIBILITY_KEYS: (keyof VisibilityValue)[] = ['hideOnDesktop', 'hideOnTablet', 'hideOnMobile'];
|
||||
export const VisibilityControl: React.FC<VisibilityControlProps> = ({ value, onChange }) => (
|
||||
<div className="guided-section" data-testid="visibility-control">
|
||||
<SectionLabel>Visibility</SectionLabel>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{VISIBILITY_KEYS.map((key) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`visibility-${key}`}
|
||||
checked={!!value[key]}
|
||||
onChange={() => onChange({ ...value, [key]: !value[key] })}
|
||||
/>
|
||||
Hide on {key.replace('hideOn', '')}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) =>
|
||||
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
|
||||
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
|
||||
placeholder={placeholder || 'Describe what you want...'}
|
||||
className="sitesmith-textarea"
|
||||
style={{
|
||||
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
|
||||
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
|
||||
|
||||
@@ -21,8 +21,8 @@ export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
✨ Sitesmith
|
||||
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
|
||||
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} /> Sitesmith
|
||||
{locked && <i className="fa fa-lock" aria-hidden style={{ marginLeft: 6, fontSize: 12 }} />}
|
||||
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -98,12 +98,15 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
onClose={onClose}
|
||||
closeOnEscape={false}
|
||||
closeOnBackdropClick={false}
|
||||
backdropStyle={{ backgroundColor: 'rgba(0,0,0,0.8)', zIndex: 9000 }}
|
||||
backdropStyle={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
|
||||
backdropProps={{ role: 'dialog', 'aria-modal': true }}
|
||||
>
|
||||
<div style={panel}>
|
||||
<div style={header}>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
||||
<div style={{ fontWeight: 600, color: '#fff' }}>
|
||||
<i className="fa fa-magic" aria-hidden style={{ marginRight: 6 }} />
|
||||
Sitesmith
|
||||
</div>
|
||||
{summary && summary.enabled && (
|
||||
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
|
||||
{summary.monthly_used} / {summary.monthly_cap} this month
|
||||
@@ -136,7 +139,7 @@ export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
|
||||
<button onClick={onClose} aria-label="Close" style={closeBtn}><i className="fa fa-times" aria-hidden /></button>
|
||||
</div>
|
||||
<div style={body}>
|
||||
<UpgradeBanner summary={summary} />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
/* ---------- DOM test harness -- same react-dom/client + `act` pattern used
|
||||
throughout src/ui/*.test.tsx (no @testing-library/react in this repo). ---------- */
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (container) {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
// Exposes the current headCode so assertions can read it back after a
|
||||
// simulated edit -- CodeEditor writes through `updateDesign`, this just
|
||||
// surfaces the result.
|
||||
function Harness({ onReady }: { onReady: (headCode: string) => void }) {
|
||||
const { design } = useSiteDesign();
|
||||
onReady(design.headCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('HeadCodeModal', () => {
|
||||
// HeadCodeModal portals its content to document.body (see the comment in
|
||||
// HeadCodeModal.tsx), so the rendered DOM lives outside `container` --
|
||||
// query document.body instead.
|
||||
|
||||
test('renders the CodeEditor (fallback textarea path) seeded with the current headCode', () => {
|
||||
render(
|
||||
<SiteDesignProvider>
|
||||
<HeadCodeModal open onClose={vi.fn()} />
|
||||
</SiteDesignProvider>,
|
||||
);
|
||||
// CodeMirror loads via async dynamic import (see CodeEditor.test.tsx);
|
||||
// synchronously after mount the fallback textarea is what's live.
|
||||
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(textarea!.value).toBe('');
|
||||
expect(textarea!.dataset.language).toBe('html');
|
||||
});
|
||||
|
||||
test('typing in the editor writes through to SiteDesignContext.headCode', () => {
|
||||
let latestHeadCode = '';
|
||||
render(
|
||||
<SiteDesignProvider>
|
||||
<HeadCodeModal open onClose={vi.fn()} />
|
||||
<Harness onReady={(v) => { latestHeadCode = v; }} />
|
||||
</SiteDesignProvider>,
|
||||
);
|
||||
|
||||
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]')!;
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
|
||||
setter.call(textarea, '<meta name="x" content="y">');
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(latestHeadCode).toBe('<meta name="x" content="y">');
|
||||
});
|
||||
|
||||
test('does not render when closed', () => {
|
||||
render(
|
||||
<SiteDesignProvider>
|
||||
<HeadCodeModal open={false} onClose={vi.fn()} />
|
||||
</SiteDesignProvider>,
|
||||
);
|
||||
expect(document.body.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user