inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deferred-layout.ts — Post-render deferred layout state machine
|
|
3
|
+
*
|
|
4
|
+
* Figma nodes are created and styled before they are appended to their parent,
|
|
5
|
+
* so many sizing/alignment decisions cannot be made at creation time. This module
|
|
6
|
+
* owns the full lifecycle of those deferred decisions:
|
|
7
|
+
*
|
|
8
|
+
* 1. mark* — called during node creation to record intent
|
|
9
|
+
* 2. apply* — called after a node is appended to its parent to resolve intent
|
|
10
|
+
* 3. reflow* — called after the full tree is built to fix up any remaining drift
|
|
11
|
+
*
|
|
12
|
+
* All state lives in module-level WeakSets/WeakMaps keyed on SceneNode, so nodes
|
|
13
|
+
* are automatically garbage-collected and there is no cleanup burden on callers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
parseUtilityClass,
|
|
18
|
+
hasResponsiveVariant as hasResponsiveVariantSemantic,
|
|
19
|
+
variantState as variantStateSemantic,
|
|
20
|
+
} from '../tailwind';
|
|
21
|
+
import type { RingInfo } from './ring-utils';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// State stores
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const ABSOLUTE_NODES = new WeakSet<SceneNode>();
|
|
28
|
+
const CSS_GRID_VERTICAL_FRAMES = new WeakSet<SceneNode>(); // grid without grid-cols-N → single-column, children stretch
|
|
29
|
+
const FULL_WIDTH_NODES = new WeakSet<SceneNode>();
|
|
30
|
+
const FULL_HEIGHT_NODES = new WeakSet<SceneNode>();
|
|
31
|
+
const FIXED_HEIGHT_NODES = new WeakSet<SceneNode>();
|
|
32
|
+
const FRACTION_WIDTH_NODES = new WeakMap<SceneNode, number>();
|
|
33
|
+
const FIXED_WIDTH_NODES = new WeakSet<SceneNode>();
|
|
34
|
+
const SELF_ALIGNMENT_NODES = new WeakMap<SceneNode, 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'>();
|
|
35
|
+
const FLEX_BASIS_NODES = new WeakMap<SceneNode, number>();
|
|
36
|
+
const MIN_WIDTH_NODES = new WeakMap<SceneNode, number>();
|
|
37
|
+
const GRID_COLUMNS_NODES = new WeakMap<SceneNode, number>();
|
|
38
|
+
const COL_SPAN_NODES = new WeakMap<SceneNode, number>(); // child → number of columns spanned
|
|
39
|
+
type PositionInfo = {
|
|
40
|
+
top?: number;
|
|
41
|
+
bottom?: number;
|
|
42
|
+
left?: number;
|
|
43
|
+
right?: number;
|
|
44
|
+
// Percentage-based positions (fractions in [0, 1]). Resolved against the
|
|
45
|
+
// parent's final dimensions in `applyDeferredPercentPositioning`. Stored
|
|
46
|
+
// separately because they can't be evaluated until the parent's width and
|
|
47
|
+
// height are settled (after `applyAspectRatioIfPossible` and friends).
|
|
48
|
+
topPercent?: number;
|
|
49
|
+
bottomPercent?: number;
|
|
50
|
+
leftPercent?: number;
|
|
51
|
+
rightPercent?: number;
|
|
52
|
+
// Self-translate fractions of the child's own width/height — `translate-x-1/2`
|
|
53
|
+
// = 0.5, `-translate-x-1/2` = -0.5, `-translate-x-full` = -1. Applied
|
|
54
|
+
// alongside percent positions to mirror CSS `transform: translate(N%, N%)`
|
|
55
|
+
// on absolute elements. Without this, an icon meant to be centered on
|
|
56
|
+
// `(left-50%, top-50%)` via `-translate-x-1/2 -translate-y-1/2` ends up
|
|
57
|
+
// anchored top-left at the percent point instead.
|
|
58
|
+
translateXFraction?: number;
|
|
59
|
+
translateYFraction?: number;
|
|
60
|
+
hintParentHeight?: number;
|
|
61
|
+
inset?: number;
|
|
62
|
+
};
|
|
63
|
+
const POSITION_INFO_NODES = new WeakMap<SceneNode, PositionInfo>();
|
|
64
|
+
const DEFERRED_BOTTOM_NODES = new WeakMap<SceneNode, number>(); // child → bottom pixel offset
|
|
65
|
+
const DEFERRED_RIGHT_NODES = new WeakMap<SceneNode, number>(); // child → right pixel offset
|
|
66
|
+
const DEFERRED_TOP_RELATIVE_NODES = new WeakMap<SceneNode, number>(); // child → (parentHeight - N) top offset
|
|
67
|
+
const MAX_WIDTH_NODES = new WeakMap<SceneNode, number>(); // child → max-width constraint from max-w-* classes
|
|
68
|
+
const DEFERRED_CENTER_Y_NODES = new WeakSet<SceneNode>(); // absolute children needing cross-axis centering
|
|
69
|
+
const ASPECT_RATIO_NODES = new WeakMap<SceneNode, number>(); // node → width/height ratio (e.g. 5/3 for aspect-5/3)
|
|
70
|
+
const BORDER_WIDTH_CLASSES = new WeakMap<SceneNode, string[]>();
|
|
71
|
+
// Ring overlays (Tailwind `ring-*`). Eagerly creating the overlay during
|
|
72
|
+
// frame construction inflates Hug-sized parents during the brief flex-child
|
|
73
|
+
// moment between `appendChild` and `layoutPositioning = 'ABSOLUTE'`. Marking
|
|
74
|
+
// at parse time and applying at post-pass time (when the host has all its
|
|
75
|
+
// content children and we can lock its sizing modes) makes the inflation
|
|
76
|
+
// class impossible. See `applyRingIfPossible` below.
|
|
77
|
+
const RING_NODES = new WeakMap<SceneNode, RingInfo>();
|
|
78
|
+
// SVG icon wraps whose inner SVG vectors must be rescaled when the wrap
|
|
79
|
+
// itself resizes (e.g. an inline `<svg className="absolute inset-0 h-full
|
|
80
|
+
// w-full">` overlay that originally got a 24×24 default size from
|
|
81
|
+
// `resolveIconSizeFromClasses`). The callback is registered by the
|
|
82
|
+
// icon-rendering layer with a closure over `resizeSvgNodeTo`, so this
|
|
83
|
+
// module doesn't depend on `effects/icon-builder`.
|
|
84
|
+
const SVG_CONTENT_RESIZE_HOOKS = new WeakMap<SceneNode, (width: number, height: number) => void>();
|
|
85
|
+
|
|
86
|
+
export function markSvgContentWrap(
|
|
87
|
+
wrap: SceneNode,
|
|
88
|
+
resize: (width: number, height: number) => void
|
|
89
|
+
): void {
|
|
90
|
+
SVG_CONTENT_RESIZE_HOOKS.set(wrap, resize);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resizeSvgContentIfMarked(wrap: SceneNode, width: number, height: number): void {
|
|
94
|
+
const hook = SVG_CONTENT_RESIZE_HOOKS.get(wrap);
|
|
95
|
+
if (!hook) return;
|
|
96
|
+
try {
|
|
97
|
+
hook(width, height);
|
|
98
|
+
} catch (_e) {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isSvgContentWrap(node: SceneNode): boolean {
|
|
104
|
+
return SVG_CONTENT_RESIZE_HOOKS.has(node);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Mark functions — record intent at node-creation time
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export function markAbsoluteNode(node: SceneNode): void {
|
|
112
|
+
ABSOLUTE_NODES.add(node);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mark a node as needing a Tailwind `ring-*` overlay. The actual overlay
|
|
117
|
+
* frame is created later by `applyRingIfPossible` once the host's content
|
|
118
|
+
* children are all in place — that's the only moment when we can lock the
|
|
119
|
+
* host's sizing modes briefly without truncating its natural size.
|
|
120
|
+
*
|
|
121
|
+
* The mark is per-node and idempotent — repeated calls overwrite the prior
|
|
122
|
+
* `RingInfo`, which matches how Tailwind's class cascade works (the last
|
|
123
|
+
* `ring-*` utility in the class list wins).
|
|
124
|
+
*/
|
|
125
|
+
export function markRingNode(node: SceneNode, ring: RingInfo): void {
|
|
126
|
+
RING_NODES.set(node, ring);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function hasRingNode(node: SceneNode): boolean {
|
|
130
|
+
return RING_NODES.has(node);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the ring mark off a node. Used to bridge node-identity transitions
|
|
135
|
+
* — e.g. `figma.createComponentFromNode(sourceNode)` creates a new node
|
|
136
|
+
* identity, so the WeakMap entry keyed on `sourceNode` becomes unreachable
|
|
137
|
+
* from the resulting component. Callers read the mark with `getRingNode`
|
|
138
|
+
* before the transition and re-set it on the new node with `markRingNode`.
|
|
139
|
+
*/
|
|
140
|
+
export function getRingNode(node: SceneNode): RingInfo | undefined {
|
|
141
|
+
return RING_NODES.get(node);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Lock a frame's aspect ratio (`aspect-5/3`, `aspect-square`, `aspect-[3/4]`).
|
|
146
|
+
* The ratio is `width / height`. Resolved by `applyAspectRatioIfPossible`
|
|
147
|
+
* once the frame's width is settled (after `w-full` / `max-w-*` resolve).
|
|
148
|
+
*/
|
|
149
|
+
export function markAspectRatio(node: SceneNode, ratio: number): void {
|
|
150
|
+
if (!Number.isFinite(ratio) || ratio <= 0) return;
|
|
151
|
+
ASPECT_RATIO_NODES.set(node, ratio);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function hasAspectRatio(node: SceneNode): boolean {
|
|
155
|
+
return ASPECT_RATIO_NODES.has(node);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function markPositionInfo(node: SceneNode, info: PositionInfo): void {
|
|
159
|
+
const existing = POSITION_INFO_NODES.get(node) || {};
|
|
160
|
+
POSITION_INFO_NODES.set(node, { ...existing, ...info });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function markFullWidthNode(node: SceneNode): void {
|
|
164
|
+
FULL_WIDTH_NODES.add(node);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function markFullHeightNode(node: SceneNode): void {
|
|
168
|
+
FULL_HEIGHT_NODES.add(node);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function markFixedHeightNode(node: SceneNode): void {
|
|
172
|
+
FIXED_HEIGHT_NODES.add(node);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function markFixedWidthNode(node: SceneNode): void {
|
|
176
|
+
FIXED_WIDTH_NODES.add(node);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function markSelfAlignmentNode(node: SceneNode, align: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH'): void {
|
|
180
|
+
SELF_ALIGNMENT_NODES.set(node, align);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function markFlexBasisNode(node: SceneNode, basis: number): void {
|
|
184
|
+
if (!Number.isFinite(basis)) return;
|
|
185
|
+
FLEX_BASIS_NODES.set(node, basis);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function markMinWidthNode(node: SceneNode, minWidth: number): void {
|
|
189
|
+
if (!Number.isFinite(minWidth)) return;
|
|
190
|
+
MIN_WIDTH_NODES.set(node, minWidth);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function markMaxWidthNode(node: SceneNode, maxWidth: number): void {
|
|
194
|
+
if (!Number.isFinite(maxWidth)) return;
|
|
195
|
+
MAX_WIDTH_NODES.set(node, maxWidth);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function markGridColumnsNode(node: SceneNode, cols: number): void {
|
|
199
|
+
if (!Number.isFinite(cols) || cols <= 0) return;
|
|
200
|
+
GRID_COLUMNS_NODES.set(node, cols);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function markColSpanNode(node: SceneNode, span: number): void {
|
|
204
|
+
if (!Number.isFinite(span) || span <= 0) return;
|
|
205
|
+
COL_SPAN_NODES.set(node, span);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function getColSpanNode(node: SceneNode): number {
|
|
209
|
+
return COL_SPAN_NODES.get(node) ?? 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function markFractionWidthNode(node: SceneNode, fraction: number): void {
|
|
213
|
+
if (!Number.isFinite(fraction) || fraction <= 0) return;
|
|
214
|
+
FRACTION_WIDTH_NODES.set(node, fraction);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Re-exported so tailwind.ts can mark CSS_GRID_VERTICAL_FRAMES (which stays private here)
|
|
218
|
+
export function markCssGridVerticalFrame(node: SceneNode): void {
|
|
219
|
+
CSS_GRID_VERTICAL_FRAMES.add(node);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// State variant helpers (used by applyBorderWidthUtilities)
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
const STATE_VARIANTS = new Set([
|
|
227
|
+
'hover',
|
|
228
|
+
'focus',
|
|
229
|
+
'focus-visible',
|
|
230
|
+
'disabled',
|
|
231
|
+
'active',
|
|
232
|
+
'aria-invalid',
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
function isStateVariant(variant: string): boolean {
|
|
236
|
+
if (STATE_VARIANTS.has(variant)) return true;
|
|
237
|
+
return variant.startsWith('data-[state=');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function shouldApplyAtom(atom: ReturnType<typeof parseUtilityClass>, state: string): boolean {
|
|
241
|
+
if (hasResponsiveVariantSemantic(atom.variants)) return false;
|
|
242
|
+
if (state === 'default') return atom.variants.length === 0;
|
|
243
|
+
if (!atom.variants.length) return false;
|
|
244
|
+
if (!atom.variants.every(isStateVariant)) return false;
|
|
245
|
+
return variantStateSemantic(atom.variants) === state;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Fraction helpers
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function parseFractionToken(token: string): number | null {
|
|
253
|
+
if (!token.includes('/')) return null;
|
|
254
|
+
const [rawNum, rawDen] = token.split('/');
|
|
255
|
+
const num = Number(rawNum);
|
|
256
|
+
const den = Number(rawDen);
|
|
257
|
+
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null;
|
|
258
|
+
return num / den;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Border width utilities
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
function setBorderSideWeights(frame: FrameNode, sides: { top: number; right: number; bottom: number; left: number }): void {
|
|
267
|
+
frame.strokeTopWeight = sides.top;
|
|
268
|
+
frame.strokeRightWeight = sides.right;
|
|
269
|
+
frame.strokeBottomWeight = sides.bottom;
|
|
270
|
+
frame.strokeLeftWeight = sides.left;
|
|
271
|
+
// Do not write strokeWeight: in Figma this can normalize all sides and undo
|
|
272
|
+
// directional borders (e.g. border-t becoming full box).
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
export function applyBorderWidthUtilities(frame: FrameNode, classes: string[]): void {
|
|
277
|
+
let touched = false;
|
|
278
|
+
let next = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
279
|
+
for (const cls of classes) {
|
|
280
|
+
const atom = parseUtilityClass(cls);
|
|
281
|
+
if (!atom.utility) continue;
|
|
282
|
+
if (!shouldApplyAtom(atom, 'default')) continue;
|
|
283
|
+
|
|
284
|
+
const utility = atom.utility;
|
|
285
|
+
if (utility === 'border') {
|
|
286
|
+
next = { top: 1, right: 1, bottom: 1, left: 1 };
|
|
287
|
+
touched = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const borderWidthMatch = utility.match(/^border-(\d+)$/);
|
|
291
|
+
if (borderWidthMatch) {
|
|
292
|
+
const weight = parseFloat(borderWidthMatch[1]);
|
|
293
|
+
if (Number.isFinite(weight) && weight >= 0) {
|
|
294
|
+
next = { top: weight, right: weight, bottom: weight, left: weight };
|
|
295
|
+
touched = true;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (utility === 'border-t') { next.top = 1; touched = true; continue; }
|
|
300
|
+
if (utility === 'border-r') { next.right = 1; touched = true; continue; }
|
|
301
|
+
if (utility === 'border-b') { next.bottom = 1; touched = true; continue; }
|
|
302
|
+
if (utility === 'border-l') { next.left = 1; touched = true; continue; }
|
|
303
|
+
if (utility === 'border-x') { next.left = 1; next.right = 1; touched = true; continue; }
|
|
304
|
+
if (utility === 'border-y') { next.top = 1; next.bottom = 1; touched = true; continue; }
|
|
305
|
+
const directionalBorderWidthMatch = utility.match(/^(border-(?:t|r|b|l|x|y))-(\d+)$/);
|
|
306
|
+
if (directionalBorderWidthMatch) {
|
|
307
|
+
const borderWeight = parseFloat(directionalBorderWidthMatch[2]);
|
|
308
|
+
if (!Number.isFinite(borderWeight) || borderWeight < 0) continue;
|
|
309
|
+
const directionalUtility = directionalBorderWidthMatch[1];
|
|
310
|
+
if (directionalUtility === 'border-t') next.top = borderWeight;
|
|
311
|
+
else if (directionalUtility === 'border-r') next.right = borderWeight;
|
|
312
|
+
else if (directionalUtility === 'border-b') next.bottom = borderWeight;
|
|
313
|
+
else if (directionalUtility === 'border-l') next.left = borderWeight;
|
|
314
|
+
else if (directionalUtility === 'border-x') { next.left = borderWeight; next.right = borderWeight; }
|
|
315
|
+
else if (directionalUtility === 'border-y') { next.top = borderWeight; next.bottom = borderWeight; }
|
|
316
|
+
touched = true;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (touched) {
|
|
321
|
+
setBorderSideWeights(frame, next);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function reapplyDirectionalBordersIfNeeded(node: SceneNode): void {
|
|
326
|
+
const classes = BORDER_WIDTH_CLASSES.get(node);
|
|
327
|
+
if (!classes || classes.length === 0) return;
|
|
328
|
+
if (!('strokes' in node)) return;
|
|
329
|
+
applyBorderWidthUtilities(node as FrameNode, classes);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Apply functions — resolve intent after a node is appended to its parent
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve a marked ring overlay on this host. Called from the post-append
|
|
338
|
+
* pipeline (after `applyAbsoluteIfPossible` / `applyFullWidthIfPossible`)
|
|
339
|
+
* when the host's content children are all in place and its natural W × H
|
|
340
|
+
* is settled.
|
|
341
|
+
*
|
|
342
|
+
* The architecturally important step is **briefly switching the host's
|
|
343
|
+
* auto-layout sizing modes to FIXED** at the host's current dimensions
|
|
344
|
+
* before appending the overlay. The Figma sandbox requires
|
|
345
|
+
* `appendChild` before `layoutPositioning = 'ABSOLUTE'`, so the overlay
|
|
346
|
+
* unavoidably participates in the host's auto-layout for a moment. With
|
|
347
|
+
* the host locked to FIXED, that moment cannot inflate the host — the
|
|
348
|
+
* sizing is no longer derived from children. After the overlay is marked
|
|
349
|
+
* ABSOLUTE, the host's sizing modes are restored to AUTO; the host then
|
|
350
|
+
* re-measures from its non-absolute children only, returning to its
|
|
351
|
+
* natural size with no drift.
|
|
352
|
+
*
|
|
353
|
+
* Ring geometry: overlay matches the host's W × H exactly, with
|
|
354
|
+
* `strokeAlign: 'OUTSIDE'` so the ring paints past the geometric edge
|
|
355
|
+
* (matching CSS `box-shadow: 0 0 0 N <color>` — see the "DO NOT use
|
|
356
|
+
* Figma DROP_SHADOW spread parameter" entry in `.ai/troubleshooting.md`
|
|
357
|
+
* for why `spread`-based effects fail on transparent frames).
|
|
358
|
+
*
|
|
359
|
+
* `parent` is accepted for API symmetry with the other `apply*IfPossible`
|
|
360
|
+
* functions; it isn't used directly. The host's `clipsContent` IS toggled
|
|
361
|
+
* off so the OUTSIDE stroke isn't clipped.
|
|
362
|
+
*/
|
|
363
|
+
export function applyRingIfPossible(host: SceneNode, _parent: FrameNode): void {
|
|
364
|
+
const ring = RING_NODES.get(host);
|
|
365
|
+
if (!ring) return;
|
|
366
|
+
if (!('layoutMode' in host) || !('resize' in host) || !('appendChild' in host)) {
|
|
367
|
+
RING_NODES.delete(host);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const hostFrame = host as FrameNode;
|
|
371
|
+
const width = typeof hostFrame.width === 'number' ? hostFrame.width : 0;
|
|
372
|
+
const height = typeof hostFrame.height === 'number' ? hostFrame.height : 0;
|
|
373
|
+
if (!(width > 0) || !(height > 0)) {
|
|
374
|
+
// Host has no usable dimensions yet — leave the mark in place so a later
|
|
375
|
+
// reflow pass (after width-solver, aspect-ratio resolution, etc.) can
|
|
376
|
+
// retry. Idempotent: re-running applyRingIfPossible after dimensions
|
|
377
|
+
// settle picks up where we left off.
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Remove any stale overlay from a previous pass (e.g. when the State Matrix
|
|
382
|
+
// re-renders a component master).
|
|
383
|
+
const children = hostFrame.children;
|
|
384
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
385
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
386
|
+
const child = children[i];
|
|
387
|
+
if (!child || child.name !== '__inkbridge-ring__') continue;
|
|
388
|
+
try { child.remove(); } catch (_e) { /* ignore */ }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const originalPrimary = hostFrame.primaryAxisSizingMode;
|
|
393
|
+
const originalCounter = hostFrame.counterAxisSizingMode;
|
|
394
|
+
const primaryWasAuto = originalPrimary === 'AUTO';
|
|
395
|
+
const counterWasAuto = originalCounter === 'AUTO';
|
|
396
|
+
|
|
397
|
+
// Lock host at current dimensions so the brief flex-child moment of the
|
|
398
|
+
// overlay's appendChild can't grow the host. This is the invariant that
|
|
399
|
+
// makes the inflation class impossible.
|
|
400
|
+
try {
|
|
401
|
+
if (primaryWasAuto) hostFrame.primaryAxisSizingMode = 'FIXED';
|
|
402
|
+
if (counterWasAuto) hostFrame.counterAxisSizingMode = 'FIXED';
|
|
403
|
+
hostFrame.resize(width, height);
|
|
404
|
+
} catch (_e) { /* ignore */ }
|
|
405
|
+
|
|
406
|
+
const overlay = figma.createFrame();
|
|
407
|
+
overlay.name = '__inkbridge-ring__';
|
|
408
|
+
overlay.resize(width, height);
|
|
409
|
+
overlay.fills = [];
|
|
410
|
+
overlay.strokes = [{
|
|
411
|
+
type: 'SOLID',
|
|
412
|
+
color: { r: ring.color.r, g: ring.color.g, b: ring.color.b },
|
|
413
|
+
opacity: ring.color.a == null ? 1 : ring.color.a,
|
|
414
|
+
}];
|
|
415
|
+
overlay.strokeWeight = ring.width;
|
|
416
|
+
try { overlay.strokeAlign = 'OUTSIDE'; } catch (_e) { /* ignore */ }
|
|
417
|
+
try {
|
|
418
|
+
(overlay as { strokesIncludedInLayout?: boolean }).strokesIncludedInLayout = false;
|
|
419
|
+
} catch (_e) { /* ignore */ }
|
|
420
|
+
|
|
421
|
+
// Mirror host corner radii — OUTSIDE stroke extends the visible perimeter
|
|
422
|
+
// outward automatically, no `+ ringWidth` arithmetic needed.
|
|
423
|
+
const nodeRadius = hostFrame.cornerRadius;
|
|
424
|
+
if (typeof nodeRadius === 'number') {
|
|
425
|
+
overlay.cornerRadius = nodeRadius;
|
|
426
|
+
} else {
|
|
427
|
+
const tl = typeof hostFrame.topLeftRadius === 'number' ? hostFrame.topLeftRadius : null;
|
|
428
|
+
const tr = typeof hostFrame.topRightRadius === 'number' ? hostFrame.topRightRadius : null;
|
|
429
|
+
const br = typeof hostFrame.bottomRightRadius === 'number' ? hostFrame.bottomRightRadius : null;
|
|
430
|
+
const bl = typeof hostFrame.bottomLeftRadius === 'number' ? hostFrame.bottomLeftRadius : null;
|
|
431
|
+
if (tl != null && tr != null && br != null && bl != null) {
|
|
432
|
+
overlay.topLeftRadius = tl;
|
|
433
|
+
overlay.topRightRadius = tr;
|
|
434
|
+
overlay.bottomRightRadius = br;
|
|
435
|
+
overlay.bottomLeftRadius = bl;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try { hostFrame.appendChild(overlay); } catch (_e) { /* ignore */ }
|
|
440
|
+
try { overlay.layoutPositioning = 'ABSOLUTE'; } catch (_e) { /* ignore */ }
|
|
441
|
+
overlay.x = 0;
|
|
442
|
+
overlay.y = 0;
|
|
443
|
+
try { hostFrame.clipsContent = false; } catch (_e) { /* ignore */ }
|
|
444
|
+
// Z-order: overlay sits in front of host's content children (last in
|
|
445
|
+
// `children` array) — exactly what CSS box-shadow does (rendered on top
|
|
446
|
+
// of the host's content along the stroke band).
|
|
447
|
+
|
|
448
|
+
// Restore the host's original sizing modes. The overlay is now ABSOLUTE
|
|
449
|
+
// and excluded from the host's flow, so AUTO sizing recomputes from the
|
|
450
|
+
// host's content children only (returning to the natural size).
|
|
451
|
+
try {
|
|
452
|
+
if (primaryWasAuto) hostFrame.primaryAxisSizingMode = 'AUTO';
|
|
453
|
+
if (counterWasAuto) hostFrame.counterAxisSizingMode = 'AUTO';
|
|
454
|
+
} catch (_e) { /* ignore */ }
|
|
455
|
+
|
|
456
|
+
// Keep the mark in `RING_NODES` so reflow passes (e.g. after
|
|
457
|
+
// `applyFullWidthIfPossible` resizes the host) can re-run this function
|
|
458
|
+
// idempotently — the stale-overlay-removal step at the top of this
|
|
459
|
+
// function makes re-application safe.
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function applyAbsoluteIfPossible(child: SceneNode, parent: FrameNode): void {
|
|
463
|
+
if (!ABSOLUTE_NODES.has(child)) return;
|
|
464
|
+
const parentIsAutoLayout = 'layoutMode' in parent && parent.layoutMode && parent.layoutMode !== 'NONE';
|
|
465
|
+
try {
|
|
466
|
+
if (parentIsAutoLayout && 'layoutPositioning' in child) {
|
|
467
|
+
child.layoutPositioning = 'ABSOLUTE';
|
|
468
|
+
}
|
|
469
|
+
// Match CSS stacking: a `position: absolute` child renders ABOVE its
|
|
470
|
+
// non-positioned siblings in the same stacking context. Figma's
|
|
471
|
+
// z-order is the parent.children array index (back-to-front), so we
|
|
472
|
+
// re-append the child to push it to the top. This is what makes a
|
|
473
|
+
// shadcn Avatar.Image overlay its Fallback instead of being hidden
|
|
474
|
+
// behind it.
|
|
475
|
+
try { parent.appendChild(child); } catch (_zerr) { /* ignore */ }
|
|
476
|
+
|
|
477
|
+
// Apply positioning from stored position info
|
|
478
|
+
const posInfo = POSITION_INFO_NODES.get(child);
|
|
479
|
+
if (posInfo) {
|
|
480
|
+
// If the child hints a minimum parent height (e.g. gradient-blob inside collapsed container)
|
|
481
|
+
if (posInfo.hintParentHeight != null && 'resize' in parent) {
|
|
482
|
+
try {
|
|
483
|
+
const ph = posInfo.hintParentHeight;
|
|
484
|
+
const pw = Math.max(1, parent.width);
|
|
485
|
+
// Respect explicit fixed-height parents. Only auto-expand when the
|
|
486
|
+
// parent is effectively unconstrained/collapsed.
|
|
487
|
+
let heightIsFixed = false;
|
|
488
|
+
if ('layoutMode' in parent && 'primaryAxisSizingMode' in parent && 'counterAxisSizingMode' in parent) {
|
|
489
|
+
if (parent.layoutMode === 'VERTICAL') {
|
|
490
|
+
heightIsFixed = parent.primaryAxisSizingMode === 'FIXED';
|
|
491
|
+
} else if (parent.layoutMode === 'HORIZONTAL') {
|
|
492
|
+
heightIsFixed = parent.counterAxisSizingMode === 'FIXED';
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const parentCollapsed = parent.height <= 1;
|
|
496
|
+
if (!heightIsFixed && parentCollapsed && parent.height < ph) {
|
|
497
|
+
parent.resize(pw, ph);
|
|
498
|
+
if ('primaryAxisSizingMode' in parent) parent.primaryAxisSizingMode = 'FIXED';
|
|
499
|
+
}
|
|
500
|
+
} catch (_e) { /* ignore */ }
|
|
501
|
+
}
|
|
502
|
+
if ('x' in child && 'y' in child) {
|
|
503
|
+
const childFrame = child as FrameNode;
|
|
504
|
+
// inset-{n}: position at (n, n) and resize to fill parent minus inset on all sides
|
|
505
|
+
if (posInfo.inset != null) {
|
|
506
|
+
const inset = posInfo.inset;
|
|
507
|
+
childFrame.x = inset;
|
|
508
|
+
childFrame.y = inset;
|
|
509
|
+
const targetW = Math.max(1, parent.width - inset * 2);
|
|
510
|
+
const targetH = Math.max(1, parent.height - inset * 2);
|
|
511
|
+
try {
|
|
512
|
+
childFrame.resize(targetW, targetH);
|
|
513
|
+
if ('primaryAxisSizingMode' in childFrame) childFrame.primaryAxisSizingMode = 'FIXED';
|
|
514
|
+
if ('counterAxisSizingMode' in childFrame) childFrame.counterAxisSizingMode = 'FIXED';
|
|
515
|
+
} catch (_e) { /* ignore */ }
|
|
516
|
+
} else {
|
|
517
|
+
if (posInfo.left != null) {
|
|
518
|
+
childFrame.x = posInfo.left;
|
|
519
|
+
} else if (posInfo.right != null) {
|
|
520
|
+
// Defer right anchoring until parent width is final.
|
|
521
|
+
DEFERRED_RIGHT_NODES.set(child, posInfo.right);
|
|
522
|
+
childFrame.x = parent.width - childFrame.width - posInfo.right;
|
|
523
|
+
}
|
|
524
|
+
if (posInfo.top != null) {
|
|
525
|
+
childFrame.y = posInfo.top;
|
|
526
|
+
} else if (posInfo.bottom != null) {
|
|
527
|
+
// Defer: parent height may not be final yet (flow children added after this call)
|
|
528
|
+
DEFERRED_BOTTOM_NODES.set(child, posInfo.bottom);
|
|
529
|
+
} else if (posInfo.topPercent == null && posInfo.bottomPercent == null) {
|
|
530
|
+
// No explicit top/bottom and no percent — CSS places absolute children
|
|
531
|
+
// at the cross-axis static position. For items-center parents this means
|
|
532
|
+
// vertically centered.
|
|
533
|
+
if (parent.counterAxisAlignItems === 'CENTER') {
|
|
534
|
+
DEFERRED_CENTER_Y_NODES.add(child);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
} catch (_err) {
|
|
541
|
+
// ignore
|
|
542
|
+
}
|
|
543
|
+
ABSOLUTE_NODES.delete(child);
|
|
544
|
+
// Retain percent fields AND translate fractions for the deferred percent
|
|
545
|
+
// resolver — they need the parent's FINAL width/height (which won't exist
|
|
546
|
+
// until aspect-ratio / full-width passes settle) and the child's own final
|
|
547
|
+
// width/height. Pixel fields are consumed; percent + translate stay.
|
|
548
|
+
const remaining = POSITION_INFO_NODES.get(child);
|
|
549
|
+
if (remaining && hasPercentPosition(remaining)) {
|
|
550
|
+
POSITION_INFO_NODES.set(child, {
|
|
551
|
+
topPercent: remaining.topPercent,
|
|
552
|
+
bottomPercent: remaining.bottomPercent,
|
|
553
|
+
leftPercent: remaining.leftPercent,
|
|
554
|
+
rightPercent: remaining.rightPercent,
|
|
555
|
+
translateXFraction: remaining.translateXFraction,
|
|
556
|
+
translateYFraction: remaining.translateYFraction,
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
POSITION_INFO_NODES.delete(child);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function hasPercentPosition(info: PositionInfo): boolean {
|
|
564
|
+
return info.topPercent != null
|
|
565
|
+
|| info.bottomPercent != null
|
|
566
|
+
|| info.leftPercent != null
|
|
567
|
+
|| info.rightPercent != null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Resolve any percent-based positions (`top-[X%]`, `left-[X%]`, etc.) on
|
|
572
|
+
* `parent`'s children against the parent's now-final dimensions. Should be
|
|
573
|
+
* called after `applyAspectRatioIfPossible(parent)` and the full-width pass
|
|
574
|
+
* — by that point `parent.width` and `parent.height` reflect the final size
|
|
575
|
+
* those percentages should resolve against.
|
|
576
|
+
*
|
|
577
|
+
* Mirrors CSS: `top: 30%` is `0.3 * containing-block-height`,
|
|
578
|
+
* `left: 50%` is `0.5 * containing-block-width`. `bottom: 70%` anchors the
|
|
579
|
+
* child's bottom edge at 70% from the parent's bottom (= 30% from top), so
|
|
580
|
+
* `child.y = parent.height * (1 - 0.7) - child.height`.
|
|
581
|
+
*/
|
|
582
|
+
export function applyDeferredPercentPositioning(parent: FrameNode): void {
|
|
583
|
+
if (!('children' in parent)) return;
|
|
584
|
+
const pw = parent.width;
|
|
585
|
+
const ph = parent.height;
|
|
586
|
+
if (!Number.isFinite(pw) || !Number.isFinite(ph) || pw <= 0 || ph <= 0) return;
|
|
587
|
+
|
|
588
|
+
for (const child of parent.children as SceneNode[]) {
|
|
589
|
+
const info = POSITION_INFO_NODES.get(child);
|
|
590
|
+
if (!info || !hasPercentPosition(info)) continue;
|
|
591
|
+
if (!('x' in child) || !('y' in child)) continue;
|
|
592
|
+
const childFrame = child as FrameNode;
|
|
593
|
+
const cw = Number.isFinite(childFrame.width) ? childFrame.width : 0;
|
|
594
|
+
const chh = Number.isFinite(childFrame.height) ? childFrame.height : 0;
|
|
595
|
+
try {
|
|
596
|
+
if (info.leftPercent != null) {
|
|
597
|
+
childFrame.x = pw * info.leftPercent;
|
|
598
|
+
} else if (info.rightPercent != null) {
|
|
599
|
+
childFrame.x = pw - cw - pw * info.rightPercent;
|
|
600
|
+
}
|
|
601
|
+
if (info.topPercent != null) {
|
|
602
|
+
childFrame.y = ph * info.topPercent;
|
|
603
|
+
} else if (info.bottomPercent != null) {
|
|
604
|
+
childFrame.y = ph - chh - ph * info.bottomPercent;
|
|
605
|
+
}
|
|
606
|
+
// Apply self-translate (CSS `transform: translate(N%, N%)`) — offset
|
|
607
|
+
// by the child's OWN width/height times the fraction. Most common
|
|
608
|
+
// case is `-translate-x-1/2 -translate-y-1/2` (center on point).
|
|
609
|
+
if (info.translateXFraction != null) {
|
|
610
|
+
childFrame.x += cw * info.translateXFraction;
|
|
611
|
+
}
|
|
612
|
+
if (info.translateYFraction != null) {
|
|
613
|
+
childFrame.y += chh * info.translateYFraction;
|
|
614
|
+
}
|
|
615
|
+
} catch (_e) {
|
|
616
|
+
// ignore
|
|
617
|
+
}
|
|
618
|
+
// Keep the mark — application is idempotent and the parent's dimensions
|
|
619
|
+
// may still change (aspect-ratio retry, w-full resolution, etc.). The
|
|
620
|
+
// final `reflowDeferredAbsolutePositioningTree` pass uses these marks to
|
|
621
|
+
// re-resolve positions against the parent's final size. Earlier this
|
|
622
|
+
// was deleted after use, which caused percent-positioned children to
|
|
623
|
+
// freeze at coordinates computed from intermediate parent dimensions
|
|
624
|
+
// (e.g. parent hugging to one child before aspect-ratio resolved).
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Re-apply right-anchored x positions for children of a frame after its width changes. */
|
|
629
|
+
export function reapplyRightPositionedChildren(frame: FrameNode, newWidth: number): void {
|
|
630
|
+
if (!('children' in frame)) return;
|
|
631
|
+
for (const child of (frame.children as SceneNode[])) {
|
|
632
|
+
const right = DEFERRED_RIGHT_NODES.get(child);
|
|
633
|
+
if (right != null) {
|
|
634
|
+
try {
|
|
635
|
+
child.x = newWidth - child.width - right;
|
|
636
|
+
} catch (_e) { /* ignore */ }
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function reflowNestedFullWidthChildren(parent: FrameNode): void {
|
|
642
|
+
if (!parent || !('children' in parent)) return;
|
|
643
|
+
const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
644
|
+
const widthOverride = Number.isFinite(parent.width) ? Math.max(0, parent.width - padding) : null;
|
|
645
|
+
const options = widthOverride != null ? { widthOverride: widthOverride } : undefined;
|
|
646
|
+
for (const child of (parent.children as SceneNode[])) {
|
|
647
|
+
if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
|
|
648
|
+
applyFullWidthIfPossible(child, parent, options);
|
|
649
|
+
if ('layoutMode' in child) {
|
|
650
|
+
reflowNestedFullWidthChildren(child as FrameNode);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Call after all children have been appended to a frame to apply bottom-anchored positions. */
|
|
656
|
+
export function applyDeferredBottomPositioning(parent: FrameNode): void {
|
|
657
|
+
for (const child of (parent.children as SceneNode[])) {
|
|
658
|
+
const right = DEFERRED_RIGHT_NODES.get(child);
|
|
659
|
+
if (right != null) {
|
|
660
|
+
try {
|
|
661
|
+
child.x = parent.width - child.width - right;
|
|
662
|
+
} catch (_e) { /* ignore */ }
|
|
663
|
+
// Do not delete — applyFullWidthIfPossible may recalculate after parent is resized.
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const bottom = DEFERRED_BOTTOM_NODES.get(child);
|
|
667
|
+
if (bottom != null) {
|
|
668
|
+
try {
|
|
669
|
+
child.y = parent.height - child.height - bottom;
|
|
670
|
+
} catch (_e) { /* ignore */ }
|
|
671
|
+
DEFERRED_BOTTOM_NODES.delete(child);
|
|
672
|
+
}
|
|
673
|
+
// Handle top-[calc(100%-Nrem)]: top = parentHeight - N
|
|
674
|
+
const topRelative = DEFERRED_TOP_RELATIVE_NODES.get(child);
|
|
675
|
+
if (topRelative != null) {
|
|
676
|
+
try {
|
|
677
|
+
child.y = parent.height - topRelative;
|
|
678
|
+
} catch (_e) { /* ignore */ }
|
|
679
|
+
DEFERRED_TOP_RELATIVE_NODES.delete(child);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Call after all children have been appended to center absolute children with no explicit top/bottom. */
|
|
685
|
+
export function applyDeferredCenterYPositioning(parent: FrameNode): void {
|
|
686
|
+
const parentHeight = parent.height;
|
|
687
|
+
if (parentHeight <= 0) return;
|
|
688
|
+
for (const child of (parent.children as SceneNode[])) {
|
|
689
|
+
if (DEFERRED_CENTER_Y_NODES.has(child)) {
|
|
690
|
+
try {
|
|
691
|
+
const childHeight = child.height || 0;
|
|
692
|
+
child.y = Math.round((parentHeight - childHeight) / 2);
|
|
693
|
+
} catch (_e) { /* ignore */ }
|
|
694
|
+
DEFERRED_CENTER_Y_NODES.delete(child);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Resolve a marked aspect ratio against the node's now-settled width.
|
|
701
|
+
* Caller is responsible for calling this AFTER any width-deciding pass
|
|
702
|
+
* (e.g. `applyFullWidthIfPossible`) so `child.width` reflects the final
|
|
703
|
+
* value. Computes `height = width / ratio`, resizes, and locks both
|
|
704
|
+
* sizing axes to FIXED so subsequent auto-layout passes don't collapse it.
|
|
705
|
+
*
|
|
706
|
+
* Mirrors CSS `aspect-ratio: <ratio>` semantics — if a `width` is set,
|
|
707
|
+
* the height is computed from the ratio; explicit `h-*` utilities will
|
|
708
|
+
* be overridden, which matches Tailwind's behaviour.
|
|
709
|
+
*/
|
|
710
|
+
export function applyAspectRatioIfPossible(child: SceneNode): void {
|
|
711
|
+
const ratio = ASPECT_RATIO_NODES.get(child);
|
|
712
|
+
if (ratio == null) return;
|
|
713
|
+
if (!('resize' in child) || !('width' in child)) return;
|
|
714
|
+
const w = (child as FrameNode).width;
|
|
715
|
+
if (!Number.isFinite(w) || w <= 0) return;
|
|
716
|
+
const h = w / ratio;
|
|
717
|
+
if (!Number.isFinite(h) || h <= 0) return;
|
|
718
|
+
try {
|
|
719
|
+
(child as FrameNode).resize(w, h);
|
|
720
|
+
if ('primaryAxisSizingMode' in child) {
|
|
721
|
+
(child as FrameNode).primaryAxisSizingMode = 'FIXED';
|
|
722
|
+
}
|
|
723
|
+
if ('counterAxisSizingMode' in child) {
|
|
724
|
+
(child as FrameNode).counterAxisSizingMode = 'FIXED';
|
|
725
|
+
}
|
|
726
|
+
} catch (_e) {
|
|
727
|
+
// ignore
|
|
728
|
+
}
|
|
729
|
+
ASPECT_RATIO_NODES.delete(child);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Re-apply deferred absolute positioning after the layout tree has settled.
|
|
734
|
+
* This is a post-pass used to avoid timing/order drift when parent widths/heights
|
|
735
|
+
* change after initial child construction.
|
|
736
|
+
*
|
|
737
|
+
* Aspect-ratio retry is part of this reflow: a frame like
|
|
738
|
+
* `<div className="aspect-5/3 w-full">` whose children are all absolute
|
|
739
|
+
* collapses to 0 height under hug-content layout. The earlier
|
|
740
|
+
* `applyAspectRatioIfPossible` pass may have run while `width` was still 0
|
|
741
|
+
* (mark retained for retry). Without re-attempting it here, the parent
|
|
742
|
+
* stays at 0 height and `applyDeferredPercentPositioning` bails — leaving
|
|
743
|
+
* every percent-positioned child stacked at y=0.
|
|
744
|
+
*/
|
|
745
|
+
export function reflowDeferredAbsolutePositioningTree(root: SceneNode): void {
|
|
746
|
+
if (!root) return;
|
|
747
|
+
if (!('children' in root)) return;
|
|
748
|
+
for (const child of root.children) {
|
|
749
|
+
reflowDeferredAbsolutePositioningTree(child);
|
|
750
|
+
}
|
|
751
|
+
if ('layoutMode' in root) {
|
|
752
|
+
try {
|
|
753
|
+
applyAspectRatioIfPossible(root as FrameNode);
|
|
754
|
+
const rootFrame = root as FrameNode;
|
|
755
|
+
// Detect "pure positioning container" — every child is absolute. In CSS
|
|
756
|
+
// such a parent's display mode is irrelevant (block / flex / grid all
|
|
757
|
+
// behave the same when children are out-of-flow). In Figma, leaving an
|
|
758
|
+
// auto-layout VERTICAL/HORIZONTAL mode on such a parent causes Figma to
|
|
759
|
+
// try to flow children and re-hug the parent height back to 0 even
|
|
760
|
+
// after `applyAspectRatioIfPossible` set FIXED sizing. Switch to NONE
|
|
761
|
+
// (freeform) so the aspect-ratio size sticks and percent positioning
|
|
762
|
+
// resolves against the real dimensions. Round-trip-section's triangle
|
|
763
|
+
// is the canonical case: `aspect-5/3 w-full` with one absolute SVG
|
|
764
|
+
// overlay + three absolute icon nodes.
|
|
765
|
+
if (
|
|
766
|
+
'children' in rootFrame
|
|
767
|
+
&& rootFrame.children.length > 0
|
|
768
|
+
&& rootFrame.layoutMode !== 'NONE'
|
|
769
|
+
) {
|
|
770
|
+
let allAbs = true;
|
|
771
|
+
for (const child of rootFrame.children) {
|
|
772
|
+
const isAbs = ABSOLUTE_NODES.has(child)
|
|
773
|
+
|| ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE');
|
|
774
|
+
if (!isAbs) {
|
|
775
|
+
allAbs = false;
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (allAbs) {
|
|
780
|
+
try {
|
|
781
|
+
rootFrame.layoutMode = 'NONE';
|
|
782
|
+
} catch (_err) {
|
|
783
|
+
// ignore
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// Re-apply fill-parent marks now that the parent dimensions are
|
|
788
|
+
// settled. Critical for two patterns:
|
|
789
|
+
// 1. SVG overlay `absolute inset-0 h-full w-full` inside an
|
|
790
|
+
// `aspect-5/3` container — initial size was computed against
|
|
791
|
+
// parent.height === 0.
|
|
792
|
+
// 2. Bridge SVG `width="100%" height="100%"` inside `aspect-6/1
|
|
793
|
+
// w-full` — same timing issue; the child is a flow child but
|
|
794
|
+
// the parent's aspect-ratio resolved AFTER the initial pass.
|
|
795
|
+
//
|
|
796
|
+
// SKIP for HORIZONTAL+WRAP parents — those are grid containers
|
|
797
|
+
// sized by `applyGridColumnsIfPossible`, where each child's width is
|
|
798
|
+
// the column width (not the full parent width). Re-running
|
|
799
|
+
// `applyFullWidthIfPossible` here would resize each `w-full` grid
|
|
800
|
+
// child to the FULL grid width, forcing each onto its own row
|
|
801
|
+
// (vertical stack instead of N-column grid). Canonical break:
|
|
802
|
+
// MediaCard Grid story with three `w-full` cards inside
|
|
803
|
+
// `grid-cols-3`.
|
|
804
|
+
const isGridWrap = rootFrame.layoutMode === 'HORIZONTAL'
|
|
805
|
+
&& 'layoutWrap' in rootFrame
|
|
806
|
+
&& rootFrame.layoutWrap === 'WRAP';
|
|
807
|
+
if ('children' in rootFrame && !isGridWrap) {
|
|
808
|
+
for (const child of rootFrame.children) {
|
|
809
|
+
if (!FULL_WIDTH_NODES.has(child) && !FULL_HEIGHT_NODES.has(child)) continue;
|
|
810
|
+
applyFullWidthIfPossible(child, rootFrame);
|
|
811
|
+
// Re-sync any ring overlay to the child's new width/height.
|
|
812
|
+
applyRingIfPossible(child, rootFrame);
|
|
813
|
+
// Standard `applyFullWidthIfPossible` for a non-absolute child in
|
|
814
|
+
// a VERTICAL parent uses `layoutAlign='STRETCH'` + `layoutGrow=1`,
|
|
815
|
+
// which Figma resolves asynchronously and doesn't trigger our
|
|
816
|
+
// `markSvgContentWrap` resize hook. Bridge SVG (`width="100%"
|
|
817
|
+
// height="100%"` inside an `aspect-6/1` wrapper) ends up with the
|
|
818
|
+
// wrap notionally sized but the inner SVG vectors still 24×24.
|
|
819
|
+
// Force a direct resize for SVG content wraps so the hook fires
|
|
820
|
+
// and the vectors scale with the wrap.
|
|
821
|
+
if (
|
|
822
|
+
isSvgContentWrap(child)
|
|
823
|
+
&& 'resize' in child
|
|
824
|
+
&& Number.isFinite(rootFrame.width)
|
|
825
|
+
&& Number.isFinite(rootFrame.height)
|
|
826
|
+
&& rootFrame.width > 0
|
|
827
|
+
&& rootFrame.height > 0
|
|
828
|
+
) {
|
|
829
|
+
try {
|
|
830
|
+
const padX = (rootFrame.paddingLeft || 0) + (rootFrame.paddingRight || 0);
|
|
831
|
+
const padY = (rootFrame.paddingTop || 0) + (rootFrame.paddingBottom || 0);
|
|
832
|
+
const targetW = FULL_WIDTH_NODES.has(child)
|
|
833
|
+
? Math.max(1, rootFrame.width - padX)
|
|
834
|
+
: (child as FrameNode).width;
|
|
835
|
+
const targetH = FULL_HEIGHT_NODES.has(child)
|
|
836
|
+
? Math.max(1, rootFrame.height - padY)
|
|
837
|
+
: (child as FrameNode).height;
|
|
838
|
+
child.resize(targetW, targetH);
|
|
839
|
+
resizeSvgContentIfMarked(child, targetW, targetH);
|
|
840
|
+
} catch (_err) {
|
|
841
|
+
// ignore
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
applyDeferredBottomPositioning(rootFrame);
|
|
847
|
+
applyDeferredCenterYPositioning(rootFrame);
|
|
848
|
+
applyDeferredPercentPositioning(rootFrame);
|
|
849
|
+
} catch (_e) {
|
|
850
|
+
// ignore
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export function applyFullWidthIfPossible(
|
|
856
|
+
child: SceneNode,
|
|
857
|
+
parent: FrameNode,
|
|
858
|
+
options?: { skipFullWidth?: boolean; widthOverride?: number }
|
|
859
|
+
): void {
|
|
860
|
+
const hasMarkedFixedWidth = FIXED_WIDTH_NODES.has(child);
|
|
861
|
+
const hasMarkedFractionWidth = FRACTION_WIDTH_NODES.get(child) != null;
|
|
862
|
+
const hasMarkedFullWidth = FULL_WIDTH_NODES.has(child);
|
|
863
|
+
const align = SELF_ALIGNMENT_NODES.get(child);
|
|
864
|
+
const hasSelfAlign = align != null;
|
|
865
|
+
// mx-auto + max-w: CENTER self-alignment combined with full-width mark means
|
|
866
|
+
// the element should be center-aligned and resized to its max-width constraint.
|
|
867
|
+
const isMxAutoFill = align === 'CENTER' && hasMarkedFullWidth;
|
|
868
|
+
if (align) {
|
|
869
|
+
if (align === 'STRETCH') {
|
|
870
|
+
if ('layoutAlign' in child) {
|
|
871
|
+
try { child.layoutAlign = align; } catch (_err) { /* ignore */ }
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
// `ml-auto` on a horizontal-flex child means "push me along the primary
|
|
875
|
+
// axis" in CSS. The child-level MAX mark alone can't express that — the
|
|
876
|
+
// Figma equivalent is the parent flipping to SPACE_BETWEEN so the child
|
|
877
|
+
// anchors to the end while siblings stay at MIN. Only promote when the
|
|
878
|
+
// parent's primary alignment is still the default MIN (consumer didn't
|
|
879
|
+
// set justify-*) and the parent has at least two children to space out.
|
|
880
|
+
if (
|
|
881
|
+
align === 'MAX'
|
|
882
|
+
&& parent.layoutMode === 'HORIZONTAL'
|
|
883
|
+
&& parent.primaryAxisAlignItems === 'MIN'
|
|
884
|
+
&& parent.children.length >= 2
|
|
885
|
+
) {
|
|
886
|
+
try { parent.primaryAxisAlignItems = 'SPACE_BETWEEN'; } catch (_err) { /* ignore */ }
|
|
887
|
+
}
|
|
888
|
+
// MIN / CENTER / MAX: avoid forcing HUG when an explicit width utility exists.
|
|
889
|
+
// Otherwise classes like w-9 can be collapsed back to content width.
|
|
890
|
+
if (hasMarkedFixedWidth) {
|
|
891
|
+
if ('layoutSizingHorizontal' in child) {
|
|
892
|
+
try { child.layoutSizingHorizontal = 'FIXED'; } catch (_err) { /* ignore if unsupported */ }
|
|
893
|
+
}
|
|
894
|
+
} else if (!hasMarkedFractionWidth && !hasMarkedFullWidth) {
|
|
895
|
+
// Best proxy for non-stretch self alignment when width is content-driven.
|
|
896
|
+
if ('layoutSizingHorizontal' in child) {
|
|
897
|
+
try { child.layoutSizingHorizontal = 'HUG'; } catch (_err) { /* ignore if unsupported */ }
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
SELF_ALIGNMENT_NODES.delete(child);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const basis = FLEX_BASIS_NODES.get(child);
|
|
905
|
+
if (basis != null && basis > 0 && 'resize' in child) {
|
|
906
|
+
try {
|
|
907
|
+
if (parent.layoutMode === 'HORIZONTAL') {
|
|
908
|
+
child.resize(basis, child.height);
|
|
909
|
+
if ('primaryAxisSizingMode' in child) child.primaryAxisSizingMode = 'FIXED';
|
|
910
|
+
} else if (parent.layoutMode === 'VERTICAL') {
|
|
911
|
+
child.resize(child.width, basis);
|
|
912
|
+
if ('primaryAxisSizingMode' in child) child.primaryAxisSizingMode = 'FIXED';
|
|
913
|
+
}
|
|
914
|
+
} catch (_err) {
|
|
915
|
+
// ignore
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (basis != null) {
|
|
919
|
+
FLEX_BASIS_NODES.delete(child);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const minWidth = MIN_WIDTH_NODES.get(child);
|
|
923
|
+
if (minWidth != null && 'resize' in child) {
|
|
924
|
+
const currentWidth = child.width as number;
|
|
925
|
+
if (!Number.isFinite(currentWidth) || currentWidth < minWidth) {
|
|
926
|
+
try {
|
|
927
|
+
child.resize(minWidth, child.height);
|
|
928
|
+
if (parent.layoutMode === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
929
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
930
|
+
} else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
931
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
932
|
+
}
|
|
933
|
+
} catch (_err) {
|
|
934
|
+
// ignore
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
MIN_WIDTH_NODES.delete(child);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const skipFullWidth = !!(options && options.skipFullWidth);
|
|
941
|
+
const rawWidthOverride = options && typeof options.widthOverride === 'number' && Number.isFinite(options.widthOverride)
|
|
942
|
+
? options.widthOverride
|
|
943
|
+
: null;
|
|
944
|
+
// Bound inherited width overrides to the parent content box — but only when the parent
|
|
945
|
+
// has been explicitly sized (FIXED mode). Auto-sized frames (compFrame, wrapperFrame)
|
|
946
|
+
// may still be at their Figma default width before children are rendered and layout
|
|
947
|
+
// settles; bounding against that default would incorrectly clamp a correct narrow
|
|
948
|
+
// override (e.g. 320px viewport) down to the frame's unset default (~100px).
|
|
949
|
+
const parentIsFixedWidth =
|
|
950
|
+
parent.layoutMode === 'NONE'
|
|
951
|
+
|| (parent.layoutMode === 'HORIZONTAL' && parent.primaryAxisSizingMode === 'FIXED')
|
|
952
|
+
|| (parent.layoutMode === 'VERTICAL' && parent.counterAxisSizingMode === 'FIXED');
|
|
953
|
+
const parentPaddingX = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
954
|
+
const parentContentWidth = Math.max(0, parent.width - parentPaddingX);
|
|
955
|
+
const widthOverride = rawWidthOverride != null && parentIsFixedWidth && parentContentWidth > 0
|
|
956
|
+
? Math.min(rawWidthOverride, parentContentWidth)
|
|
957
|
+
: rawWidthOverride;
|
|
958
|
+
const widthBase = widthOverride != null ? widthOverride : parent.width;
|
|
959
|
+
// CSS grid (single-column) children stretch to fill the column by default (align-items: stretch).
|
|
960
|
+
// Don't stretch absolute-positioned children — they're handled separately.
|
|
961
|
+
const childIsAbsolutePositioned = ABSOLUTE_NODES.has(child)
|
|
962
|
+
|| ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE');
|
|
963
|
+
const isGridStretchChild = !skipFullWidth
|
|
964
|
+
&& CSS_GRID_VERTICAL_FRAMES.has(parent)
|
|
965
|
+
&& !childIsAbsolutePositioned;
|
|
966
|
+
const hasFullWidth = skipFullWidth ? false : (hasMarkedFullWidth || isGridStretchChild);
|
|
967
|
+
const fractionWidth = skipFullWidth ? null : FRACTION_WIDTH_NODES.get(child);
|
|
968
|
+
const hasFixedWidth = hasMarkedFixedWidth;
|
|
969
|
+
const hasFullHeight = FULL_HEIGHT_NODES.has(child);
|
|
970
|
+
if (!hasFullWidth && fractionWidth == null && !hasFullHeight && !hasFixedWidth) return;
|
|
971
|
+
if (hasFixedWidth && parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
|
|
972
|
+
try {
|
|
973
|
+
child.layoutAlign = 'INHERIT';
|
|
974
|
+
} catch (_err) {
|
|
975
|
+
// ignore
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
if (!hasFullWidth && fractionWidth != null && 'resize' in child && widthBase > 0) {
|
|
980
|
+
// widthOverride is already content-width; only subtract padding when using parent.width
|
|
981
|
+
const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
982
|
+
const targetWidth = Math.max(0, widthBase - padding) * fractionWidth;
|
|
983
|
+
try {
|
|
984
|
+
child.resize(targetWidth, child.height);
|
|
985
|
+
// Prevent fractional width children from expanding
|
|
986
|
+
if ('layoutGrow' in child) {
|
|
987
|
+
child.layoutGrow = 0;
|
|
988
|
+
}
|
|
989
|
+
if (parent.layoutMode === 'VERTICAL' && !hasSelfAlign && 'layoutAlign' in child) {
|
|
990
|
+
child.layoutAlign = 'INHERIT';
|
|
991
|
+
}
|
|
992
|
+
// Set sizing mode to FIXED so the width is respected
|
|
993
|
+
if ('layoutMode' in child) {
|
|
994
|
+
const childLayout = child.layoutMode;
|
|
995
|
+
if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
996
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
997
|
+
} else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
998
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
999
|
+
}
|
|
1000
|
+
reflowNestedFullWidthChildren(child as FrameNode);
|
|
1001
|
+
} else if (parent.layoutMode === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
1002
|
+
// For non-layout children in vertical parent, fix the width
|
|
1003
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1004
|
+
}
|
|
1005
|
+
} catch (_err) {
|
|
1006
|
+
// ignore resize errors
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (hasFullWidth) {
|
|
1011
|
+
if (parent.layoutMode === 'HORIZONTAL') {
|
|
1012
|
+
// The synthetic mx-auto wrapper is HORIZONTAL with primaryAxisAlignItems='CENTER'.
|
|
1013
|
+
// When its sole child has a max-w-* constraint, the child is pre-sized to its
|
|
1014
|
+
// max-w and meant to sit centered inside the wrapper. Applying full-width here
|
|
1015
|
+
// would stretch the child back to the wrapper width, defeating the centering.
|
|
1016
|
+
// Without a max-w (e.g. `mx-auto w-full`), the child should still fill — fall
|
|
1017
|
+
// through. The check is structural (parent.name and child's max-w mark) rather
|
|
1018
|
+
// than relying on `isMxAutoFill`, because `SELF_ALIGNMENT_NODES.delete(child)`
|
|
1019
|
+
// above nukes the CENTER mark after the first pass and subsequent calls from
|
|
1020
|
+
// `reflowNestedFullWidthChildren` would otherwise bypass the guard.
|
|
1021
|
+
if (parent.name === 'mx-auto' && MAX_WIDTH_NODES.has(child)) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
// When parent uses SPACE_BETWEEN, don't apply layoutGrow=1 because that would
|
|
1025
|
+
// make this child consume all space, leaving nothing for siblings.
|
|
1026
|
+
// In CSS flexbox, width:100% with justify-content:space-between still spaces items at edges.
|
|
1027
|
+
// In Figma, we need to explicitly set layoutGrow=0 and let SPACE_BETWEEN distribute items.
|
|
1028
|
+
const isSpaceBetween = parent.primaryAxisAlignItems === 'SPACE_BETWEEN';
|
|
1029
|
+
if (isSpaceBetween) {
|
|
1030
|
+
// Explicitly set to 0 to prevent any expansion
|
|
1031
|
+
if ('layoutGrow' in child) child.layoutGrow = 0;
|
|
1032
|
+
// Also ensure the child sizes to its content, not to fill
|
|
1033
|
+
if ('primaryAxisSizingMode' in child) {
|
|
1034
|
+
child.primaryAxisSizingMode = 'AUTO';
|
|
1035
|
+
}
|
|
1036
|
+
} else {
|
|
1037
|
+
if ('layoutGrow' in child) child.layoutGrow = 1;
|
|
1038
|
+
}
|
|
1039
|
+
if (
|
|
1040
|
+
(('primaryAxisSizingMode' in parent && parent.primaryAxisSizingMode === 'FIXED') || widthOverride != null)
|
|
1041
|
+
&& 'resize' in child
|
|
1042
|
+
&& !isSpaceBetween
|
|
1043
|
+
&& widthBase > 0
|
|
1044
|
+
) {
|
|
1045
|
+
try {
|
|
1046
|
+
child.resize(widthBase, child.height);
|
|
1047
|
+
if ('primaryAxisSizingMode' in child) {
|
|
1048
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1049
|
+
}
|
|
1050
|
+
reapplyRightPositionedChildren(child as FrameNode, widthBase);
|
|
1051
|
+
if ('layoutMode' in child) {
|
|
1052
|
+
reflowNestedFullWidthChildren(child as FrameNode);
|
|
1053
|
+
}
|
|
1054
|
+
} catch (_err) {
|
|
1055
|
+
// ignore resize errors
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
} else if (
|
|
1059
|
+
(childIsAbsolutePositioned || !('layoutMode' in parent) || parent.layoutMode === 'NONE')
|
|
1060
|
+
&& 'resize' in child
|
|
1061
|
+
&& Number.isFinite(parent.width)
|
|
1062
|
+
&& parent.width > 0
|
|
1063
|
+
) {
|
|
1064
|
+
// Direct-resize path for either:
|
|
1065
|
+
// (a) parents with no auto-layout (positioning containers, e.g.
|
|
1066
|
+
// an `aspect-*` div or a `relative` overlay), or
|
|
1067
|
+
// (b) absolute-positioned children of an auto-layout parent
|
|
1068
|
+
// (e.g. `<svg className="absolute inset-0 h-full w-full">`
|
|
1069
|
+
// inside an `aspect-5/3` triangle frame). For absolute
|
|
1070
|
+
// children, `layoutAlign='STRETCH'` and `layoutGrow=1` are
|
|
1071
|
+
// no-ops — only direct resize works. Mirrors CSS
|
|
1072
|
+
// `width: 100%` semantics.
|
|
1073
|
+
try {
|
|
1074
|
+
const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
1075
|
+
const rawTarget = Math.max(0, parent.width - padding);
|
|
1076
|
+
const maxWConst = MAX_WIDTH_NODES.get(child);
|
|
1077
|
+
const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
|
|
1078
|
+
if (targetWidth > 0) {
|
|
1079
|
+
child.resize(targetWidth, child.height);
|
|
1080
|
+
resizeSvgContentIfMarked(child, targetWidth, child.height);
|
|
1081
|
+
if ('primaryAxisSizingMode' in child) {
|
|
1082
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1083
|
+
}
|
|
1084
|
+
if ('counterAxisSizingMode' in child) {
|
|
1085
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} catch (_err) {
|
|
1089
|
+
// ignore
|
|
1090
|
+
}
|
|
1091
|
+
} else if (parent.layoutMode === 'VERTICAL') {
|
|
1092
|
+
// If the child is HORIZONTAL with SPACE_BETWEEN and already has FIXED sizing,
|
|
1093
|
+
// don't use STRETCH as it conflicts. Instead, keep the explicit FIXED width.
|
|
1094
|
+
const childIsHorizontal = 'layoutMode' in child && child.layoutMode === 'HORIZONTAL';
|
|
1095
|
+
const childHasSpaceBetween = childIsHorizontal && child.primaryAxisAlignItems === 'SPACE_BETWEEN';
|
|
1096
|
+
const childHasFixedWidth = 'primaryAxisSizingMode' in child && child.primaryAxisSizingMode === 'FIXED';
|
|
1097
|
+
if (childHasSpaceBetween && childHasFixedWidth) {
|
|
1098
|
+
// Keep the FIXED sizing, but set layoutAlign to FILL to take parent width
|
|
1099
|
+
// Actually, for SPACE_BETWEEN to work, we need the child to have a specific width
|
|
1100
|
+
// So we resize it to parent width and keep FIXED
|
|
1101
|
+
if ('resize' in child && widthBase > 0) {
|
|
1102
|
+
try {
|
|
1103
|
+
const padding = (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
1104
|
+
const resizedWidth = widthBase - padding;
|
|
1105
|
+
child.resize(resizedWidth, child.height);
|
|
1106
|
+
reapplyRightPositionedChildren(child as FrameNode, resizedWidth);
|
|
1107
|
+
} catch (_err) {
|
|
1108
|
+
// ignore
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
} else if (isMxAutoFill) {
|
|
1112
|
+
// mx-auto + max-w: center-align child and resize to max-w constraint.
|
|
1113
|
+
// `layoutAlign` only accepts 'INHERIT' | 'STRETCH' in current Figma —
|
|
1114
|
+
// legacy 'CENTER' triggers a console warning per call. Use
|
|
1115
|
+
// `counterAxisAlignSelf` for non-STRETCH alignment.
|
|
1116
|
+
if ('counterAxisAlignSelf' in child) {
|
|
1117
|
+
try { child.counterAxisAlignSelf = 'CENTER'; } catch (_e) { /* ignore */ }
|
|
1118
|
+
}
|
|
1119
|
+
if ('resize' in child && widthBase > 0) {
|
|
1120
|
+
try {
|
|
1121
|
+
const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
1122
|
+
const rawTarget = Math.max(0, widthBase - padding);
|
|
1123
|
+
const maxWConst = MAX_WIDTH_NODES.get(child);
|
|
1124
|
+
const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
|
|
1125
|
+
if (targetWidth > 0) {
|
|
1126
|
+
child.resize(targetWidth, child.height);
|
|
1127
|
+
reapplyRightPositionedChildren(child as FrameNode, targetWidth);
|
|
1128
|
+
if ('layoutMode' in child) {
|
|
1129
|
+
const childLayout = child.layoutMode;
|
|
1130
|
+
if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
1131
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1132
|
+
} else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
1133
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1134
|
+
}
|
|
1135
|
+
reflowNestedFullWidthChildren(child as FrameNode);
|
|
1136
|
+
} else if ('counterAxisSizingMode' in child) {
|
|
1137
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
} catch (_err) {
|
|
1141
|
+
// ignore
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
} else {
|
|
1145
|
+
if ('layoutAlign' in child) child.layoutAlign = 'STRETCH';
|
|
1146
|
+
const forceResizeForMxAutoWrapper = child.name === 'mx-auto';
|
|
1147
|
+
if (
|
|
1148
|
+
(
|
|
1149
|
+
('counterAxisSizingMode' in parent && parent.counterAxisSizingMode === 'FIXED')
|
|
1150
|
+
|| widthOverride != null
|
|
1151
|
+
|| forceResizeForMxAutoWrapper
|
|
1152
|
+
)
|
|
1153
|
+
&& 'resize' in child
|
|
1154
|
+
) {
|
|
1155
|
+
try {
|
|
1156
|
+
// widthOverride is already content-width (padding already subtracted by caller).
|
|
1157
|
+
// When falling back to parent.width we must subtract padding ourselves.
|
|
1158
|
+
const padding = widthOverride != null ? 0 : (parent.paddingLeft || 0) + (parent.paddingRight || 0);
|
|
1159
|
+
const rawTarget = Math.max(0, widthBase - padding);
|
|
1160
|
+
// Respect max-w-* constraints for normal nodes, but never for the synthetic
|
|
1161
|
+
// mx-auto wrapper itself: wrapper must stay full-width and center its child.
|
|
1162
|
+
const maxWConst = forceResizeForMxAutoWrapper ? null : MAX_WIDTH_NODES.get(child);
|
|
1163
|
+
const targetWidth = maxWConst != null ? Math.min(rawTarget, maxWConst) : rawTarget;
|
|
1164
|
+
if (targetWidth > 0) {
|
|
1165
|
+
child.resize(targetWidth, child.height);
|
|
1166
|
+
reapplyRightPositionedChildren(child as FrameNode, targetWidth);
|
|
1167
|
+
if ('layoutMode' in child) {
|
|
1168
|
+
const childLayout = child.layoutMode;
|
|
1169
|
+
if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
1170
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1171
|
+
} else if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
1172
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1173
|
+
}
|
|
1174
|
+
reflowNestedFullWidthChildren(child as FrameNode);
|
|
1175
|
+
} else if ('counterAxisSizingMode' in child) {
|
|
1176
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
} catch (_err) {
|
|
1180
|
+
// ignore
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (hasFullHeight) {
|
|
1187
|
+
// Absolute-positioned children don't honor layoutGrow / layoutAlign —
|
|
1188
|
+
// for `absolute inset-0 h-full w-full` (e.g. the round-trip arrow SVG
|
|
1189
|
+
// overlay) only a direct resize works. Mirrors the width branch above.
|
|
1190
|
+
if (childIsAbsolutePositioned && 'resize' in child && Number.isFinite(parent.height) && parent.height > 0) {
|
|
1191
|
+
try {
|
|
1192
|
+
const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
|
|
1193
|
+
const targetHeight = Math.max(0, parent.height - padding);
|
|
1194
|
+
if (targetHeight > 0) {
|
|
1195
|
+
child.resize(child.width, targetHeight);
|
|
1196
|
+
resizeSvgContentIfMarked(child, child.width, targetHeight);
|
|
1197
|
+
if ('counterAxisSizingMode' in child) {
|
|
1198
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1199
|
+
}
|
|
1200
|
+
if ('primaryAxisSizingMode' in child) {
|
|
1201
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
} catch (_err) {
|
|
1205
|
+
// ignore
|
|
1206
|
+
}
|
|
1207
|
+
} else if (parent.layoutMode === 'VERTICAL') {
|
|
1208
|
+
if ('layoutGrow' in child && parent.primaryAxisSizingMode === 'FIXED') {
|
|
1209
|
+
child.layoutGrow = 1;
|
|
1210
|
+
}
|
|
1211
|
+
} else if (parent.layoutMode === 'HORIZONTAL') {
|
|
1212
|
+
if ('layoutAlign' in child) {
|
|
1213
|
+
child.layoutAlign = 'STRETCH';
|
|
1214
|
+
}
|
|
1215
|
+
if (
|
|
1216
|
+
'counterAxisSizingMode' in parent
|
|
1217
|
+
&& parent.counterAxisSizingMode === 'FIXED'
|
|
1218
|
+
&& 'resize' in child
|
|
1219
|
+
) {
|
|
1220
|
+
try {
|
|
1221
|
+
const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
|
|
1222
|
+
const targetHeight = Math.max(0, parent.height - padding);
|
|
1223
|
+
if (targetHeight > 0) {
|
|
1224
|
+
child.resize(child.width, targetHeight);
|
|
1225
|
+
if ('counterAxisSizingMode' in child) {
|
|
1226
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
} catch (_err) {
|
|
1230
|
+
// ignore
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
} else if ('resize' in child && Number.isFinite(parent.height) && parent.height > 0) {
|
|
1234
|
+
// Parent has no auto-layout (layoutMode='NONE') — typical for relative
|
|
1235
|
+
// positioning containers and absolutely-positioned overlays. Direct
|
|
1236
|
+
// resize to parent's content box, mirroring CSS `height: 100%`.
|
|
1237
|
+
try {
|
|
1238
|
+
const padding = (parent.paddingTop || 0) + (parent.paddingBottom || 0);
|
|
1239
|
+
const targetHeight = Math.max(0, parent.height - padding);
|
|
1240
|
+
if (targetHeight > 0) {
|
|
1241
|
+
child.resize(child.width, targetHeight);
|
|
1242
|
+
if ('counterAxisSizingMode' in child) {
|
|
1243
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1244
|
+
}
|
|
1245
|
+
if ('primaryAxisSizingMode' in child) {
|
|
1246
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
} catch (_err) {
|
|
1250
|
+
// ignore
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
} catch (_err) {
|
|
1255
|
+
// ignore
|
|
1256
|
+
}
|
|
1257
|
+
reapplyDirectionalBordersIfNeeded(child);
|
|
1258
|
+
// Keep mark so we can re-apply after parent resize.
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Vertical mirror of the hasFullHeight post-pass that already runs inside
|
|
1263
|
+
* `applyFullWidthIfPossible` for HORIZONTAL parents: once a VERTICAL parent
|
|
1264
|
+
* has a FIXED primary axis (set by `resolveStoryLayoutHeight`), pin each grow
|
|
1265
|
+
* child's own primary-axis sizing to FIXED so the cascade continues. Figma
|
|
1266
|
+
* auto-layout resizes the grow child to its proportional share; this just
|
|
1267
|
+
* locks that allocation in place so the child's own descendants (`h-full`,
|
|
1268
|
+
* nested `flex-1`) keep resolving correctly.
|
|
1269
|
+
*/
|
|
1270
|
+
export function enforceGrowChildPrimaryFixed(child: SceneNode, parent: FrameNode): void {
|
|
1271
|
+
if (!parent || parent.layoutMode !== 'VERTICAL') return;
|
|
1272
|
+
if (parent.primaryAxisSizingMode !== 'FIXED') return;
|
|
1273
|
+
if (!('layoutGrow' in child)) return;
|
|
1274
|
+
const grow = child.layoutGrow;
|
|
1275
|
+
if (!Number.isFinite(grow) || grow <= 0) return;
|
|
1276
|
+
if (!('layoutMode' in child)) return;
|
|
1277
|
+
const childLayout = child.layoutMode;
|
|
1278
|
+
try {
|
|
1279
|
+
if (childLayout === 'VERTICAL' && 'primaryAxisSizingMode' in child) {
|
|
1280
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1281
|
+
} else if (childLayout === 'HORIZONTAL' && 'counterAxisSizingMode' in child) {
|
|
1282
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1283
|
+
}
|
|
1284
|
+
} catch (_err) {
|
|
1285
|
+
// ignore
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
export function applyGridColumnsIfPossible(
|
|
1290
|
+
frame: FrameNode,
|
|
1291
|
+
widthOverride?: number,
|
|
1292
|
+
colsOverride?: number
|
|
1293
|
+
): void {
|
|
1294
|
+
const cols = colsOverride != null ? colsOverride : GRID_COLUMNS_NODES.get(frame);
|
|
1295
|
+
// A single-column grid (`grid` with no `grid-cols-N`, or an explicit
|
|
1296
|
+
// `grid-cols-1`) is CSS-semantically a vertical stack. Flipping to
|
|
1297
|
+
// HORIZONTAL + WRAP for it produces a one-row layout that
|
|
1298
|
+
// Hug-grows along its counter axis to the sum of children — exactly
|
|
1299
|
+
// the bench-renders-horizontal bug that recurred at multiple call
|
|
1300
|
+
// sites until the guard moved here. Single source of truth: callers
|
|
1301
|
+
// can forward whatever `cols` they extracted and rely on this gate.
|
|
1302
|
+
if (!cols || cols <= 1) return;
|
|
1303
|
+
if (colsOverride != null) {
|
|
1304
|
+
GRID_COLUMNS_NODES.set(frame, cols);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const wasVerticalBeforeFlip = frame.layoutMode === 'VERTICAL';
|
|
1308
|
+
try {
|
|
1309
|
+
if (frame.layoutMode !== 'HORIZONTAL') {
|
|
1310
|
+
frame.layoutMode = 'HORIZONTAL';
|
|
1311
|
+
}
|
|
1312
|
+
if (frame.layoutWrap !== undefined) {
|
|
1313
|
+
frame.layoutWrap = 'WRAP';
|
|
1314
|
+
}
|
|
1315
|
+
// When flipping from VERTICAL, the old height (computed from stacked children)
|
|
1316
|
+
// lingers on the counter axis and causes STRETCH-aligned children to inflate to
|
|
1317
|
+
// that stale value. Reset counter-axis sizing to AUTO so the frame re-hugs the
|
|
1318
|
+
// true row height of the new horizontal-wrap layout.
|
|
1319
|
+
if (wasVerticalBeforeFlip && 'counterAxisSizingMode' in frame) {
|
|
1320
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
1321
|
+
}
|
|
1322
|
+
} catch (_err) {
|
|
1323
|
+
// ignore
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
const totalWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
|
|
1327
|
+
if (!totalWidth || totalWidth <= 0) return;
|
|
1328
|
+
|
|
1329
|
+
const gap = frame.itemSpacing || 0;
|
|
1330
|
+
const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
|
|
1331
|
+
const available = totalWidth - padding - gap * (cols - 1);
|
|
1332
|
+
if (available <= 0) return;
|
|
1333
|
+
|
|
1334
|
+
const childWidth = Math.max(0, available / cols);
|
|
1335
|
+
if (frame.counterAxisSpacing !== undefined) {
|
|
1336
|
+
frame.counterAxisSpacing = gap;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
for (const child of frame.children) {
|
|
1340
|
+
if (!('resize' in child)) continue;
|
|
1341
|
+
try {
|
|
1342
|
+
if ('layoutPositioning' in child && child.layoutPositioning === 'ABSOLUTE') continue;
|
|
1343
|
+
const span = Math.min(COL_SPAN_NODES.get(child) ?? 1, cols);
|
|
1344
|
+
const spanWidth = childWidth * span + gap * (span - 1);
|
|
1345
|
+
// For text nodes, fix the width so textAlignHorizontal (e.g. text-right) takes effect.
|
|
1346
|
+
// Without this, WIDTH_AND_HEIGHT auto-resize snaps the node back to text-fit width.
|
|
1347
|
+
if (child.type === 'TEXT' && child.textAutoResize !== undefined) {
|
|
1348
|
+
child.textAutoResize = 'HEIGHT';
|
|
1349
|
+
}
|
|
1350
|
+
// Track whether the child has an explicit fixed height (h-*, size-*, etc.).
|
|
1351
|
+
// Can't rely on counterAxisSizingMode alone because Figma promotes it to FIXED
|
|
1352
|
+
// as a side effect of resize() and of STRETCH-inheritance from row height.
|
|
1353
|
+
const hadFixedHeight = FIXED_HEIGHT_NODES.has(child);
|
|
1354
|
+
child.resize(spanWidth, child.height);
|
|
1355
|
+
// Prevent grid children from expanding beyond calculated width
|
|
1356
|
+
if ('layoutGrow' in child) {
|
|
1357
|
+
child.layoutGrow = 0;
|
|
1358
|
+
}
|
|
1359
|
+
if ('layoutMode' in child) {
|
|
1360
|
+
const childLayout = child.layoutMode;
|
|
1361
|
+
if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
1362
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1363
|
+
} else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
1364
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1365
|
+
// Let height hug content unless the child had an explicit h-* class.
|
|
1366
|
+
// Two things conspire to freeze the wrong height:
|
|
1367
|
+
// 1. resize() above promotes counterAxisSizingMode to FIXED.
|
|
1368
|
+
// 2. Figma can STRETCH an un-fixed child to the row height (input's 36px),
|
|
1369
|
+
// so its snapshot already reads FIXED before we look.
|
|
1370
|
+
// Resetting layoutAlign to INHERIT prevents re-stretch after we go AUTO.
|
|
1371
|
+
if (!hadFixedHeight) {
|
|
1372
|
+
if ('layoutAlign' in child && child.layoutAlign === 'STRETCH') {
|
|
1373
|
+
child.layoutAlign = 'INHERIT';
|
|
1374
|
+
}
|
|
1375
|
+
if ('counterAxisSizingMode' in child) {
|
|
1376
|
+
child.counterAxisSizingMode = 'AUTO';
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
} else if ('primaryAxisSizingMode' in child) {
|
|
1381
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1382
|
+
}
|
|
1383
|
+
reapplyDirectionalBordersIfNeeded(child);
|
|
1384
|
+
} catch (_err) {
|
|
1385
|
+
// ignore
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
export function hasGridColumnsNode(node: SceneNode): boolean {
|
|
1391
|
+
return GRID_COLUMNS_NODES.has(node);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
export function getGridColumnsNode(node: SceneNode): number | null {
|
|
1395
|
+
const cols = GRID_COLUMNS_NODES.get(node);
|
|
1396
|
+
return cols != null ? cols : null;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
export function applyFlexGrowIfPossible(frame: FrameNode, widthOverride?: number): void {
|
|
1400
|
+
if (frame.layoutMode !== 'HORIZONTAL') return;
|
|
1401
|
+
|
|
1402
|
+
const padding = (frame.paddingLeft || 0) + (frame.paddingRight || 0);
|
|
1403
|
+
const gap = frame.itemSpacing || 0;
|
|
1404
|
+
const children = frame.children.filter(child => {
|
|
1405
|
+
if (!('layoutPositioning' in child)) return true;
|
|
1406
|
+
return child.layoutPositioning !== 'ABSOLUTE';
|
|
1407
|
+
});
|
|
1408
|
+
if (children.length === 0) return;
|
|
1409
|
+
|
|
1410
|
+
let fixedWidth = 0;
|
|
1411
|
+
let growTotal = 0;
|
|
1412
|
+
const growChildren: SceneNode[] = [];
|
|
1413
|
+
|
|
1414
|
+
for (const child of children) {
|
|
1415
|
+
if (!('layoutGrow' in child)) continue;
|
|
1416
|
+
const grow = child.layoutGrow;
|
|
1417
|
+
if (Number.isFinite(grow) && grow > 0) {
|
|
1418
|
+
growTotal += grow;
|
|
1419
|
+
growChildren.push(child);
|
|
1420
|
+
} else {
|
|
1421
|
+
fixedWidth += child.width || 0;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (growChildren.length === 0 || growTotal <= 0) return;
|
|
1426
|
+
|
|
1427
|
+
// Determine target width: use override, frame width, or compute based on children
|
|
1428
|
+
let targetWidth = widthOverride && Number.isFinite(widthOverride) ? widthOverride : frame.width;
|
|
1429
|
+
|
|
1430
|
+
// If no width available, compute a minimum width based on fixed children + space for grow children
|
|
1431
|
+
const totalGap = gap * Math.max(0, children.length - 1);
|
|
1432
|
+
if (!targetWidth || targetWidth <= 0) {
|
|
1433
|
+
// Use 150px per grow unit as a reasonable default for flex-grow children
|
|
1434
|
+
const growWidth = 150 * growTotal;
|
|
1435
|
+
targetWidth = padding + totalGap + fixedWidth + growWidth;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// Resize frame to target width and set to FIXED
|
|
1439
|
+
try {
|
|
1440
|
+
frame.resize(targetWidth, frame.height);
|
|
1441
|
+
frame.primaryAxisSizingMode = 'FIXED';
|
|
1442
|
+
} catch (_err) {
|
|
1443
|
+
// ignore
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const remaining = targetWidth - padding - totalGap - fixedWidth;
|
|
1447
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return;
|
|
1448
|
+
|
|
1449
|
+
for (const child of growChildren) {
|
|
1450
|
+
if (!('layoutGrow' in child)) continue;
|
|
1451
|
+
const grow = child.layoutGrow;
|
|
1452
|
+
const width = remaining * (grow / growTotal);
|
|
1453
|
+
if (!Number.isFinite(width) || width <= 0) continue;
|
|
1454
|
+
if (!('resize' in child)) continue;
|
|
1455
|
+
try {
|
|
1456
|
+
child.resize(width, child.height);
|
|
1457
|
+
if ('layoutMode' in child) {
|
|
1458
|
+
const childLayout = child.layoutMode;
|
|
1459
|
+
if (childLayout === 'VERTICAL' && 'counterAxisSizingMode' in child) {
|
|
1460
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1461
|
+
} else if (childLayout === 'HORIZONTAL' && 'primaryAxisSizingMode' in child) {
|
|
1462
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1463
|
+
}
|
|
1464
|
+
} else if ('primaryAxisSizingMode' in child) {
|
|
1465
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1466
|
+
}
|
|
1467
|
+
} catch (_err) {
|
|
1468
|
+
// ignore
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Vertical mirror of `applyFlexGrowIfPossible` for viewport-anchored frames.
|
|
1475
|
+
* When a VERTICAL frame has a FIXED primary axis (set by the story-level
|
|
1476
|
+
* viewport-height pre-pass), distribute the remaining height among grow
|
|
1477
|
+
* children proportional to their `layoutGrow` values. Grow children that are
|
|
1478
|
+
* themselves auto-layout frames have their own primary axis locked to FIXED so
|
|
1479
|
+
* the cascade continues into their descendants.
|
|
1480
|
+
*/
|
|
1481
|
+
export function applyVerticalFlexGrowIfPossible(frame: FrameNode): void {
|
|
1482
|
+
if (frame.layoutMode !== 'VERTICAL') return;
|
|
1483
|
+
if (frame.primaryAxisSizingMode !== 'FIXED') return;
|
|
1484
|
+
|
|
1485
|
+
const padding = (frame.paddingTop || 0) + (frame.paddingBottom || 0);
|
|
1486
|
+
const gap = frame.itemSpacing || 0;
|
|
1487
|
+
const children = frame.children.filter(child => {
|
|
1488
|
+
if (!('layoutPositioning' in child)) return true;
|
|
1489
|
+
return child.layoutPositioning !== 'ABSOLUTE';
|
|
1490
|
+
});
|
|
1491
|
+
if (children.length === 0) return;
|
|
1492
|
+
|
|
1493
|
+
let fixedHeight = 0;
|
|
1494
|
+
let growTotal = 0;
|
|
1495
|
+
const growChildren: SceneNode[] = [];
|
|
1496
|
+
|
|
1497
|
+
for (const child of children) {
|
|
1498
|
+
if (!('layoutGrow' in child)) continue;
|
|
1499
|
+
const grow = child.layoutGrow;
|
|
1500
|
+
if (Number.isFinite(grow) && grow > 0) {
|
|
1501
|
+
growTotal += grow;
|
|
1502
|
+
growChildren.push(child);
|
|
1503
|
+
} else {
|
|
1504
|
+
fixedHeight += child.height || 0;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
if (growChildren.length === 0 || growTotal <= 0) return;
|
|
1509
|
+
|
|
1510
|
+
const totalGap = gap * Math.max(0, children.length - 1);
|
|
1511
|
+
const remaining = frame.height - padding - totalGap - fixedHeight;
|
|
1512
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return;
|
|
1513
|
+
|
|
1514
|
+
for (const child of growChildren) {
|
|
1515
|
+
if (!('layoutGrow' in child)) continue;
|
|
1516
|
+
const grow = child.layoutGrow;
|
|
1517
|
+
const height = remaining * (grow / growTotal);
|
|
1518
|
+
if (!Number.isFinite(height) || height <= 0) continue;
|
|
1519
|
+
if (!('resize' in child)) continue;
|
|
1520
|
+
try {
|
|
1521
|
+
child.resize(child.width, height);
|
|
1522
|
+
if ('layoutMode' in child) {
|
|
1523
|
+
const childLayout = child.layoutMode;
|
|
1524
|
+
if (childLayout === 'VERTICAL' && 'primaryAxisSizingMode' in child) {
|
|
1525
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1526
|
+
} else if (childLayout === 'HORIZONTAL' && 'counterAxisSizingMode' in child) {
|
|
1527
|
+
child.counterAxisSizingMode = 'FIXED';
|
|
1528
|
+
}
|
|
1529
|
+
} else if ('primaryAxisSizingMode' in child) {
|
|
1530
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
1531
|
+
}
|
|
1532
|
+
} catch (_err) {
|
|
1533
|
+
// ignore
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// ---------------------------------------------------------------------------
|
|
1539
|
+
// Exports for tailwind.ts internal use (mark functions called during CSS processing)
|
|
1540
|
+
// ---------------------------------------------------------------------------
|
|
1541
|
+
|
|
1542
|
+
export {
|
|
1543
|
+
markFullHeightNode,
|
|
1544
|
+
markFixedHeightNode,
|
|
1545
|
+
markFixedWidthNode,
|
|
1546
|
+
markSelfAlignmentNode,
|
|
1547
|
+
markFlexBasisNode,
|
|
1548
|
+
markMinWidthNode,
|
|
1549
|
+
markMaxWidthNode,
|
|
1550
|
+
markGridColumnsNode,
|
|
1551
|
+
markColSpanNode,
|
|
1552
|
+
markFractionWidthNode,
|
|
1553
|
+
parseFractionToken,
|
|
1554
|
+
BORDER_WIDTH_CLASSES,
|
|
1555
|
+
DEFERRED_TOP_RELATIVE_NODES,
|
|
1556
|
+
};
|