diff --git a/src/features/style/embed/useEditorRuleEdits.ts b/src/features/style/embed/useEditorRuleEdits.ts index 0c7c0381..bcd80880 100644 --- a/src/features/style/embed/useEditorRuleEdits.ts +++ b/src/features/style/embed/useEditorRuleEdits.ts @@ -16,6 +16,11 @@ import { splitRuleSelectorAt, } from '../model/css'; import { canonicalCompound, parseSelectorList } from '../model/selectors'; +import { + clearPaddingSides, + isPaddingProperty, + setPaddingDeclaration, +} from '../model/spacingShorthand'; import { getHost, type ClassOutcome } from '../model/host'; import { type EmbedDocument, navigateToEmbed, writeEmbedDocument } from '../model/webflow'; import type { ParsedDeclaration, ParsedRule } from '../model/styleTypes'; @@ -208,6 +213,10 @@ export function usePropEdits( (rule: ParsedRule, prop: string, value: string, important: boolean) => { void applyEdit(rule, () => { const { rule: editRule } = splitForEdit(rule); + if (isPaddingProperty(prop)) { + setPaddingDeclaration(editRule.node, prop, { value, important }); + return; + } const target = lastDeclFor(editRule, prop); if (target) { target.value = value; @@ -224,13 +233,15 @@ export function usePropEdits( const props = Array.isArray(prop) ? prop : [prop]; void applyEdit(rule, () => { const { rule: editRule } = splitForEdit(rule); + // A side grouped into `padding` or an axis has no declaration of its own. + const clearedPadding = clearPaddingSides(editRule.node, props); const targets: Declaration[] = []; directDecls(editRule.node).forEach((decl) => { if (props.includes(decl.prop)) { targets.push(decl); } }); - if (!targets.length) { + if (!targets.length && !clearedPadding) { return false; } targets.forEach((decl) => decl.remove()); diff --git a/src/features/style/model/cssRegions.ts b/src/features/style/model/cssRegions.ts index aa4a6841..59a781a0 100644 --- a/src/features/style/model/cssRegions.ts +++ b/src/features/style/model/cssRegions.ts @@ -5,6 +5,7 @@ import postcss, { type Root, type Rule, type AtRule, type Declaration } from 'postcss'; import type { StyleRegion } from './styleTypes'; import { selectorKey } from './resolved'; +import { isPaddingProperty, setPaddingDeclaration } from './spacingShorthand'; /** A declaration's value and whether it is `!important` — the two always travel together. */ export type DeclarationValue = { readonly value: string; readonly important: boolean }; @@ -53,6 +54,10 @@ export function findChildRuleBySelector( // Update the rule's existing declaration for `prop`, or append it — mirrors onSetProp. export function setDeclOnRule(rule: Rule, prop: string, declared: DeclarationValue) { + if (isPaddingProperty(prop)) { + setPaddingDeclaration(rule, prop, declared); + return; + } const found: Declaration[] = []; rule.walkDecls(prop, (declaration) => { found.push(declaration); @@ -184,6 +189,10 @@ export function appendDecl( /** Set (update-or-append) a DIRECT declaration on a node — scoped so a nested rule's * declaration of the same prop is never mistaken for this node's own. */ export function setDeclDirect(node: Rule | AtRule, prop: string, declared: DeclarationValue) { + if (isPaddingProperty(prop)) { + setPaddingDeclaration(node, prop, declared); + return; + } const key = prop.trim().toLowerCase(); const existing = directDecls(node).find( (declaration) => declaration.prop.trim().toLowerCase() === key, diff --git a/src/features/style/model/resolved.ts b/src/features/style/model/resolved.ts index a328bd07..bd6d6464 100644 --- a/src/features/style/model/resolved.ts +++ b/src/features/style/model/resolved.ts @@ -9,6 +9,7 @@ import { compareCascade } from './cascade'; import type { MatchedRule, RuleModel } from './cascade'; import { assert } from '../../../../shared/core/assert'; import { canonicalCompound, compareSpecificity, normalizePseudoElement } from './selectors'; +import { spacingSides } from './spacingShorthand'; import type { BreakpointId, ParsedRule, Specificity } from './styleTypes'; export type ContextKey = string; // '' = Base; else the joined atContext @@ -593,12 +594,7 @@ function foldEmbedRule(matched: MatchedRule, view: ResolveView): FoldedRule | un } const isSelected = atContext && isSelectedRule(matched, view); - // One contributor per (rule, prop) — the last decl wins within a rule. - const lastByProp = new Map(); - for (const decl of matched.rule.declarations) { - lastByProp.set(decl.prop, { value: decl.value, important: decl.important }); - } - const contributors = [...lastByProp].map(([prop, decl]) => ({ + const contributors = [...declaredByProp(matched.rule)].map(([prop, decl]) => ({ prop, contributor: { selectorText: winner.text, @@ -619,6 +615,32 @@ function foldEmbedRule(matched: MatchedRule, view: ResolveView): FoldedRule | un return { isSelected, contributors }; } +// One value per property of a rule: the last declaration wins, unless an earlier +// one is `!important` and it is not. A box shorthand also counts for each side it +// sets, which is where the spacing box reads it (`padding: 1rem 2rem` fills the +// four padding fields). The shorthand stays a property of its own, so it is still +// edited and cleared whole, and reading never rewrites the source. +function declaredByProp(rule: ParsedRule): ReadonlyMap { + const declared = new Map(); + const declare = (prop: string, value: Declared): void => { + if (declared.get(prop)?.important === true && !value.important) { + return; + } + declared.set(prop, value); + }; + for (const decl of rule.declarations) { + declare(decl.prop, { value: decl.value, important: decl.important }); + for (const [side, value] of spacingSides(decl.prop, decl.value)) { + declare(side, { value, important: decl.important }); + } + } + assert(declared.size >= Math.min(rule.declarations.length, 1), 'declaredByProp: none dropped'); + return declared; +} + +/** A declaration's value and whether it is `!important`. */ +type Declared = { readonly value: string; readonly important: boolean }; + // A rule is "selected" (blue, editable) when it carries a matched selector — in // the active state — equal to the active selector (by selector identity: `.a.b` // == `.b.a`, complex by text). This is NOT scoped to the source dropdown: the diff --git a/src/features/style/model/sections.ts b/src/features/style/model/sections.ts index 42221c16..07230905 100644 --- a/src/features/style/model/sections.ts +++ b/src/features/style/model/sections.ts @@ -87,13 +87,12 @@ export const SECTIONS: readonly SectionDefinition[] = [ { id: 'spacing', label: 'Spacing', - // The sides only. The box edits one side at a time, so it has a field for - // each longhand and none for `padding: a b` — and a property listed in a - // section is a property routed to that section's control, which for the - // shorthand meant routed to nothing: it rendered in no field at all while - // still applying on the page. Left out, it falls through to the catch-all - // at the bottom, where every declaration the panel has no control for is - // shown and can be edited as written. + // The sides only: the box has a field for each longhand. The resolved model + // projects `margin` and the padding shorthands onto these sides + // (spacingShorthand.ts), so the box shows them. The shorthand itself is left + // out, as a property listed in a section is routed to that section's control + // and the box has none for it: it falls through to the catch-all at the + // bottom, where it can still be edited and cleared whole, as written. order: [ 'margin-top', 'margin-right', diff --git a/src/features/style/model/spacingShorthand.ts b/src/features/style/model/spacingShorthand.ts new file mode 100644 index 00000000..a2615a8d --- /dev/null +++ b/src/features/style/model/spacingShorthand.ts @@ -0,0 +1,242 @@ +// The box shorthands the spacing box reads, and the padding it writes back. +// The spacing box has one field per side, so `padding: 1rem 2rem` reached no +// field while still applying on the page. Reading projects a shorthand onto the +// sides it sets (resolved.ts); a committed padding edit rewrites the rule's own +// padding in its shortest form, so one drag never leaves four longhands beside +// the shorthand they override. Sides are physical: the box draws a horizontal +// writing mode, as the canvas bands do. + +import postcss, { type AtRule, type Declaration, type Rule } from 'postcss'; +import { assert } from '../../../../shared/core/assert'; +import { splitTopLevelSpaces } from './background'; + +/** A declaration's value and whether it is `!important`. */ +type Declared = { readonly value: string; readonly important: boolean }; + +const SIDES = ['top', 'right', 'bottom', 'left'] as const; +type Side = (typeof SIDES)[number]; + +type Shorthand = { readonly box: 'margin' | 'padding'; readonly sides: readonly Side[] }; + +// Every property projected onto sides. A padding longhand is listed as a +// shorthand of one side, so a rule's padding declarations all fold the same way. +const SHORTHANDS: ReadonlyMap = new Map([ + ['margin', { box: 'margin', sides: SIDES }], + ['padding', { box: 'padding', sides: SIDES }], + ['padding-block', { box: 'padding', sides: ['top', 'bottom'] }], + ['padding-inline', { box: 'padding', sides: ['left', 'right'] }], + ['padding-top', { box: 'padding', sides: ['top'] }], + ['padding-right', { box: 'padding', sides: ['right'] }], + ['padding-bottom', { box: 'padding', sides: ['bottom'] }], + ['padding-left', { box: 'padding', sides: ['left'] }], +]); + +const NO_SIDES: ReadonlyMap = new Map(); + +/** The side longhands `prop: value` sets, by property name (`padding-left` → its + * value). Empty for a property the spacing box does not read, and for a value + * with more parts than the property has sides. `prop` is lowercase. */ +export function spacingSides(prop: string, value: string): ReadonlyMap { + const shorthand = SHORTHANDS.get(prop); + if (shorthand === undefined) { + return NO_SIDES; + } + const parts = splitTopLevelSpaces(value); + const first = parts[0]; + if (first === undefined) { + return NO_SIDES; + } + if (parts.length > shorthand.sides.length) { + return NO_SIDES; + } + // CSS fills a missing part from the opposite side, two parts back in the + // clockwise order, and from the first part when that one is missing too. + const sides = new Map( + shorthand.sides.map((side, index) => [ + `${shorthand.box}-${side}`, + parts[index] ?? parts[index - 2] ?? first, + ]), + ); + assert(sides.size === shorthand.sides.length, 'spacingSides: one value per side'); + return sides; +} + +/** Whether `prop` is one of the padding properties a padding edit rewrites. */ +export function isPaddingProperty(prop: string): boolean { + return SHORTHANDS.get(prop.trim().toLowerCase())?.box === 'padding'; +} + +/** Commit a padding edit on `node`, then rewrite the node's padding in its + * shortest form. The new declaration replaces the node's earlier ones of the + * same property and goes last, so it wins over a shorthand written after them. + * Live previews keep their single sides: cancelling restores each on its own. */ +export function setPaddingDeclaration(node: Rule | AtRule, prop: string, declared: Declared): void { + const key = prop.trim().toLowerCase(); + assert(isPaddingProperty(key), 'setPaddingDeclaration: the property is a padding property'); + for (const declaration of paddingDeclarations(node)) { + if (declaration.prop.toLowerCase() === key) { + declaration.remove(); + } + } + const added = postcss.decl({ prop: key, value: declared.value, important: declared.important }); + // Before any nested rule, as every declaration the panel adds (appendDecl). + const firstNested = node.nodes?.find((child) => child.type === 'rule' || child.type === 'atrule'); + if (firstNested) { + node.insertBefore(firstNested, added); + } else { + node.append(added); + } + node.assign({ raws: { ...node.raws, semicolon: true } }); + assert(paddingDeclarations(node).includes(added), 'setPaddingDeclaration: the edit is written'); + compactPadding(node); +} + +/** Reset the padding sides among `props` on `node`, also when an earlier edit + * grouped one into an axis or the full shorthand: the other sides of that + * declaration keep their value. Returns whether the node changed. */ +export function clearPaddingSides(node: Rule | AtRule, props: readonly string[]): boolean { + const cleared = new Set(props.filter((prop) => SHORTHANDS.get(prop)?.sides.length === 1)); + if (cleared.size === 0) { + return false; + } + let changed = false; + for (const declaration of paddingDeclarations(node)) { + const sides = spacingSides(declaration.prop.toLowerCase(), declaration.value); + const kept = [...sides].filter(([prop]) => !cleared.has(prop)); + if (kept.length === sides.size) { + continue; + } + // A var() may hold several lengths: its sides cannot be told apart. + if (isOpaque(declaration)) { + continue; + } + for (const [prop, value] of kept) { + node.insertBefore(declaration, declaration.clone({ prop, value })); + } + declaration.remove(); + changed = true; + } + if (changed) { + compactPadding(node); + } + return changed; +} + +type SideValue = Declared & { readonly opaque: boolean }; + +// The padding declarations written directly on `node`, never a nested rule's. +function paddingDeclarations(node: Rule | AtRule): Declaration[] { + return (node.nodes ?? []).filter( + (child): child is Declaration => child.type === 'decl' && isPaddingProperty(child.prop), + ); +} + +// A multi-side value with a var() or env() part: the function may substitute +// several lengths, so which side gets which cannot be read from the source. +function isOpaque(declaration: Declaration): boolean { + const shorthand = SHORTHANDS.get(declaration.prop.toLowerCase()); + assert(shorthand !== undefined, 'isOpaque: the declaration is a padding declaration'); + if (shorthand.sides.length === 1) { + return false; + } + return splitTopLevelSpaces(declaration.value).some(isSubstitution); +} + +// A var() or env() standing for a whole part of a value. +const isSubstitution = (part: string): boolean => /^(var|env)\(/i.test(part); + +// What each side resolves to within one node: a later declaration wins, unless +// an earlier one is `!important` and it is not. Undefined when a declaration +// cannot be read side by side, which leaves the node as written. +function paddingSideValues( + declarations: readonly Declaration[], +): ReadonlyMap | undefined { + const values = new Map(); + for (const declaration of declarations) { + const sides = spacingSides(declaration.prop.toLowerCase(), declaration.value); + if (sides.size === 0) { + return undefined; + } + const important = declaration.important; + const opaque = isOpaque(declaration); + for (const [prop, value] of sides) { + const earlier = values.get(prop); + if (earlier?.important === true && !important) { + continue; + } + values.set(prop, { value, important, opaque }); + } + } + return values; +} + +type Compact = Declared & { readonly prop: string }; + +// The fewest declarations that set `values`: `padding` when the four sides +// agree, else an axis property for each pair that agrees and a side otherwise. +// A var() or env() is never grouped: the shorthand it would make is one the +// next edit could not split again (isOpaque), so the result would depend on the +// order of the edits. +function compactForm(values: ReadonlyMap): readonly Compact[] { + const equal = (first: Side, second: Side): boolean => { + const one = values.get(`padding-${first}`); + const other = values.get(`padding-${second}`); + if (one === undefined || other === undefined) { + return false; + } + if (isSubstitution(one.value)) { + return false; + } + return one.value === other.value && one.important === other.important; + }; + const written = (prop: string, side: Side): readonly Compact[] => { + const entry = values.get(`padding-${side}`); + return entry ? [{ prop, value: entry.value, important: entry.important }] : []; + }; + if (SIDES.every((side) => equal('top', side))) { + return written('padding', 'top'); + } + const block = equal('top', 'bottom') + ? written('padding-block', 'top') + : [...written('padding-top', 'top'), ...written('padding-bottom', 'bottom')]; + const inline = equal('left', 'right') + ? written('padding-inline', 'left') + : [...written('padding-right', 'right'), ...written('padding-left', 'left')]; + return [...block, ...inline]; +} + +// Rewrite the node's own padding in its shortest form, where its first padding +// declaration stands, so an edit changes the authored lines in place. Only what +// the node declares is compacted, never a value it inherits from another rule. +// A side still held by an opaque shorthand keeps every declaration as written: +// splitting that value would be a guess. +function compactPadding(node: Rule | AtRule): void { + const declarations = paddingDeclarations(node); + const anchor = declarations[0]; + if (anchor === undefined) { + return; + } + const values = paddingSideValues(declarations); + if (values === undefined) { + return; + } + if ([...values.values()].some((entry) => entry.opaque)) { + return; + } + const compact = compactForm(values); + assert(compact.length > 0, 'compactPadding: a padding declaration sets a side'); + assert(compact.length <= SIDES.length, 'compactPadding: at most one declaration per side'); + for (const entry of compact) { + node.insertBefore(anchor, anchor.clone(entry)); + } + for (const declaration of declarations) { + declaration.remove(); + } + // The form changed, never a side: the node resolves as it did. + const rewritten = paddingSideValues(paddingDeclarations(node)); + assert(rewritten !== undefined, 'compactPadding: the compact form reads side by side'); + assert(rewritten.size === values.size, 'compactPadding: the same sides stay set'); + for (const [prop, entry] of values) { + assert(rewritten.get(prop)?.value === entry.value, `compactPadding: ${prop} keeps its value`); + } +} diff --git a/test/renderer/style/spacingShorthand.test.js b/test/renderer/style/spacingShorthand.test.js new file mode 100644 index 00000000..04effe43 --- /dev/null +++ b/test/renderer/style/spacingShorthand.test.js @@ -0,0 +1,375 @@ +// Goal: a box shorthand shows in the spacing box, and a padding edit leaves the +// rule compact. The box has one field per side, so `padding: 1rem 2rem` filled +// none of them while applying on the page, and dragging one side of it left a +// longhand beside the shorthand it overrode: four drags, five declarations. +// Method: two halves, both on the panel's own modules, bundled as the panel +// loads them. Reading goes through the real parser (parseRegion, collectRules), +// cascade (computeRuleModel) and resolver (resolveStyle), and names what each +// side resolves to, where its edit goes (editableRuleFor) and what a clear +// empties (selectedRulesSetting); the stylesheet is compared before and after, +// as reading must not rewrite it. Writing drives setPaddingDeclaration and +// clearPaddingSides on postcss rules and lists the padding declarations left, +// through the paths that set a property on an existing rule and the two that +// create one (createRuleAtRoot, createRuleInQuery). The negative space is +// covered on both halves: values with too many parts, properties the box does +// not read, a var() that may hold several lengths, and a non-padding property +// handed to the padding writer, whose assertion message is pinned. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const esbuild = require('esbuild'); +const postcss = require('postcss'); +const { ROOT, repoPath } = require('../../helpers/sources.js'); + +const directory = repoPath('node_modules/.stacki-test'); +fs.mkdirSync(directory, { recursive: true }); +const bundle = path.join(directory, 'spacing-shorthand.bundle.js'); +esbuild.buildSync({ + stdin: { + contents: [ + "export { parseRegion } from './src/features/style/model/cssRegions';", + 'export { collectRules, createRuleAtRoot, createRuleInQuery }', + " from './src/features/style/model/css';", + "export { computeRuleModel } from './src/features/style/model/cascade';", + 'export { editableRuleFor, resolveStyle, selectedRulesSetting }', + " from './src/features/style/model/resolved';", + 'export { clearPaddingSides, isPaddingProperty, setPaddingDeclaration, spacingSides }', + " from './src/features/style/model/spacingShorthand';", + ].join('\n'), + resolveDir: ROOT, + loader: 'ts', + }, + outfile: bundle, + bundle: true, + platform: 'node', + format: 'cjs', + logLevel: 'silent', +}); +const model = require(bundle); + +const SIDES = ['top', 'right', 'bottom', 'left']; +const MEDIA = '@media (max-width: 600px)'; + +// The picked selector's resolved style over `css`, in `context`, for an element +// every selector of the sheet matches. Fails if reading changed the stylesheet. +async function resolve(css, activeSelector = '.card', context = '') { + const region = model.parseRegion({ start: 0, end: css.length, css, root: undefined }); + const rules = model.collectRules(region, { + embedKey: 'file:/site.css', + embedLabel: 'site.css', + fromComponent: false, + componentName: undefined, + regionIndex: 0, + idSeed: 'site', + order: { n: 0 }, + }); + const selectors = rules.flatMap((rule) => rule.selectors.map((selector) => selector.text)); + const domMatched = new Map(selectors.map((text) => [text, true])); + const ruleModel = await model.computeRuleModel(rules, { + rootKey: 'root', + view: undefined, + domMatched, + }); + const resolved = model.resolveStyle(ruleModel, context, activeSelector); + assert.equal(region.root.toString(), css, 'reading spacing does not rewrite the CSS'); + return resolved; +} + +// What the four sides of `box` resolve to, clockwise from the top. +const sidesOf = (resolved, box = 'padding') => + SIDES.map((side) => resolved.props.get(`${box}-${side}`)?.winner.value); + +// The padding declarations written directly on `rule`, in source order. +const paddingOf = (rule) => + rule.nodes + .filter((node) => node.type === 'decl' && node.prop.startsWith('padding')) + .map((declaration) => [declaration.prop, declaration.value, declaration.important === true]); + +// `.card { source }` after each `[prop, value, important]` edit, in order. +function edited(source, edits) { + const rule = postcss.parse(`.card { ${source} }`).first; + for (const [prop, value, important = false] of edits) { + model.setPaddingDeclaration(rule, prop, { value, important }); + } + return rule; +} + +const eachSide = (values) => SIDES.map((side, index) => [`padding-${side}`, values[index]]); + +// Four ungrouped sides holding `value`, in the order the compact form writes them. +const eachSideOrdered = (value) => + ['top', 'bottom', 'right', 'left'].map((side) => [`padding-${side}`, value, false]); + +test('a shorthand of one to four values fills the four sides, as CSS expands it', async () => { + const cases = [ + ['1rem', ['1rem', '1rem', '1rem', '1rem']], + ['1px 2px', ['1px', '2px', '1px', '2px']], + ['1px 2px 3px', ['1px', '2px', '3px', '2px']], + ['1px 2px 3px 4px', ['1px', '2px', '3px', '4px']], + [ + 'var(--space-3, 6px) calc(1rem + 2px)', + ['var(--space-3, 6px)', 'calc(1rem + 2px)', 'var(--space-3, 6px)', 'calc(1rem + 2px)'], + ], + ]; + for (const [value, expected] of cases) { + assert.deepEqual(sidesOf(await resolve(`.card { padding: ${value} }`)), expected, value); + } + const margin = await resolve('.card { margin: 0 auto }'); + assert.deepEqual(sidesOf(margin, 'margin'), ['0', 'auto', '0', 'auto']); +}); + +test('an axis shorthand fills its two sides and leaves the other axis unset', async () => { + const inline = await resolve('.card { padding-inline: 1rem 2rem }'); + assert.deepEqual(sidesOf(inline), [undefined, '2rem', undefined, '1rem']); + const block = await resolve('.card { padding-block: 3rem }'); + assert.deepEqual(sidesOf(block), ['3rem', undefined, '3rem', undefined]); +}); + +test('each projected side reads as set by the picked selector, in its own sheet', async () => { + const resolved = await resolve('.card { padding: var(--space-3) }'); + assert.equal(resolved.props.get('padding').selectedValue.value, 'var(--space-3)'); + for (const side of SIDES) { + const shown = resolved.props.get(`padding-${side}`); + assert.equal(shown.source, 'selected', side); + assert.equal(shown.winner.embedKey, 'file:/site.css', side); + assert.equal(shown.overridden, false, side); + } +}); + +test('within a rule the later declaration wins, unless an earlier one is important', async () => { + const after = await resolve('.card { padding: 1rem; padding-left: 2rem }'); + assert.deepEqual(sidesOf(after), ['1rem', '1rem', '1rem', '2rem']); + const before = await resolve('.card { padding-left: 2rem; padding: 1rem }'); + assert.deepEqual(sidesOf(before), ['1rem', '1rem', '1rem', '1rem']); + const shorthand = await resolve('.card { padding: 1rem !important; padding-left: 2rem }'); + assert.deepEqual(sidesOf(shorthand), ['1rem', '1rem', '1rem', '1rem']); + assert.equal(shorthand.props.get('padding-left').winner.important, true); + const longhand = await resolve('.card { padding-left: 2rem !important; padding: 1rem }'); + assert.deepEqual(sidesOf(longhand), ['1rem', '1rem', '1rem', '2rem']); + const twice = await resolve('.card { padding: 1rem !important; padding: 2rem !important }'); + assert.deepEqual(sidesOf(twice), ['2rem', '2rem', '2rem', '2rem']); +}); + +test('a projected side takes part in the cascade across rules and queries', async () => { + const overridden = await resolve('.card { padding: 1rem } .card.special { padding-left: 4rem }'); + const left = overridden.props.get('padding-left'); + assert.equal(left.winner.value, '4rem'); + assert.equal(left.selectedValue.value, '1rem'); + assert.equal(left.overridden, true); + + const elsewhere = await resolve('.card { padding: 1rem }', '.other'); + assert.equal(elsewhere.props.get('padding-top').source, 'other'); + + const css = `.card { padding: 1rem } ${MEDIA} { .card { padding-left: 2rem } }`; + const narrow = await resolve(css, '.card', MEDIA); + assert.equal(narrow.props.get('padding-top').source, 'other', 'inherited from the base'); + assert.equal(narrow.props.get('padding-left').source, 'selected'); + assert.equal(narrow.props.get('padding-left').winner.value, '2rem'); +}); + +test('a side set through a shorthand is edited and cleared in the shorthand’s rule', async () => { + const css = '.card { padding: 1rem }\n.card { color: red }'; + const resolved = await resolve(css); + const rules = [...resolved.selectedRules.values()]; + assert.equal(rules.indexOf(model.editableRuleFor(resolved, 'padding-left')), 0); + const cleared = model.selectedRulesSetting(resolved, 'padding-left'); + assert.deepEqual( + cleared.map((rule) => rules.indexOf(rule)), + [0], + ); + // A side set nowhere still goes to the last rule, where a new value wins a tie. + assert.equal(rules.indexOf(model.editableRuleFor(resolved, 'margin-left')), 1); +}); + +test('values the box cannot read side by side set no side', async () => { + for (const css of [ + '.card { padding: 1px 2px 3px 4px 5px }', + '.card { padding-inline: 1px 2px 3px }', + ]) { + assert.deepEqual( + sidesOf(await resolve(css)), + [undefined, undefined, undefined, undefined], + css, + ); + } + assert.equal(model.spacingSides('padding', '').size, 0, 'an empty value'); + assert.equal(model.spacingSides('padding-top', '1px 2px').size, 0, 'two parts on one side'); + assert.equal(model.spacingSides('margin-inline', '1px').size, 0, 'a property the box skips'); + assert.equal(model.spacingSides('border-width', '1px 2px').size, 0, 'another box shorthand'); + assert.deepEqual( + [...model.spacingSides('padding-block', '1rem 3rem')], + [ + ['padding-top', '1rem'], + ['padding-bottom', '3rem'], + ], + ); +}); + +test('a padding edit rewrites the rule’s padding in its shortest form', () => { + const cases = [ + [['3rem', '3rem', '3rem', '3rem'], [['padding', '3rem', false]]], + [ + ['1rem', '2rem', '1rem', '2rem'], + [ + ['padding-block', '1rem', false], + ['padding-inline', '2rem', false], + ], + ], + [ + ['1rem', '2rem', '3rem', '2rem'], + [ + ['padding-top', '1rem', false], + ['padding-bottom', '3rem', false], + ['padding-inline', '2rem', false], + ], + ], + [ + ['1rem', '2rem', '1rem', '4rem'], + [ + ['padding-block', '1rem', false], + ['padding-right', '2rem', false], + ['padding-left', '4rem', false], + ], + ], + [ + ['1rem', '2rem', '3rem', '4rem'], + [ + ['padding-top', '1rem', false], + ['padding-bottom', '3rem', false], + ['padding-right', '2rem', false], + ['padding-left', '4rem', false], + ], + ], + ]; + for (const [values, expected] of cases) { + const rule = edited('color: red; padding: 2rem;', eachSide(values)); + assert.deepEqual(paddingOf(rule), expected, values.join(' ')); + } +}); + +test('one side dragged off a shorthand keeps the three others, and no more', () => { + assert.deepEqual(paddingOf(edited('padding: 2rem;', [['padding-left', '3rem']])), [ + ['padding-block', '2rem', false], + ['padding-right', '2rem', false], + ['padding-left', '3rem', false], + ]); + // Only what the rule declares is compacted: no side is invented from elsewhere. + assert.deepEqual(paddingOf(edited('', [['padding-left', '2rem']])), [ + ['padding-left', '2rem', false], + ]); + // A longhand written before the shorthand lost to it; the edit must win. + const reordered = edited('padding-top: 9rem; padding: 2rem;', [['padding-top', '1rem']]); + assert.deepEqual(paddingOf(reordered), [ + ['padding-top', '1rem', false], + ['padding-bottom', '2rem', false], + ['padding-inline', '2rem', false], + ]); + // Editing the shorthand itself replaces the sides that overrode it. + const whole = edited('padding: 2rem; padding-top: 9rem;', [['padding', '3rem']]); + assert.deepEqual(paddingOf(whole), [['padding', '3rem', false]]); +}); + +test('importance is part of a side’s value when sides are grouped', () => { + const all = edited( + 'padding: 2rem !important;', + SIDES.map((side) => [`padding-${side}`, '3rem', true]), + ); + assert.deepEqual(paddingOf(all), [['padding', '3rem', true]]); + const mixed = edited('padding: 2rem;', [['padding-top', '2rem', true]]); + assert.deepEqual(paddingOf(mixed), [ + ['padding-top', '2rem', true], + ['padding-bottom', '2rem', false], + ['padding-inline', '2rem', false], + ]); + // An unimportant edit does not win over an important shorthand: nothing to regroup. + const losing = edited('padding: 2rem !important;', [['padding-top', '3rem']]); + assert.deepEqual(paddingOf(losing), [['padding', '2rem', true]]); +}); + +test('a var() that may hold several lengths is kept whole until every side is set', () => { + const partial = edited('padding: var(--spacing);', [['padding-top', '3rem']]); + assert.deepEqual(paddingOf(partial), [ + ['padding', 'var(--spacing)', false], + ['padding-top', '3rem', false], + ]); + const full = edited('padding: var(--spacing);', eachSide(['3rem', '3rem', '3rem', '3rem'])); + assert.deepEqual(paddingOf(full), [['padding', '3rem', false]]); + // A variable is never grouped: the shorthand would be one the next edit could + // not split, and the rule would depend on the order the sides were set in. + const tokens = edited('padding: 2rem;', eachSide(Array(4).fill('var(--space-3)'))); + assert.deepEqual(paddingOf(tokens), eachSideOrdered('var(--space-3)')); + const reversed = edited('padding: 2rem;', eachSide(Array(4).fill('var(--space-3)')).reverse()); + assert.deepEqual(paddingOf(reversed), paddingOf(tokens), 'in any order'); + // A value with too many parts is left exactly as written. + const unread = edited('padding: 1px 2px 3px 4px 5px;', [['padding-top', '3rem']]); + assert.deepEqual(paddingOf(unread), [ + ['padding', '1px 2px 3px 4px 5px', false], + ['padding-top', '3rem', false], + ]); +}); + +test('compacting touches nothing but the rule’s own padding', () => { + const source = 'color: red; padding: 2rem; /* keep */ & .child { padding: 7rem; }'; + const rule = edited(source, eachSide(['3rem', '3rem', '3rem', '3rem'])); + assert.equal( + rule.toString(), + '.card { color: red; padding: 3rem; /* keep */ & .child { padding: 7rem; } }', + ); +}); + +test('clearing a side splits the shorthand that grouped it', () => { + const rule = edited('padding: 2rem !important;', []); + assert.equal(model.clearPaddingSides(rule, ['padding-top']), true); + assert.deepEqual(paddingOf(rule), [ + ['padding-bottom', '2rem', true], + ['padding-inline', '2rem', true], + ]); + assert.equal( + model.clearPaddingSides(rule, ['padding-left', 'padding-right', 'padding-bottom']), + true, + ); + assert.deepEqual(paddingOf(rule), []); + assert.equal(model.clearPaddingSides(rule, ['padding-top']), false, 'nothing left to clear'); +}); + +test('a clear leaves alone what it cannot or need not split', () => { + const opaque = edited('padding: var(--spacing);', []); + assert.equal(model.clearPaddingSides(opaque, ['padding-top']), false); + assert.deepEqual(paddingOf(opaque), [['padding', 'var(--spacing)', false]]); + const other = edited('padding-inline: 2rem;', []); + assert.equal(model.clearPaddingSides(other, ['padding-top']), false, 'a side not set here'); + assert.equal(model.clearPaddingSides(other, ['color', 'margin-top', 'padding']), false); + assert.deepEqual(paddingOf(other), [['padding-inline', '2rem', false]]); +}); + +test('a new rule, at the root or in a query, is compact from its first sides', () => { + for (const context of ['', MEDIA]) { + const region = model.parseRegion({ start: 0, end: 0, css: '', root: undefined }); + for (const side of SIDES) { + const declared = { value: '3rem', important: false }; + if (context === '') { + model.createRuleAtRoot(region, '.card', `padding-${side}`, declared); + } else { + model.createRuleInQuery(region, context, '.card', `padding-${side}`, declared); + } + } + const rule = context === '' ? region.root.first : region.root.first.first; + assert.deepEqual(paddingOf(rule), [['padding', '3rem', false]], context || 'root'); + } +}); + +test('the padding writer refuses a property that is not padding', () => { + for (const prop of ['padding', 'PADDING-INLINE', ' padding-top ']) { + assert.equal(model.isPaddingProperty(prop), true, prop); + } + for (const prop of ['margin', 'margin-top', 'padding-inline-start', 'padding-box', '']) { + assert.equal(model.isPaddingProperty(prop), false, prop); + } + const rule = postcss.parse('.card { margin: 0 }').first; + assert.throws( + () => model.setPaddingDeclaration(rule, 'margin', { value: '1rem', important: false }), + { message: 'Assertion failed: setPaddingDeclaration: the property is a padding property' }, + ); + assert.equal(rule.toString(), '.card { margin: 0 }', 'and writes nothing'); +}); diff --git a/test/renderer/style/spacingShorthandPanel.test.js b/test/renderer/style/spacingShorthandPanel.test.js new file mode 100644 index 00000000..d0c3ecff --- /dev/null +++ b/test/renderer/style/spacingShorthandPanel.test.js @@ -0,0 +1,213 @@ +// Goal: the spacing box, as mounted in the panel, shows a `padding` shorthand +// on its four sides and writes drags back compact. The model half is pinned in +// spacingShorthand.test.js; this suite checks that the panel is wired to it: a +// shorthand that reaches the labels, and a commit that goes through the padding +// writer instead of appending a longhand beside the shorthand. +// Method: the real EmbedEditor in jsdom over a stylesheet bridge whose +// `writeStyleFile` keeps the CSS as the disk would. One `.card` rule holds +// `padding: 2rem` next to a declaration that must survive. The class chip is +// picked, then the side labels are dragged as a user drags them: Shift moves +// the four sides, Alt the opposite pair, no key one side. After each gesture +// the written CSS is parsed and its padding declarations listed, and the four +// labels are read back, since a value the panel wrote but no longer shows is +// the defect this replaces. An Alt-click, the reset gesture, ends on a side +// that was grouped into an axis. Waits poll for the condition they need. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const esbuild = require('esbuild'); +const postcss = require('postcss'); +const { JSDOM } = require('jsdom'); +const { ROOT, repoPath } = require('../../helpers/sources.js'); + +const SIDES = ['top', 'right', 'bottom', 'left']; +const POLL_MS = 25; +const POLLS_MAX = 200; +const SHEET = { + rel: 'src/styles/main.css', + name: 'main.css', + path: '/p/src/styles/main.css', + size: 10, +}; + +// The jsdom window the panel runs in, with the globals React and the panel read. +function openWindow() { + const dom = new JSDOM('
', { pretendToBeVisual: true }); + const { window } = dom; + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + window.Element.prototype.setPointerCapture = () => {}; + window.Element.prototype.releasePointerCapture = () => {}; + global.window = window; + global.document = window.document; + global.navigator = window.navigator; + global.IS_REACT_ACT_ENVIRONMENT = false; + for (const key of ['ResizeObserver', 'MutationObserver', 'Element', 'HTMLElement', 'Node']) { + global[key] = window[key]; + } + for (const key of ['getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame']) { + global[key] = window[key].bind(window); + } + return window; +} + +// The panel and its host setter, bundled as the app loads them. +function loadPanel() { + const directory = repoPath('node_modules/.stacki-test'); + fs.mkdirSync(directory, { recursive: true }); + const bundle = path.join(directory, 'spacing-shorthand-panel.bundle.js'); + esbuild.buildSync({ + stdin: { + contents: [ + "export { default as EmbedEditor } from './src/features/style/EmbedEditor';", + "export { setHost } from './src/features/style/model/host';", + ].join('\n'), + resolveDir: ROOT, + loader: 'tsx', + }, + outfile: bundle, + bundle: true, + format: 'cjs', + platform: 'node', + jsx: 'automatic', + external: ['react', 'react-dom', 'react/jsx-runtime', 'react-dom/client'], + loader: { '.css': 'empty' }, + logLevel: 'silent', + }); + return require(bundle); +} + +test('the spacing box reads a padding shorthand and writes drags back compact', async () => { + const window = openWindow(); + const { EmbedEditor, setHost } = loadPanel(); + const React = require('react'); + const { createRoot } = require('react-dom/client'); + + let disk = '.card { color: red; padding: 2rem; }'; + window.avb = { + listStyleFiles: async () => ({ files: [SHEET] }), + listAstroStyleFiles: async () => ({ files: [] }), + listAssets: async () => ({ entries: [] }), + readStyleFile: async () => ({ css: disk }), + writeStyleFile: async ({ css }) => { + disk = css; + return { ok: true }; + }, + }; + const card = { type: 'string', value: 'card' }; + setHost({ + projectPath: '/p', + nodes: [{ id: 'n1', kind: 'element', name: 'div', props: { class: card } }], + selectedId: 'n1', + files: [SHEET], + astroFiles: [], + renderedClasses: [], + pathOf: () => '0.1', + }); + const panel = window.document.getElementById('root'); + const root = createRoot(panel); + root.render(React.createElement(EmbedEditor)); + + // Unmounted on failure too: a mounted panel keeps the process alive. + try { + const wait = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + // Poll until `read` returns something, failing with `what` at the bound. + const until = async (what, read) => { + for (let tries = 0; tries < POLLS_MAX; tries += 1) { + const found = read(); + if (found) { + return found; + } + await wait(POLL_MS); + } + return assert.fail(`timed out waiting for ${what}\n${disk}`); + }; + const label = (side) => panel.querySelector(`button[data-prop="padding-${side}"]`); + const shown = () => SIDES.map((side) => label(side)?.textContent); + const padding = () => + postcss + .parse(disk) + .first.nodes.filter((node) => node.type === 'decl' && node.prop.startsWith('padding')) + .map((declaration) => [declaration.prop, declaration.value]); + const send = async (side, type, init) => { + label(side).dispatchEvent(new window.MouseEvent(type, { bubbles: true, ...init })); + await wait(POLL_MS); + }; + // Press `side`, move by (dx, dy) with `modifiers` held, release, and wait for + // the write: the padding on disk differs from what it was. + const drag = async (side, [dx, dy], modifiers = {}) => { + const before = JSON.stringify(padding()); + await until(`the ${side} label to be ready`, () => label(side) && !label(side).disabled); + await send(side, 'pointerdown', { clientX: 100, clientY: 100, ...modifiers }); + await send(side, 'pointermove', { clientX: 100 + dx, clientY: 100 + dy, ...modifiers }); + await send(side, 'pointerup', { clientX: 100 + dx, clientY: 100 + dy, ...modifiers }); + await until(`the ${side} drag to be written`, () => JSON.stringify(padding()) !== before); + await until('the panel to settle', () => SIDES.every((each) => !label(each)?.disabled)); + }; + + const chip = await until('the class chip', () => + panel.querySelector('.embed-editor_selector-chip'), + ); + chip.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await until('the shorthand on the four sides', () => + shown().every((text) => text === '2rem') ? shown() : undefined, + ); + assert.equal(disk, '.card { color: red; padding: 2rem; }', 'showing it writes nothing'); + + // Shift: four sides move together, and the rule still holds one declaration. + await drag('top', [0, -18], { shiftKey: true }); + const [[allProp, all]] = padding(); + assert.deepEqual([padding().length, allProp], [1, 'padding'], disk); + assert.notEqual(all, '2rem', 'the drag changed the value'); + await until('the four labels to follow', () => shown().every((text) => text === all)); + + // Alt: the opposite pair moves, so each axis groups on its own. + await drag('left', [-16, 0], { altKey: true }); + assert.deepEqual( + padding().map(([prop]) => prop), + ['padding-block', 'padding-inline'], + disk, + ); + const [[, block], [, inline]] = padding(); + assert.equal(block, all, 'the block axis kept its value'); + assert.notEqual(inline, all); + await until( + 'the labels to follow each axis', + () => JSON.stringify(shown()) === JSON.stringify([block, inline, block, inline]), + ); + + // No key: one side leaves its axis, the other axis stays grouped. + await drag('top', [0, -16]); + assert.deepEqual( + padding().map(([prop]) => prop), + ['padding-top', 'padding-bottom', 'padding-inline'], + disk, + ); + assert.notEqual(padding()[0][1], block); + assert.equal(padding()[1][1], block, 'the bottom kept the axis value'); + + // Alt-click resets a side that has no declaration of its own any more. + const before = JSON.stringify(padding()); + for (const type of ['pointerdown', 'pointerup', 'click']) { + await send('left', type, { clientX: 100, clientY: 100, altKey: true }); + } + await until('the reset to be written', () => JSON.stringify(padding()) !== before); + assert.deepEqual( + padding().map(([prop]) => prop), + ['padding-top', 'padding-bottom', 'padding-right'], + disk, + ); + assert.equal(padding()[2][1], inline, 'the right side kept the axis value'); + await until('the left label to read as unset', () => label('left')?.textContent === '0'); + + const color = postcss.parse(disk).first.nodes.find((node) => node.prop === 'color'); + assert.equal(color?.value, 'red', 'the rule’s other declaration is untouched'); + } finally { + root.unmount(); + window.close(); + } +});