Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/features/style/embed/useEditorRuleEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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());
Expand Down
9 changes: 9 additions & 0 deletions src/features/style/model/cssRegions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 28 additions & 6 deletions src/features/style/model/resolved.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { value: string; important: boolean }>();
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,
Expand All @@ -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<string, Declared> {
const declared = new Map<string, Declared>();
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
Expand Down
13 changes: 6 additions & 7 deletions src/features/style/model/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
242 changes: 242 additions & 0 deletions src/features/style/model/spacingShorthand.ts
Original file line number Diff line number Diff line change
@@ -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<string, Shorthand> = new Map<string, Shorthand>([
['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<string, string> = new Map<string, string>();

/** 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<string, string> {
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<string, SideValue> | undefined {
const values = new Map<string, SideValue>();
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<string, SideValue>): 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`);
}
}
Loading
Loading