| @@ -110,8 +110,50 @@ | ||
| 110 | 110 | } |
| 111 | 111 | return out; |
| 112 | 112 | }; |
| 113 | 113 | |
| 114 | +// Colours WP stores as a slug attribute beside a custom-value attribute. | |
| 115 | +const PAIRED_COLORS = [ | |
| 116 | + { | |
| 117 | + named: 'overlayColor', | |
| 118 | + custom: 'customOverlayColor', | |
| 119 | + flag: 'isUserOverlayColor', | |
| 120 | + }, | |
| 121 | + // The icon renders from the value alone; the slug only adds a class. | |
| 122 | + { named: 'iconColor', custom: 'iconColorValue', valueForSlug: true }, | |
| 123 | + { | |
| 124 | + named: 'iconBackgroundColor', | |
| 125 | + custom: 'iconBackgroundColorValue', | |
| 126 | + valueForSlug: true, | |
| 127 | + }, | |
| 128 | +]; | |
| 129 | + | |
| 130 | +const routePairedColors = (attributes, patch, colorSlugs, colorValues) => { | |
| 131 | + const slugs = new Set(colorSlugs ?? []); | |
| 132 | + let out = attributes; | |
| 133 | + for (const { named, custom, flag, valueForSlug } of PAIRED_COLORS) { | |
| 134 | + const value = patch?.[named]; | |
| 135 | + if (value == null) continue; | |
| 136 | + const slug = slugs.has(value) ? value : slugForValue(value, colorValues); | |
| 137 | + if (slug) { | |
| 138 | + out = { ...out, [named]: slug }; | |
| 139 | + const slugHex = colorValues?.[slug]; | |
| 140 | + out = | |
| 141 | + valueForSlug && slugHex | |
| 142 | + ? { ...out, [custom]: colord(String(slugHex)).toHex() } | |
| 143 | + : unsetPath(out, custom); | |
| 144 | + } else { | |
| 145 | + const parsed = colord(value); | |
| 146 | + out = unsetPath( | |
| 147 | + { ...out, [custom]: parsed.isValid() ? parsed.toHex() : value }, | |
| 148 | + named, | |
| 149 | + ); | |
| 150 | + } | |
| 151 | + if (flag) out = { ...out, [flag]: true }; | |
| 152 | + } | |
| 153 | + return out; | |
| 154 | +}; | |
| 155 | + | |
| 114 | 156 | // fontSize / fontFamily mirror the color routing: known slug → named attribute |
| 115 | 157 | // (preset class), anything else → the inline style path. |
| 116 | 158 | const NAMED_PRESETS = [ |
| 117 | 159 | { |
| @@ -160,8 +202,9 @@ | ||
| 160 | 202 | ['backgroundColor', 'style.color.background'], |
| 161 | 203 | ['textColor', 'style.color.text'], |
| 162 | 204 | ['gradient', 'style.color.gradient'], |
| 163 | 205 | ...NAMED_PRESETS.map(({ named, path }) => [named, path]), |
| 206 | + ...PAIRED_COLORS.map(({ named, custom }) => [named, custom]), | |
| 164 | 207 | ]; |
| 165 | 208 | const CLEAR_ALIASES = Object.fromEntries( |
| 166 | 209 | ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])), |
| 167 | 210 | ); |
| @@ -199,16 +242,45 @@ | ||
| 199 | 242 | if (isMergeable(value)) return emptyLeafPaths(value, path); |
| 200 | 243 | return value === '' ? [path] : []; |
| 201 | 244 | }); |
| 202 | 245 | |
| 246 | +// A block with no style would otherwise save the patch's null and emptied keys. | |
| 203 | 247 | const withoutEmptyLeaves = (patch) => |
| 204 | 248 | Object.fromEntries( |
| 205 | 249 | Object.entries(patch ?? {}).flatMap(([key, value]) => { |
| 206 | - if (isMergeable(value)) return [[key, withoutEmptyLeaves(value)]]; | |
| 207 | - return value === '' ? [] : [[key, value]]; | |
| 250 | + if (isMergeable(value)) { | |
| 251 | + const kept = withoutEmptyLeaves(value); | |
| 252 | + return Object.keys(kept).length ? [[key, kept]] : []; | |
| 253 | + } | |
| 254 | + return value === '' || value == null ? [] : [[key, value]]; | |
| 208 | 255 | }), |
| 209 | 256 | ); |
| 210 | 257 | |
| 258 | +// Patch values arrive as strings; the parser drops "30" on a number attribute. | |
| 259 | +const coerceTypes = (block, patch) => { | |
| 260 | + const attributes = getBlockType(block.name)?.attributes ?? {}; | |
| 261 | + let out = patch; | |
| 262 | + for (const [key, value] of Object.entries(patch ?? {})) { | |
| 263 | + if (typeof value !== 'string') continue; | |
| 264 | + const type = attributes[key]?.type; | |
| 265 | + if (type === 'boolean' && ['true', 'false'].includes(value)) { | |
| 266 | + out = { ...out, [key]: value === 'true' }; | |
| 267 | + continue; | |
| 268 | + } | |
| 269 | + if (type !== 'number') continue; | |
| 270 | + const [, amount, unit] = | |
| 271 | + value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i) ?? []; | |
| 272 | + if (amount == null) continue; | |
| 273 | + out = { ...out, [key]: Number(amount) }; | |
| 274 | + // The model writes lengths like "60vh" into the number field. | |
| 275 | + const unitKey = `${key}Unit`; | |
| 276 | + if (unit && attributes[unitKey] && out[unitKey] == null) { | |
| 277 | + out = { ...out, [unitKey]: unit }; | |
| 278 | + } | |
| 279 | + } | |
| 280 | + return out; | |
| 281 | +}; | |
| 282 | + | |
| 211 | 283 | export const applyBlockPatch = ( |
| 212 | 284 | serializedBlock, |
| 213 | 285 | patch, |
| 214 | 286 | clear = [], |
| @@ -216,15 +288,17 @@ | ||
| 216 | 288 | ) => { |
| 217 | 289 | const blocks = parse(serializedBlock).map((block) => { |
| 218 | 290 | if (!block.name) return block; |
| 219 | 291 | const remapped = remapText(block, patch); |
| 220 | - const filled = withoutEmptyLeaves(remapped); | |
| 292 | + const filled = coerceTypes(block, withoutEmptyLeaves(remapped)); | |
| 221 | 293 | const merged = deepMerge(block.attributes, filled); |
| 222 | - const routed = routeNamedPresets( | |
| 294 | + const colored = routePairedColors( | |
| 223 | 295 | routeColors(merged, filled, presetSlugs.color, presetSlugs.colorValues), |
| 224 | 296 | filled, |
| 225 | - presetSlugs, | |
| 297 | + presetSlugs.color, | |
| 298 | + presetSlugs.colorValues, | |
| 226 | 299 | ); |
| 300 | + const routed = routeNamedPresets(colored, filled, presetSlugs); | |
| 227 | 301 | return { |
| 228 | 302 | ...block, |
| 229 | 303 | attributes: applyClears(routed, [ |
| 230 | 304 | ...(clear ?? []), |