在开发过程中,有时候会遇到一个问题,显而易见的解决方案(甚至是明显的解决方案B或C)都无法奏效。这就发生在我制作Polychrom时,这是我们新的UI文本可读性插件。在我们的情况下,我从这个障碍中学到了东西,利用了解决问题的技巧来完全改变我对问题的看法,工程了一个“啊哈”时刻,然后迅速前进找到了解决方案。
Polychrom (opens new window) 是我们的免费Figma插件,可以更精确地控制UI文本的可读性。它通过使用APCA方法 (opens new window)自动检测所选项目与其背景层之间的对比度水平。为了确保用户获得完美且可读的对比值,它允许用户:根据背景轻松调整文本颜色;根据文本调整背景颜色。 这篇文章讨论了处理颜色填充混合的最终情况,并介绍了在插件内实现颜色填充混合的方法。我们是如何做到的?请继续阅读。
最初,Polychrom仅处理简单的用户场景(例如,当选择了一个只有一个不透明填充的文本对象,并且它位于另一个只有一个不透明填充的对象的背景之上)。这是我们的开发策略的一部分——我们首先实现一些简单的东西来定义主要的里程碑,然后逐渐增强插件的功能。
因此,一旦我们发布了第一个版本,我们希望让用户能够看到“复合”对象的对比度,即具有不同透明级别和不同混合模式的多个填充的对象,这些对象还与具有多个填充的其他“复合”对象相互叠加等。您可以看到一个例子。 简单与复杂案例
在左侧是简单的情况,在右侧是复杂的情况,其中包含重叠的透明形状。
你可以在视频中看到这种复杂行为的示例:
请注意,文本也会改变颜色,因为这个元素是部分透明的。
因此,我们需要能够混合用户所选择的对象的填充,并从中计算计算出的颜色。此外,我们还需要-下面的部分听起来有些不切实际-考虑到位于所选对象下方的对象,其中的填充,并为它们所有计算计算出的颜色。(然后我们将使用这两个值来获得我们最终的对比度值。)
整个解决方案可以归结为两个步骤:找到构成所选元素背景的所有相交元素,然后计算这些元素的计算颜色。 修正所有元素及其字段的计算颜色。
在解决问题时,尝试实现明显且直接的解决方案是一个好习惯。毕竟,当那些努力可以更好地用在其他地方时,就没有必要做额外的工作。
但是,正如我们将看到的那样,当这种方法没有带来成果时,通常需要改变视角。
然而,让我们像我一样开始。
第一次失败尝试:使用 Culori 直接解决任务 #
一开始接近这个任务时,我想:“颜色混合?这将会很容易。我们只需要采用某个库,完成它,然后就可以了。”因此,我的第一次尝试是直接的迭代混合方法,使用 Culori 库。
Culori 提供了 blend
函数 (opens new window),该函数接受形式为数组的颜色代码和混合模式,然后返回最终计算的颜色:
imp import { blend } from 'culori';
blend(
['rgba(255, 0, 0, 0.5)', 'rgba(0, 255, 0, 0.5)', 'rgba(0, 0, 255, 0.5)'],
'screen'
);
// ⇒ { mode: 'rgb', alpha: 0.875, r: 0.57…, g: 0.57…, b:0.57… }
但是… 它并没有完全按我们的需求工作。换句话说,我们无法完全让 Figma 世界和 Culori 世界“混合”在一起。
为什么?首先,Figma 中的每个填充和每个元素都有自己的混合模式。这些模式影响填充和图层之间的互动方式。相反,Culori 的 `blend` 只接受一个混合模式作为参数。
一个下拉菜单显示了填充的所有混合模式选项
这意味着我们不能输入一堆填充,而是必须像这样逐步循环计算计算出的颜色:
* 合并前两个 从列表中选择颜色
* 记录结果
* 选择下一个颜色
* 将其与前一个结果混合
* 重复这个过程
在代码中,看起来像这样:
import { blend } from 'culori';
export const blendFills = (fills: SolidPaint[]): SolidPaint => { const [firstFill, ...fillsToBlend] = fills;
const result = fillsToBlend.reduce((acc, fill) => { return blend([acc, fill], fill.blendMode, 'rgb'); }, firstFill);
return result; };
但这还不够。实际上,我们不能只是简单地将选定元素的第一对背景填充“粘合”在一起,因为列表中的第一个填充,具有透明值,还需要使用自己的混合模式“粘合”到其所在的对象上。
这意味着我们应该考虑所有下面的元素 用户选择的对象,一直到页面本身,并以某种方式处理它们:
* 获取所有图层
* 获取所有填充
* 找到最深的不透明填充
* 从它开始混合色调,直到选择的那个(因为不透明填充之下的填充和图层并不需要,因为它们实际上是不可见的)
在执行此过程时,我们还必须过滤禁用的填充。 😫
## 失败的尝试一(续):使用 Culori 的算法
在您继续阅读之前,我将进行一个披露:有大量的代码示例表明我如何使用这种方法进行调试,但最终,我决定这不是这个故事的重点。
因此,我留下了最终的函数供查阅,只是要注意,它们也没有起作用:
export const blendLayersColors = (layers: SceneNode[]): string | undefined => { // 跳过隐藏的元素和没有填充的元素 const visibleLayers = layers.filter((layer) => !isLayerI ```markdown const firstNonTransparentLayerIndex = visibleLayers.findIndex( (layer) => !ifLayerHasTransparency(layer) );
// 无需考虑不透明元素下面的填充,混合将从不透明图层开始从底部向上 const layersToBlend = visibleLayers.slice( 0, firstNonTransparentLayerIndex + 1 );
// 通过正确排列顺序,从图层列表生成填充列表 const allFills = layersToBlend.map((layer) => layer.fills.reverse()).flat();
const visibleFills = allFills.filter( (fill) => fill.visible === true && fill.opacity !== 0 );
// 与元素一样 - 不透明填充下面的填充是不需要的 const firstNonTransparentFillIndex = visibleFills.findIndex( (fill) => fill.opacity === 1 );
const fillsToBlend = visibleFills .slice(0, firstNonTransparentFillIndex + 1) .reverse();
const [firstFill, ...restFills] = fillsToBlend;
// 在循环中开始混合与中间结果
顺便说一句,如果您看到这段代码并认为它可能有效,那么您是对的!作为简单情况的解决方案,这段代码确实可以正常工作。但是想象一下,用户可能会以多少种方式排列Figma元素和填充。总会有一些情况使得这段代码无法正常工作,需要不断修复和调整。
对于我们的任务来说,这种方法很不可靠。为什么呢?因为这是一种天真(但必要)的方法!最重要的是,这种方法仅适用于平面填充列表:它不考虑元素是否按照树形结构排列。
但是您知道Figma中的布局是如何组织的吧?一切都是嵌套的! ## 到了改变视角的时候
那么,现在怎么办呢?在我们跳入一些复杂的解决方案之前,让我们先停下来反思一下。这里有一个问题:是否有更简单的方法?也就是说,我们是否可以避免试图一点一点地重新发明混合机制呢?
这就是我们可以再次回顾本文开头提到的水平创新的教训的地方。从这种心态出发,一个想法突然出现:如果我们尝试以一种更“声明式”的方式混合颜色会怎样呢?
我的意思是,如果我们在代码中简单描述用户在Figma填充中看到的特定情况,然后获得结果颜色,那不是很酷吗?
例如,如果我们有一种吸管机制——比如这里有一堆图层和填充,在它们之间有透明度,很多透明度。 颜色和混合模式,并**仅以与Figma中呈现的方式相同的方式复制它们**,然后给我结果颜色。
实际上,浏览器中有这样一种机制:[`Canvas`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas)。
更具体地说,[`OffscreenCanvas`](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) 对我们来说是个合适的灵感,因为我们不需要在界面中显示我们的画布,我们只需要它在虚拟中进行计算。
> 这个想法很简单:从用户创建的Figma元素树中获取,然后将其直接传输到浏览器画布中。
但是如何获取计算出的颜色呢?[`getImageData`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData) 方法返回一个像素数组,并将每个像素的颜色作为一个包含RGBA颜色的四个数字的数组返回:`r`、`g`、`b`、`a`。
## Canvas API的一个小问题 在这种情况下,Canvas 也并非完美:这里没有分组功能。
由于我们在谈论Canvas API,我们需要明白它不是基于矢量的,而是基于光栅的。这意味着我们需要在其上绘制形状,而不能仅仅将一个元素树从Figma传输到画布上。这是一个问题,因为我们需要将Figma元素绘制为组,而Canvas 无法做到。
> 让我强调一下我们任务中的关键要素:Figma 文档中元素的树状结构和祖先 - 后代关系非常重要,因为祖先的透明度和混合模式会影响其所有后代。
使用Canvas API,我们无法使用类似祖先 - 后代范例的概念,我们只能在其中绘制形状,这意味着直接在画布上绘制并不适合我们。
我们如何简单地绘制一个结构,其中有一个父级框架,其他父级框架嵌套在其中,其他父级框架内有形状嵌套? 在其中,并且这种链中的每个元素都有自己的透明度和混合模式?
我们不能。
但是有一个解决方案:我们可以使用`SVG`格式。
SVG也理解[祖先-后代](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g)、[透明度](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/opacity)和[混合模式](https://developer.mozilla.org/en-US/docs/Web/CSS/mix-blend-mode)的概念。
最重要的是,我们可以在Canvas上使用SVG进行绘制!🎉 因此,我们可以从Figma获取元素树,将其按嵌套视角转换为SVG,保存树状结构,然后在画布上绘制它。
从那里,我们可以使用[`getImageData`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData)来获取生成图像的颜色。 Canvas API, 我们需要定义形状的视觉组合方式,以便获取数据。
在这种情况下,不需要实现用户将要使用的确切元素,我们只需要绘制一些代表元素关系及其填充的像素。
因此,我们将绘制一个由矩形组成的组合,其中所有元素和填充属于用户选择的背景将填满整个宽度,而用户选择的确切部分将位于右侧。
例如,当用户选择卡片的标题以检查标题与背景的对比时,在虚拟画布中,我们将绘制类似以下矩形组合:
## 魔法展现
现在是时候揭示我们的底牌了。首先,让我们确定我们将使用的数据格式: 树状数据结构,树的每个节点都包含多个属性。
```typescript
export interface PolychromNode {
blendMode: BlendMode;
children: PolychromNode[];
fills: FigmaPaint[];
opacity?: number;
visible?: boolean;
}
我们将使用这些数据以后递归地绘制填充和矩形。
现在,让我们为虚拟画布上的背景和前景对象定义一些框的坐标。我们还将定义“吸色器”的坐标,从这些点我们将获得有关颜色的信息:
const BACKGROUND_BOX = {
eyeDropperX: 10,
eyeDropperY: 10,
height: 10,
width: 20,
};
const FOREGROUND_BOX = {
eyeDropperX: 0,
eyeDropperY: 0,
height: 10,
width: 10,
};
让我们不要忘记考虑颜色空间,因为Figma最近添加了P3支持 (opens new window)。 我们可以使用Canvas API来管理设计文件中的颜色配置。我们可以创建一个用于将Figma数据与Canvas API进行匹配的字典:
export type FigmaColorSpace = 'DISPLAY_P3' | 'LEGACY' | 'SRGB';
export const CanvasColorSpace: Record<FigmaColorSpace, 'display-p3' | 'srgb'> =
{
DISPLAY_P3: 'display-p3',
LEGACY: 'srgb',
SRGB: 'srgb',
};
接下来,我们创建canvas
和context
对象:
const canvas = new OffscreenCanvas(
BACKGROUND_BOX.width,
BACKGROUND_BOX.height
);
const ctx = canvas.getContext('2d', {
colorSpace: CanvasColorSpace[figmaColorSpace]
});
接着我们开始绘制;我们创建一个svg
元素,在其上绘制构图,然后传递给Canvas进行渲染:
const drawNodesOnContext = async (
ctx: OffscreenCanvasRenderingContext2D,
userSelection: PolychromNode,
colorSpace: FigmaColorSpace
): Promise<void> => {
co nst drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
parentGroup.appendChild(group);
group.setAttribute('transform', `translate(${node.x}, ${node.y})`);
node.children.forEach(child => {
drawNode(child, group);
});
};
drawNode(pair, svg);
};
在SVG格式中呈现可视元素的drawNodesOnSvg
函数是一个核心部分;它通过迭代PolychromNode
节点并根据其属性应用特定属性和样式来运作:
export const drawNodesOnSvg = (
svg: SVGSVGElement,
pair: PolychromNode,
foregroundBox: CanvasRect,
backgroundBox: CanvasRect,
colorSpace: FigmaColorSpace
): void => {
const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {};
drawNode(pair, svg);
};
这里是创建SVG组的分解:对于每个节点,我们创建一个组(<g>
元素),允许我们对SVG元素进行有组织的结构化:
const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
parentGroup.appendChild(group);
group.setAttribute('transform', `translate(${node.x}, ${node.y})`);
node.children.forEach(child => {
drawNode(child, group);
});
};
drawNode(pair, svg);
};
``` nst svgGroup = document.createElementNS(
'http://www.w3.org/2000/svg',
'g'
);
现在,让我们来看一下如何设置不透明度和混合模式:这个函数根据节点的不透明度设置每个组的不透明度,并且将从 Figma 格式转换为 SVG 样式的混合模式应用于每个组,确保视觉一致性:
如果节点的不透明度不是 1,则执行以下操作:
```javascript
svgGroup.setAttribute('opacity', `${node.opacity?.toFixed(2) ?? 1}`);
映射的混合模式为 mapFigmaBlendToCanvas(node.blendMode)
。
svgGroup.setAttribute(
'style',
`mix-blend-mode: ${mappedBlendMode}; isolation: isolate;`
);
通过在 SVG 组上设置 isolation: isolate;
,我们本质上为混合模式创建了一个边界。
这意味着应用于该组内元素的混合效果只会相互作用,而不会与组外的元素相互作用。在颜色混合方面:“在这个组中发生的事情,都发生在这个组内”。 mapFigmaBlendToCanvas
函数将 Figma 的混合模式转换为 CSS 混合模式,确保在 Canvas 上呈现 Figma 设计时视觉一致性。
export const mapFigmaBlendToCanvas = (
figmaBlend?: BlendMode
): CSSProperties['mixBlendMode'] => {
const mapping: Record<BlendMode, CSSProperties['mixBlendMode']> = {
COLOR: 'color',
COLOR_BURN: 'color-burn',
COLOR_DODGE: 'color-dodge',
// ... 其他模式
}
}
下一个函数仅过滤出每个节点中可见的填充。然后,它为每个填充创建一个 SVG 矩形(<rect>
),根据节点是否被选中,确定它应该放在前景框还是背景框中:
const visibleFills = node.fills.filter(isVisibleSolidFill);
visibleFills.forEach((fill) => {
const svgRect = drawFillAsRect(
fill,
node.isSelected === true ? foregroundBox : backgroundBox,
colorSpace
);
if (isEmpty(svgRect)) return;
svgGroup.appendChild(s
``` 每个代表填充的SVG矩形都附加到为节点创建的组中,然后将每个组添加到其父组中,保持层次结构:
parentGroup.appendChild(svgGroup);
该函数递归地将此过程应用于初始节点的所有子节点,确保整个节点层次结构准确呈现:
node.children.forEach((childNode) => { drawNode(childNode, svgGroup); });
这样,我们最终得到了节点的详细准确的表示(包括它们的视觉样式和层次关系)以SVG格式呈现。
## 渲染单个填充
`drawFillAsRect`函数创建一个SVG矩形(`<rect>`)来直观表示Figma的填充。它根据提供的尺寸(通过`rectBox`)设置矩形的大小,应用适当的混合模式(从Figma翻译到CSS标准),并分配填充样式(考虑颜色空间的考虑): ```
export const drawFillAsRect = (
fill: FigmaPaint,
rectBox: CanvasRect,
colorSpace: FigmaColorSpace
): null | SVGGElement => {
const svgRect = document.createElementNS(
'http://www.w3.org/2000/svg',
'rect'
);
svgRect.setAttribute('width', String(rectBox.width));
svgRect.setAttribute('height', String(rectBox.height));
if (notEmpty(fill.blendMode)) {
const mappedBlendMode = mapFigmaBlendToCanvas(fill.blendMode);
if (notEmpty(mappedBlendMode)) {
svgRect.setAttribute('style', `mix-blend-mode: ${mappedBlendMode};`);
}
}
const fillStyle = determineFillStyle(fill, colorSpace);
if (isEmpty(fillStyle)) return null;
svgRect.setAttribute('fill', fillStyle);
if (fill.opacity !== 1) {
svgRect.setAttribute('opacity', `${fill.opacity?.toFixed(2) ?? 1}`);
}
return svgRect;
};
``` 这个过程包含几个关键步骤。
首先是**SVG序列化**。在这里,函数通过使用XMLSerializer接口将SVG元素转换为字符串开始:
const xml = new XMLSerializer().serializeToString(svg);
接下来是**Base64编码**。序列化的SVG字符串被编码为Base64,这代表二进制数据以ASCII字符串格式表示。这一步对于将SVG转换为可用作有效图像源的格式至关重要:
const svg64 = btoa(xml); const b64Start = 'data:image/svg+xml;base64,';
现在我们继续\_\_创建一个图像元素\_\_;一个HTML `<img>`元素会被动态创建,这个元素将作为一个容器来存放编码后的SVG,使其被视为图像:
const img = document.createElement('img');
是时候**设置图像源**了。我们必须通过将Base64前缀与编码后的SVG字符串组合来构建一个数据URL。然后将这个URL设置为`src`属性。 使用DataURL创建图像元素。浏览器将此DataURL解释为图像的源,有效地加载SVG作为图像。
img.src = b64Start + svg64;
最后一步:**在Canvas上绘制**。一旦图像加载完成,事件(`img.onload`)会触发绘图动作。加载的图像(现在包含SVG图形)使用`drawImage`方法绘制到Canvas上。我们将SVG呈现到Canvas上,其图形内容将显示为:
img.onload = () => { ctx.drawImage(img, 0, 0); }
## 从Canvas获取数据
`getColorData`函数从`Uint8ClampedArray`中提取RGBA值,并将其转换为标准化格式,检查颜色值的存在:
export interface ColorData { alpha: number; b: number; g: number; r: number; }
export const getColorData = (fill: Uint8ClampedArray): ColorData | null => { const [r, g, b, alpha] = fill;
if (isEmpty(r) || isEmpty(g) || isEmpty(b)) return null;
};
`getFillFromCtx` 函数从 Canvas 上特定像素的颜色数据。它使用 Canvas API 上下文的 `getImageData` 方法来访问给定坐标 (x, y) 处的颜色信息,考虑指定的颜色空间,并返回一个包含该像素的 RGBA 值的 `Uint8ClampedArray`。
export const getFillFromCtx = ( ctx: OffscreenCanvasRenderingContext2D, x: number, y: number, colorSpace: FigmaColorSpace ): Uint8ClampedArray => { return ctx.getImageData(x, y, 1, 1, { colorSpace: isSupportsOKLCH ? CanvasColorSpace[colorSpace] : 'srgb', }).data; };
然后这个函数接收 Canvas 上下文,以及 `BACKGROUND_BOX` 的背景和 `FOREGROUND_BOX` 的前景的坐标。
const bgColorData = getColorData( getFillFromCtx( ctx, BACKGROUND_BOX.eyeDropperX, BACKGROUND_BOX.eyeDropperY, figmaColorSpa const bgColorData = getColorData( getFillFromCtx( ctx, BACKGROUND_BOX.eyeDropperX, BACKGROUND_BOX.eyeDropperY, figmaColorSpace ) );
const fgColorData = getColorData( getFillFromCtx( ctx, FOREGROUND_BOX.eyeDropperX, FOREGROUND_BOX.eyeDropperY, figmaColorSpace ) );
By using this data, we can easily calculate the final required contrast ratio as follows:
const apcaScore = calculateApcaScore( fgColorData, bgColorData, figmaColorSpace );
And that's it!
演出结束 #
通过使用这种问题解决方法,我能够利用预先存在的解决方案,如Canvas API和SVG,为Polychrom插件重新创建所需的Figma功能。当然,我的第一个方法并没有按照预期工作,但这都是过程的一部分。最终,我能够花更多时间编写实际的Polychrom业务逻辑,而不是重复造轮子!
在Evil Martians,我们将处于增长阶段的初创公司转变为独角兽,构建开发人员工具,并创建开源产品。如果您准备启动超空间引擎,请给我们发消息!