diff --git a/craft/package-lock.json b/craft/package-lock.json index e8a3314..07fd023 100644 --- a/craft/package-lock.json +++ b/craft/package-lock.json @@ -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", diff --git a/craft/package.json b/craft/package.json index d3acd5f..f73194d 100644 --- a/craft/package.json +++ b/craft/package.json @@ -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", diff --git a/craft/src/constants/presets.ts b/craft/src/constants/presets.ts index 6c7de6d..8d4c05d 100644 --- a/craft/src/constants/presets.ts +++ b/craft/src/constants/presets.ts @@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record = { 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' }, +]; diff --git a/craft/src/panels/right/styles/shared-controls.test.tsx b/craft/src/panels/right/styles/shared-controls.test.tsx new file mode 100644 index 0000000..d49adbc --- /dev/null +++ b/craft/src/panels/right/styles/shared-controls.test.tsx @@ -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(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(); + expect(q('numeric-unit-number')!.value).toBe('16'); + expect(q('numeric-unit-unit')!.value).toBe('px'); + unmount(); + }); + + test('changing the number emits the recombined string', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-number')!, '32'); + expect(onChange).toHaveBeenCalledWith('32px'); + unmount(); + }); + + test('changing the unit re-emits with the current number', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-unit')!, '%'); + expect(onChange).toHaveBeenCalledWith('16%'); + unmount(); + }); + + test('clearing the number emits empty string, not "0px"', () => { + const onChange = vi.fn(); + render(); + setValue(q('numeric-unit-number')!, ''); + expect(onChange).toHaveBeenCalledWith(''); + unmount(); + }); + + test('omits the unit 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 emit(e.target.value, unit)} + style={{ ...inputStyle, flex: 1 }} + /> + {units.length > 1 && ( + + )} + + ); +}; + +/* ---------- 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 = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => ( +
+ {label} + +
+ p.value === value) ? '' : value} + onChange={onChange} + units={units} + testId="size-control-custom" + /> +
+
+); + +/* ---------- 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 = ({ value, onChange, presets = ASPECT_RATIOS }) => { + const [custom, setCustom] = useState(() => formatAspectRatioForInput(value)); + + const applyCustom = () => onChange(parseAspectRatioInput(custom)); + + return ( +
+ Aspect Ratio + { onChange(v); setCustom(''); }} /> +
+ setCustom(e.target.value)} + onBlur={applyCustom} + onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }} + style={{ ...inputStyle }} + /> +
+
+ ); +}; + +/* ---------- 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 = ({ value, onChange }) => { + const active = value || 'center'; + return ( +
+ Focal Point +
+ {FOCAL_POINTS.map((p) => ( + + ))} +
+
+ ); +}; + +/* ---------- 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 = ({ 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 ( +
+
+ {label} + +
+ + {linked ? ( +
+ +
+ ) : ( +
+ {SPACING_SIDES.map((s) => ( +
+ + onChange(s, v)} units={units} testId={`spacing-${s}`} /> +
+ ))} +
+ )} +
+ ); +}; + +/* ---------- 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 = ({ value, onChange, label = 'Border' }) => { + const set = (patch: Partial) => onChange({ ...value, ...patch }); + return ( +
+ {label} +
+
+ set({ width: v })} units={['px']} placeholder="0" testId="border-width" /> +
+ +
+ set({ color: v })} /> +
+ ); +}; +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 = ({ value, onChange }) => { + const animation = value.animation || 'none'; + const animationDelay = value.animationDelay ?? '0'; + return ( +
+ Entrance Animation + onChange({ animation: v, animationDelay })} /> + {animation !== 'none' && ( +
+ + onChange({ animation, animationDelay: e.target.value })} + style={inputStyle} + /> +
+ )} +
+ ); +}; + +/* ---------- 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 = ({ value, onChange }) => ( +
+ Visibility +
+ {VISIBILITY_KEYS.map((key) => ( + + ))} +
+
+);