|
| 1 | +import { getProps } from '../props' |
| 2 | +import { renderNode } from '../render' |
| 3 | +import { getDevupComponentByNode } from '../utils/get-devup-component' |
| 4 | +import { |
| 5 | + BREAKPOINT_ORDER, |
| 6 | + type BreakpointKey, |
| 7 | + getBreakpointByWidth, |
| 8 | + mergePropsToResponsive, |
| 9 | + optimizeResponsiveValue, |
| 10 | +} from './index' |
| 11 | + |
| 12 | +type PropValue = boolean | string | number | undefined | null | object |
| 13 | +type Props = Record<string, PropValue> |
| 14 | + |
| 15 | +interface NodePropsMap { |
| 16 | + breakpoint: BreakpointKey |
| 17 | + props: Props |
| 18 | + children: Map<string, NodePropsMap[]> |
| 19 | + nodeType: string |
| 20 | + nodeName: string |
| 21 | + node: SceneNode |
| 22 | +} |
| 23 | + |
| 24 | +/** |
| 25 | + * Generate responsive code by merging children inside a Section. |
| 26 | + */ |
| 27 | +export class ResponsiveCodegen { |
| 28 | + private breakpointNodes: Map<BreakpointKey, SceneNode> = new Map() |
| 29 | + |
| 30 | + constructor(private sectionNode: SectionNode) { |
| 31 | + this.categorizeChildren() |
| 32 | + } |
| 33 | + |
| 34 | + /** |
| 35 | + * Group Section children by width to decide breakpoints. |
| 36 | + */ |
| 37 | + private categorizeChildren() { |
| 38 | + for (const child of this.sectionNode.children) { |
| 39 | + if ('width' in child) { |
| 40 | + const breakpoint = getBreakpointByWidth(child.width) |
| 41 | + // If multiple nodes share a breakpoint, keep the first. |
| 42 | + if (!this.breakpointNodes.has(breakpoint)) { |
| 43 | + this.breakpointNodes.set(breakpoint, child) |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + } |
| 48 | + |
| 49 | + /** |
| 50 | + * Recursively extract props and children from a node. |
| 51 | + * Reuses getProps. |
| 52 | + */ |
| 53 | + private async extractNodeProps( |
| 54 | + node: SceneNode, |
| 55 | + breakpoint: BreakpointKey, |
| 56 | + ): Promise<NodePropsMap> { |
| 57 | + const props = await getProps(node) |
| 58 | + const children = new Map<string, NodePropsMap[]>() |
| 59 | + |
| 60 | + if ('children' in node) { |
| 61 | + for (const child of node.children) { |
| 62 | + const childProps = await this.extractNodeProps(child, breakpoint) |
| 63 | + const existing = children.get(child.name) || [] |
| 64 | + existing.push(childProps) |
| 65 | + children.set(child.name, existing) |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + return { |
| 70 | + breakpoint, |
| 71 | + props, |
| 72 | + children, |
| 73 | + nodeType: node.type, |
| 74 | + nodeName: node.name, |
| 75 | + node, |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Generate responsive code. |
| 81 | + */ |
| 82 | + async generateResponsiveCode(): Promise<string> { |
| 83 | + if (this.breakpointNodes.size === 0) { |
| 84 | + return '// No responsive variants found in section' |
| 85 | + } |
| 86 | + |
| 87 | + if (this.breakpointNodes.size === 1) { |
| 88 | + // If only one breakpoint, generate normal code (reuse existing path). |
| 89 | + const [, node] = [...this.breakpointNodes.entries()][0] |
| 90 | + return await this.generateNodeCode(node, 0) |
| 91 | + } |
| 92 | + |
| 93 | + // Extract props per breakpoint node. |
| 94 | + const breakpointNodeProps = new Map<BreakpointKey, NodePropsMap>() |
| 95 | + for (const [bp, node] of this.breakpointNodes) { |
| 96 | + const nodeProps = await this.extractNodeProps(node, bp) |
| 97 | + breakpointNodeProps.set(bp, nodeProps) |
| 98 | + } |
| 99 | + |
| 100 | + // Merge responsively and generate code. |
| 101 | + return await this.generateMergedCode(breakpointNodeProps, 0) |
| 102 | + } |
| 103 | + |
| 104 | + /** |
| 105 | + * Generate merged responsive code. |
| 106 | + * Reuses renderNode. |
| 107 | + */ |
| 108 | + private async generateMergedCode( |
| 109 | + nodesByBreakpoint: Map<BreakpointKey, NodePropsMap>, |
| 110 | + depth: number, |
| 111 | + ): Promise<string> { |
| 112 | + // Merge props. |
| 113 | + const propsMap = new Map<BreakpointKey, Props>() |
| 114 | + for (const [bp, nodeProps] of nodesByBreakpoint) { |
| 115 | + propsMap.set(bp, nodeProps.props) |
| 116 | + } |
| 117 | + const mergedProps = mergePropsToResponsive(propsMap) |
| 118 | + |
| 119 | + // Decide component type from the first node (reuse existing util). |
| 120 | + const firstNodeProps = [...nodesByBreakpoint.values()][0] |
| 121 | + const component = getDevupComponentByNode( |
| 122 | + firstNodeProps.node, |
| 123 | + firstNodeProps.props, |
| 124 | + ) |
| 125 | + |
| 126 | + // Merge child nodes (preserve order). |
| 127 | + const childrenCodes: string[] = [] |
| 128 | + const processedChildNames = new Set<string>() |
| 129 | + |
| 130 | + // Base order on the first breakpoint children. |
| 131 | + const firstBreakpointChildren = firstNodeProps.children |
| 132 | + const allChildNames: string[] = [] |
| 133 | + |
| 134 | + // Keep the first breakpoint child order. |
| 135 | + for (const name of firstBreakpointChildren.keys()) { |
| 136 | + allChildNames.push(name) |
| 137 | + processedChildNames.add(name) |
| 138 | + } |
| 139 | + |
| 140 | + // Add children that exist only in other breakpoints. |
| 141 | + for (const nodeProps of nodesByBreakpoint.values()) { |
| 142 | + for (const name of nodeProps.children.keys()) { |
| 143 | + if (!processedChildNames.has(name)) { |
| 144 | + allChildNames.push(name) |
| 145 | + processedChildNames.add(name) |
| 146 | + } |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + for (const childName of allChildNames) { |
| 151 | + const childByBreakpoint = new Map<BreakpointKey, NodePropsMap>() |
| 152 | + const presentBreakpoints = new Set<BreakpointKey>() |
| 153 | + |
| 154 | + for (const [bp, nodeProps] of nodesByBreakpoint) { |
| 155 | + const children = nodeProps.children.get(childName) |
| 156 | + if (children && children.length > 0) { |
| 157 | + childByBreakpoint.set(bp, children[0]) |
| 158 | + presentBreakpoints.add(bp) |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + if (childByBreakpoint.size > 0) { |
| 163 | + // Add display props when a child exists only at specific breakpoints. |
| 164 | + if (presentBreakpoints.size < nodesByBreakpoint.size) { |
| 165 | + const displayProps = this.getDisplayProps( |
| 166 | + presentBreakpoints, |
| 167 | + new Set(nodesByBreakpoint.keys()), |
| 168 | + ) |
| 169 | + for (const nodeProps of childByBreakpoint.values()) { |
| 170 | + Object.assign(nodeProps.props, displayProps) |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + const childCode = await this.generateMergedCode( |
| 175 | + childByBreakpoint, |
| 176 | + depth, |
| 177 | + ) |
| 178 | + childrenCodes.push(childCode) |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + // Reuse renderNode. |
| 183 | + return renderNode(component, mergedProps, depth, childrenCodes) |
| 184 | + } |
| 185 | + |
| 186 | + /** |
| 187 | + * Build display props so a child shows only on present breakpoints. |
| 188 | + */ |
| 189 | + private getDisplayProps( |
| 190 | + presentBreakpoints: Set<BreakpointKey>, |
| 191 | + allBreakpoints: Set<BreakpointKey>, |
| 192 | + ): Props { |
| 193 | + // Always use 5 slots: [mobile, sm, tablet, lg, pc] |
| 194 | + // If the child exists on the breakpoint: null (visible); otherwise 'none' (hidden). |
| 195 | + // If the Section lacks that breakpoint entirely: null. |
| 196 | + const displayValues: (string | null)[] = BREAKPOINT_ORDER.map((bp) => { |
| 197 | + if (!allBreakpoints.has(bp)) return null // Section lacks this breakpoint |
| 198 | + return presentBreakpoints.has(bp) ? null : 'none' |
| 199 | + }) |
| 200 | + |
| 201 | + // If all null, return empty object. |
| 202 | + if (displayValues.every((v) => v === null)) { |
| 203 | + return {} |
| 204 | + } |
| 205 | + |
| 206 | + // Remove trailing nulls only (keep leading nulls). |
| 207 | + while ( |
| 208 | + displayValues.length > 0 && |
| 209 | + displayValues[displayValues.length - 1] === null |
| 210 | + ) { |
| 211 | + displayValues.pop() |
| 212 | + } |
| 213 | + |
| 214 | + // Empty array => empty object. |
| 215 | + if (displayValues.length === 0) { |
| 216 | + return {} |
| 217 | + } |
| 218 | + |
| 219 | + return { display: optimizeResponsiveValue(displayValues) } |
| 220 | + } |
| 221 | + |
| 222 | + /** |
| 223 | + * Generate code for a single node (fallback). |
| 224 | + * Reuses existing module. |
| 225 | + */ |
| 226 | + private async generateNodeCode( |
| 227 | + node: SceneNode, |
| 228 | + depth: number, |
| 229 | + ): Promise<string> { |
| 230 | + const props = await getProps(node) |
| 231 | + const childrenCodes: string[] = [] |
| 232 | + |
| 233 | + if ('children' in node) { |
| 234 | + for (const child of node.children) { |
| 235 | + const childCode = await this.generateNodeCode(child, depth + 1) |
| 236 | + childrenCodes.push(childCode) |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + const component = getDevupComponentByNode(node, props) |
| 241 | + return renderNode(component, props, depth, childrenCodes) |
| 242 | + } |
| 243 | + |
| 244 | + /** |
| 245 | + * Check if node is Section and can generate responsive. |
| 246 | + */ |
| 247 | + static canGenerateResponsive(node: SceneNode): node is SectionNode { |
| 248 | + return node.type === 'SECTION' |
| 249 | + } |
| 250 | + |
| 251 | + /** |
| 252 | + * Return parent Section if exists. |
| 253 | + */ |
| 254 | + static hasParentSection(node: SceneNode): SectionNode | null { |
| 255 | + if (node.parent?.type === 'SECTION') { |
| 256 | + return node.parent as SectionNode |
| 257 | + } |
| 258 | + return null |
| 259 | + } |
| 260 | +} |
0 commit comments