| @@ -1,4 +1,5 @@ | ||
| 1 | +import { RICH_TEXT_ATTRIBUTES } from '@agent/lib/block-schema'; | |
| 1 | 2 | import { getBlockType, parse, serialize } from '@wordpress/blocks'; |
| 2 | 3 | import { colord } from 'colord'; |
| 3 | 4 | |
| 4 | 5 | const isMergeable = (value) => |
| @@ -67,9 +68,21 @@ | ||
| 67 | 68 | ]; |
| 68 | 69 | |
| 69 | 70 | // A known slug maps to a theme preset class; anything else routes to inline |
| 70 | 71 | // style.color.* — so "yellow" (no matching token) never lands on a wrong slug. |
| 71 | -const routeColors = (attributes, patch, colorSlugs) => { | |
| 72 | +// Kept inline, a token's own hex stops the block following the palette. | |
| 73 | +const slugForValue = (value, colorValues) => { | |
| 74 | + const wanted = colord(value); | |
| 75 | + if (!wanted.isValid()) return null; | |
| 76 | + const hex = wanted.toHex(); | |
| 77 | + const match = Object.entries(colorValues ?? {}).find(([, preset]) => { | |
| 78 | + const parsed = colord(String(preset)); | |
| 79 | + return parsed.isValid() && parsed.toHex() === hex; | |
| 80 | + }); | |
| 81 | + return match?.[0] ?? null; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const routeColors = (attributes, patch, colorSlugs, colorValues) => { | |
| 72 | 85 | const slugs = new Set(colorSlugs ?? []); |
| 73 | 86 | const stripped = {}; |
| 74 | 87 | let out = attributes; |
| 75 | 88 | for (const [named, key] of COLOR_CHANNELS) { |
| @@ -74,9 +87,13 @@ | ||
| 74 | 87 | let out = attributes; |
| 75 | 88 | for (const [named, key] of COLOR_CHANNELS) { |
| 76 | 89 | const value = patch?.[named]; |
| 77 | 90 | if (value == null) continue; |
| 78 | - if (slugs.has(value)) { | |
| 91 | + const named_slug = slugs.has(value) | |
| 92 | + ? value | |
| 93 | + : slugForValue(value, colorValues); | |
| 94 | + if (named_slug) { | |
| 95 | + if (named_slug !== value) out = setPath(out, named, named_slug); | |
| 79 | 96 | out = unsetPath(out, `style.color.${key}`); |
| 80 | 97 | continue; |
| 81 | 98 | } |
| 82 | 99 | const parsed = colord(value); |
| @@ -93,8 +110,50 @@ | ||
| 93 | 110 | } |
| 94 | 111 | return out; |
| 95 | 112 | }; |
| 96 | 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 | + | |
| 97 | 156 | // fontSize / fontFamily mirror the color routing: known slug → named attribute |
| 98 | 157 | // (preset class), anything else → the inline style path. |
| 99 | 158 | const NAMED_PRESETS = [ |
| 100 | 159 | { |
| @@ -143,8 +202,9 @@ | ||
| 143 | 202 | ['backgroundColor', 'style.color.background'], |
| 144 | 203 | ['textColor', 'style.color.text'], |
| 145 | 204 | ['gradient', 'style.color.gradient'], |
| 146 | 205 | ...NAMED_PRESETS.map(({ named, path }) => [named, path]), |
| 206 | + ...PAIRED_COLORS.map(({ named, custom }) => [named, custom]), | |
| 147 | 207 | ]; |
| 148 | 208 | const CLEAR_ALIASES = Object.fromEntries( |
| 149 | 209 | ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])), |
| 150 | 210 | ); |
| @@ -162,19 +222,65 @@ | ||
| 162 | 222 | } |
| 163 | 223 | return out; |
| 164 | 224 | }; |
| 165 | 225 | |
| 166 | -// The model always says `text`; blocks whose rich text is `content` need the remap. | |
| 226 | +// The model always says `text`; blocks that keep theirs elsewhere need the remap. | |
| 167 | 227 | const remapText = (block, patch) => { |
| 168 | 228 | if (patch?.text == null) return patch; |
| 169 | 229 | const attributes = getBlockType(block.name)?.attributes ?? {}; |
| 170 | - if (attributes.text || !attributes.content) return patch; | |
| 230 | + const real = RICH_TEXT_ATTRIBUTES.find((name) => attributes[name]); | |
| 231 | + if (!real || real === 'text') return patch; | |
| 171 | 232 | const { text, ...rest } = patch; |
| 172 | - return { ...rest, content: text }; | |
| 233 | + return { ...rest, [real]: text }; | |
| 173 | 234 | }; |
| 174 | 235 | |
| 175 | 236 | // Re-serializing runs the block's own save(), which core/button needs to render |
| 176 | 237 | // the color/border styles it marks __experimentalSkipSerialization. |
| 238 | +// Merging an empty value leaves a dead key and keeps the preset class. | |
| 239 | +const emptyLeafPaths = (patch, prefix = '') => | |
| 240 | + Object.entries(patch ?? {}).flatMap(([key, value]) => { | |
| 241 | + const path = prefix ? `${prefix}.${key}` : key; | |
| 242 | + if (isMergeable(value)) return emptyLeafPaths(value, path); | |
| 243 | + return value === '' ? [path] : []; | |
| 244 | + }); | |
| 245 | + | |
| 246 | +// A block with no style would otherwise save the patch's null and emptied keys. | |
| 247 | +const withoutEmptyLeaves = (patch) => | |
| 248 | + Object.fromEntries( | |
| 249 | + Object.entries(patch ?? {}).flatMap(([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]]; | |
| 255 | + }), | |
| 256 | + ); | |
| 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 | + | |
| 177 | 283 | export const applyBlockPatch = ( |
| 178 | 284 | serializedBlock, |
| 179 | 285 | patch, |
| 180 | 286 | clear = [], |
| @@ -181,14 +287,24 @@ | ||
| 181 | 287 | presetSlugs = {}, |
| 182 | 288 | ) => { |
| 183 | 289 | const blocks = parse(serializedBlock).map((block) => { |
| 184 | 290 | if (!block.name) return block; |
| 185 | - const merged = deepMerge(block.attributes, remapText(block, patch)); | |
| 186 | - const routed = routeNamedPresets( | |
| 187 | - routeColors(merged, patch, presetSlugs.color), | |
| 188 | - patch, | |
| 189 | - presetSlugs, | |
| 291 | + const remapped = remapText(block, patch); | |
| 292 | + const filled = coerceTypes(block, withoutEmptyLeaves(remapped)); | |
| 293 | + const merged = deepMerge(block.attributes, filled); | |
| 294 | + const colored = routePairedColors( | |
| 295 | + routeColors(merged, filled, presetSlugs.color, presetSlugs.colorValues), | |
| 296 | + filled, | |
| 297 | + presetSlugs.color, | |
| 298 | + presetSlugs.colorValues, | |
| 190 | 299 | ); |
| 191 | - return { ...block, attributes: applyClears(routed, clear ?? []) }; | |
| 300 | + const routed = routeNamedPresets(colored, filled, presetSlugs); | |
| 301 | + return { | |
| 302 | + ...block, | |
| 303 | + attributes: applyClears(routed, [ | |
| 304 | + ...(clear ?? []), | |
| 305 | + ...emptyLeafPaths(remapped), | |
| 306 | + ]), | |
| 307 | + }; | |
| 192 | 308 | }); |
| 193 | 309 | return serialize(blocks); |
| 194 | 310 | }; |