GAP-1: add 'fontWeight' to the text-node key list in resolveNodeForCanvas so
$type-*-weight refs resolve to a number before reaching the renderer.
GAP-2: replace the early-exit `if (!variables || Object.keys(variables).length === 0)
return node` with `if (!variables) variables = {}` so DEFAULT_PALETTE_FALLBACK
fires even when the document has an empty variables map (un-seeded v1 docs).
Adds 2 new tests to fallback-equivalence.test.ts covering both gaps.
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Variable resolution utilities.
|
|
*
|
|
* Resolves `$variableName` references against a VariableDefinition map,
|
|
* optionally matching themed values to an active theme context.
|
|
*/
|
|
|
|
import type { PenNode } from '@zseven-w/pen-types';
|
|
import type { PenFill, PenStroke, PenEffect } from '@zseven-w/pen-types';
|
|
import type { VariableDefinition, ThemedValue } from '@zseven-w/pen-types';
|
|
import { DEFAULT_PALETTE_FALLBACK } from './default-palette-fallback.js';
|
|
|
|
type Vars = Record<string, VariableDefinition>;
|
|
type Theme = Record<string, string>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Check whether a value is a `$variable` reference string. */
|
|
export function isVariableRef(value: unknown): value is string {
|
|
return typeof value === 'string' && value.startsWith('$');
|
|
}
|
|
|
|
/** Build the default theme map (first value per axis) from PenDocument.themes. */
|
|
export function getDefaultTheme(themes: Record<string, string[]> | undefined): Theme {
|
|
const result: Theme = {};
|
|
if (!themes) return result;
|
|
for (const [key, values] of Object.entries(themes)) {
|
|
if (values.length > 0) result[key] = values[0];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Pick the concrete value from a `ThemedValue[]` for the given theme. */
|
|
function resolveThemedValue(
|
|
values: ThemedValue[],
|
|
activeTheme?: Theme,
|
|
): string | number | boolean | undefined {
|
|
if (activeTheme && Object.keys(activeTheme).length > 0) {
|
|
const match = values.find((v) => {
|
|
if (!v.theme) return false;
|
|
return Object.entries(activeTheme).every(([key, expected]) => v.theme?.[key] === expected);
|
|
});
|
|
if (match) return match.value;
|
|
}
|
|
return values[0]?.value;
|
|
}
|
|
|
|
/**
|
|
* Resolve a single `$variableName` reference to its concrete value.
|
|
* Returns `undefined` if the variable does not exist or has an incompatible type.
|
|
*/
|
|
export function resolveVariableRef(
|
|
ref: string,
|
|
variables: Vars,
|
|
activeTheme?: Theme,
|
|
): string | number | boolean | undefined {
|
|
if (!ref.startsWith('$')) return undefined;
|
|
const name = ref.slice(1);
|
|
const def = variables[name];
|
|
if (!def) {
|
|
// P1.6: fall back to DEFAULT_PALETTE_FALLBACK when vars doesn't have the token
|
|
const fallback = DEFAULT_PALETTE_FALLBACK[name];
|
|
if (fallback !== undefined) {
|
|
if (fallback.single !== undefined) return fallback.single;
|
|
const mode = activeTheme?.Mode ?? 'Light';
|
|
return mode === 'Dark' ? fallback.dark : fallback.light;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const val = def.value;
|
|
if (Array.isArray(val)) {
|
|
const resolved = resolveThemedValue(val, activeTheme);
|
|
// Circular guard: if resolved value is also a $ref, stop
|
|
if (typeof resolved === 'string' && resolved.startsWith('$')) return undefined;
|
|
return resolved;
|
|
}
|
|
// Circular guard
|
|
if (typeof val === 'string' && val.startsWith('$')) return undefined;
|
|
return val;
|
|
}
|
|
|
|
/**
|
|
* Resolve a color string that may be a `$variable` reference.
|
|
* Returns the original string if it's not a ref, or the resolved color.
|
|
*/
|
|
export function resolveColorRef(
|
|
color: string | undefined,
|
|
variables: Vars,
|
|
activeTheme?: Theme,
|
|
): string | undefined {
|
|
if (color === undefined) return undefined;
|
|
if (!isVariableRef(color)) return color;
|
|
const resolved = resolveVariableRef(color, variables, activeTheme);
|
|
return typeof resolved === 'string' ? resolved : undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve a numeric value that may be a `$variable` reference.
|
|
* Returns the original number if it's not a ref.
|
|
*/
|
|
export function resolveNumericRef(
|
|
value: unknown,
|
|
variables: Vars,
|
|
activeTheme?: Theme,
|
|
): number | undefined {
|
|
if (typeof value === 'number') return value;
|
|
if (typeof value === 'string') {
|
|
if (isVariableRef(value)) {
|
|
const resolved = resolveVariableRef(value, variables, activeTheme);
|
|
return typeof resolved === 'number' ? resolved : undefined;
|
|
}
|
|
const num = parseFloat(value);
|
|
return isNaN(num) ? undefined : num;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fill / stroke / effect resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveFillsForCanvas(
|
|
fills: PenFill[] | string | undefined,
|
|
vars: Vars,
|
|
theme?: Theme,
|
|
): PenFill[] | string | undefined {
|
|
if (!fills) return fills;
|
|
if (typeof fills === 'string') return fills;
|
|
return fills.map((fill) => {
|
|
if (fill.type === 'solid') {
|
|
const color = resolveColorRef(fill.color, vars, theme);
|
|
return color !== fill.color ? { ...fill, color: color ?? '#000000' } : fill;
|
|
}
|
|
if (fill.type === 'linear_gradient' || fill.type === 'radial_gradient') {
|
|
const newStops = fill.stops.map((stop) => {
|
|
const color = resolveColorRef(stop.color, vars, theme);
|
|
return color !== stop.color ? { ...stop, color: color ?? '#000000' } : stop;
|
|
});
|
|
return newStops !== fill.stops ? { ...fill, stops: newStops } : fill;
|
|
}
|
|
return fill;
|
|
});
|
|
}
|
|
|
|
function resolveStrokeForCanvas(
|
|
stroke: PenStroke | undefined,
|
|
vars: Vars,
|
|
theme?: Theme,
|
|
): PenStroke | undefined {
|
|
if (!stroke) return stroke;
|
|
let changed = false;
|
|
const out: Record<string, unknown> = { ...stroke };
|
|
|
|
// Resolve thickness
|
|
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
|
|
out.thickness = resolveNumericRef(stroke.thickness, vars, theme) ?? 1;
|
|
changed = true;
|
|
}
|
|
|
|
// Resolve stroke fill colors
|
|
if (stroke.fill) {
|
|
const resolved = resolveFillsForCanvas(stroke.fill, vars, theme);
|
|
if (resolved !== stroke.fill) {
|
|
out.fill = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? (out as unknown as PenStroke) : stroke;
|
|
}
|
|
|
|
function resolveEffectsForCanvas(
|
|
effects: PenEffect[] | undefined,
|
|
vars: Vars,
|
|
theme?: Theme,
|
|
): PenEffect[] | undefined {
|
|
if (!effects) return effects;
|
|
return effects.map((effect) => {
|
|
if (effect.type !== 'shadow') return effect;
|
|
let changed = false;
|
|
const out: Record<string, unknown> = { ...effect };
|
|
|
|
if (typeof effect.color === 'string' && isVariableRef(effect.color)) {
|
|
out.color = resolveColorRef(effect.color, vars, theme) ?? '#000000';
|
|
changed = true;
|
|
}
|
|
for (const key of ['blur', 'offsetX', 'offsetY', 'spread'] as const) {
|
|
const val = effect[key];
|
|
if (typeof val === 'string' && isVariableRef(val)) {
|
|
out[key] = resolveNumericRef(val, vars, theme) ?? 0;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? (out as unknown as PenEffect) : effect;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full node resolution for canvas rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve all `$variable` references in a PenNode, returning a new node
|
|
* with concrete values suitable for Fabric.js rendering.
|
|
*
|
|
* Returns the same object reference when no variables are present.
|
|
*/
|
|
export function resolveNodeForCanvas(node: PenNode, variables: Vars, activeTheme?: Theme): PenNode {
|
|
if (!variables) variables = {};
|
|
|
|
let changed = false;
|
|
const out: Record<string, unknown> = { ...node };
|
|
|
|
// Opacity
|
|
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
|
|
out.opacity = resolveNumericRef(node.opacity, variables, activeTheme) ?? 1;
|
|
changed = true;
|
|
}
|
|
|
|
// Gap
|
|
if ('gap' in node && typeof (node as unknown as Record<string, unknown>).gap === 'string') {
|
|
const gap = (node as unknown as Record<string, unknown>).gap as string;
|
|
if (isVariableRef(gap)) {
|
|
out.gap = resolveNumericRef(gap, variables, activeTheme) ?? 0;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Padding
|
|
if ('padding' in node) {
|
|
const padding = (node as unknown as Record<string, unknown>).padding;
|
|
if (typeof padding === 'string' && isVariableRef(padding)) {
|
|
out.padding = resolveNumericRef(padding, variables, activeTheme) ?? 0;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Fill
|
|
if ('fill' in node && (node as unknown as Record<string, unknown>).fill) {
|
|
const fills = (node as unknown as Record<string, unknown>).fill as PenFill[] | string;
|
|
const resolved = resolveFillsForCanvas(fills, variables, activeTheme);
|
|
if (resolved !== fills) {
|
|
out.fill = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Stroke
|
|
if ('stroke' in node && (node as unknown as Record<string, unknown>).stroke) {
|
|
const stroke = (node as unknown as Record<string, unknown>).stroke as PenStroke;
|
|
const resolved = resolveStrokeForCanvas(stroke, variables, activeTheme);
|
|
if (resolved !== stroke) {
|
|
out.stroke = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Effects
|
|
if ('effects' in node && (node as unknown as Record<string, unknown>).effects) {
|
|
const effects = (node as unknown as Record<string, unknown>).effects as PenEffect[];
|
|
const resolved = resolveEffectsForCanvas(effects, variables, activeTheme);
|
|
if (resolved !== effects) {
|
|
out.effects = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Typography numeric refs (text nodes only) — fontSize / lineHeight / letterSpacing
|
|
// These must be resolved before the layout engine and renderer consume them,
|
|
// since both paths use `node.fontSize ?? 16` (not type-guarded for strings).
|
|
if (node.type === 'text') {
|
|
const n = node as unknown as Record<string, unknown>;
|
|
for (const key of ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'] as const) {
|
|
if (typeof n[key] === 'string' && isVariableRef(n[key] as string)) {
|
|
const resolved = resolveNumericRef(n[key] as string, variables, activeTheme);
|
|
if (resolved !== undefined) {
|
|
out[key] = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// cornerRadius scalar ref (frames, rectangles, etc.)
|
|
{
|
|
const n = node as unknown as Record<string, unknown>;
|
|
if (typeof n.cornerRadius === 'string' && isVariableRef(n.cornerRadius)) {
|
|
const resolved = resolveNumericRef(n.cornerRadius, variables, activeTheme);
|
|
if (resolved !== undefined) {
|
|
out.cornerRadius = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Text content
|
|
if (node.type === 'text' && typeof node.content === 'string' && isVariableRef(node.content)) {
|
|
const resolved = resolveVariableRef(node.content, variables, activeTheme);
|
|
if (typeof resolved === 'string') {
|
|
out.content = resolved;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Recurse into children
|
|
if ('children' in node && node.children) {
|
|
const children = node.children;
|
|
const resolvedChildren = children.map((child) =>
|
|
resolveNodeForCanvas(child, variables, activeTheme),
|
|
);
|
|
// Only allocate new array if any child actually changed
|
|
if (resolvedChildren.some((rc, i) => rc !== children[i])) {
|
|
out.children = resolvedChildren;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? (out as unknown as PenNode) : node;
|
|
}
|