diff --git a/src/api/services/figma/nodes/create-polychrom-node.ts b/src/api/services/figma/nodes/create-polychrom-node.ts index 7e59454..2774451 100644 --- a/src/api/services/figma/nodes/create-polychrom-node.ts +++ b/src/api/services/figma/nodes/create-polychrom-node.ts @@ -14,6 +14,7 @@ export const createPolychromNode = ( const parents = collectNodeParents(node); return { + blendMode: 'blendMode' in node ? node.blendMode : 'PASS_THROUGH', children: [], fills: fills.map((fill) => { if (fill.type === 'SOLID') { diff --git a/src/api/services/figma/nodes/has-only-valid-blend-modes.ts b/src/api/services/figma/nodes/has-only-valid-blend-modes.ts index 5bcb4cb..6e272e9 100644 --- a/src/api/services/figma/nodes/has-only-valid-blend-modes.ts +++ b/src/api/services/figma/nodes/has-only-valid-blend-modes.ts @@ -1,26 +1,22 @@ -// import { type PolychromNode, type FigmaPaint } from '~types/figma.ts'; -// import { isEmpty, notEmpty } from '~utils/not-empty.ts'; -// -// // PLUS_LIGHTER is LINEAR_DODGE -// // PLUS_DARKER is LINEAR_BURN -// const unprocessedBlendModes = ['LINEAR_BURN', 'LINEAR_DODGE']; -// -// const isVisibleSolidFill = (fill: FigmaPaint): boolean => -// fill.visible === true && -// (notEmpty(fill.opacity) ? fill.opacity > 0 : true) && -// fill.type === 'SOLID'; -// -// const hasValidBlendMode = (fill: FigmaPaint): boolean => { -// if (isEmpty(fill.blendMode)) return true; -// -// return !unprocessedBlendModes.includes(fill.blendMode); -// }; -// -// export const hasOnlyValidBlendModes = (nodes: PolychromNode[]): boolean => -// nodes.every( -// (node) => -// node.fills -// .filter((fill) => isVisibleSolidFill(fill)) -// .every(hasValidBlendMode) && -// !unprocessedBlendModes.includes(node.blendMode) -// ); +import { type FigmaPaint, type PolychromNode } from '~types/figma.ts'; +import { flattenPolychromNodesTree } from '~utils/figma/flatten-polychrom-nodes-tree.ts'; +import { isVisibleSolidFill } from '~utils/figma/is-visible-solid-fill.ts'; +import { isEmpty } from '~utils/not-empty.ts'; + +// PLUS_DARKER is LINEAR_BURN +const unprocessedBlendModes = ['LINEAR_BURN']; + +const hasValidBlendMode = (fill: FigmaPaint): boolean => { + if (isEmpty(fill.blendMode)) return true; + + return !unprocessedBlendModes.includes(fill.blendMode); +}; + +export const hasOnlyValidBlendModes = (nodes: PolychromNode): boolean => + flattenPolychromNodesTree(nodes).every( + (node) => + node.fills + .filter((fill) => isVisibleSolidFill(fill)) + .every(hasValidBlendMode) && + !unprocessedBlendModes.includes(node.blendMode) + ); diff --git a/src/api/services/figma/nodes/map-tree.ts b/src/api/services/figma/nodes/map-tree.ts index ba55770..2b4fc80 100644 --- a/src/api/services/figma/nodes/map-tree.ts +++ b/src/api/services/figma/nodes/map-tree.ts @@ -4,10 +4,8 @@ export const mapPolychromNodeTree = ( node: PolychromNode, transform: (node: PolychromNode) => PolychromNode ): PolychromNode => { - // Apply the transformation to the current node const newNode = transform(node); - // Recursively map the children newNode.children = node.children.map((child) => mapPolychromNodeTree(child, transform) ); diff --git a/src/api/services/figma/nodes/specs/create-figma-node.spec.ts b/src/api/services/figma/nodes/specs/create-polychrome-node.spec.ts similarity index 98% rename from src/api/services/figma/nodes/specs/create-figma-node.spec.ts rename to src/api/services/figma/nodes/specs/create-polychrome-node.spec.ts index a3535cd..038d9c8 100644 --- a/src/api/services/figma/nodes/specs/create-figma-node.spec.ts +++ b/src/api/services/figma/nodes/specs/create-polychrome-node.spec.ts @@ -15,6 +15,7 @@ describe('createPolychromNode', () => { const result = createPolychromNode(node); expect(result).toEqual({ + blendMode: 'PASS_THROUGH', children: [], fills: [], id: '123', diff --git a/src/api/services/payload/build-general-selection-payload.ts b/src/api/services/payload/build-general-selection-payload.ts index 44a392c..93f06e3 100644 --- a/src/api/services/payload/build-general-selection-payload.ts +++ b/src/api/services/payload/build-general-selection-payload.ts @@ -1,5 +1,5 @@ import { getIntersectingNodes } from '~api/services/figma/intersections/get-intersecting-nodes.ts'; -// import { hasOnlyValidBlendModes } from '~api/services/figma/nodes/has-only-valid-blend-modes.ts'; +import { hasOnlyValidBlendModes } from '~api/services/figma/nodes/has-only-valid-blend-modes.ts'; import { isValidForBackground } from '~api/services/figma/nodes/is-valid-for-background.ts'; import { isValidForSelection } from '~api/services/figma/nodes/is-valid-for-selection.ts'; import { type PolychromNode } from '~types/figma.ts'; @@ -17,7 +17,11 @@ enum PairState { const isValidSelection = ( pair: PairState | PolychromNode ): pair is PolychromNode => { - return notEmpty(pair) && pair !== PairState.InvalidBackground; + return ( + notEmpty(pair) && + pair !== PairState.InvalidBackground && + pair !== PairState.InvalidBlendMode + ); }; export const buildGeneralSelectionPayload = ( @@ -28,9 +32,9 @@ export const buildGeneralSelectionPayload = ( .map((selectedNode) => { const intersectingNodesTree = getIntersectingNodes(selectedNode); - // if (!hasOnlyValidBlendModes([selectedPolychromNode, ...intersectingNodesTree])) { - // return PairState.InvalidBlendMode; - // } + if (!hasOnlyValidBlendModes(intersectingNodesTree)) { + return PairState.InvalidBlendMode; + } if (isValidForBackground(intersectingNodesTree)) { return intersectingNodesTree; @@ -53,12 +57,12 @@ export const buildGeneralSelectionPayload = ( }; } - // if (selectedNodePairs.some((pair) => pair === PairState.InvalidBlendMode)) { - // return { - // colorSpace: figma.root.documentColorProfile, - // text: SelectionMessageTypes.unprocessedBlendModes, - // }; - // } + if (selectedNodePairs.some((pair) => pair === PairState.InvalidBlendMode)) { + return { + colorSpace: figma.root.documentColorProfile, + text: SelectionMessageTypes.unprocessedBlendModes, + }; + } return { colorSpace: figma.root.documentColorProfile, diff --git a/src/api/services/payload/build-pair-selection-payload.ts b/src/api/services/payload/build-pair-selection-payload.ts index d767902..b731bd8 100644 --- a/src/api/services/payload/build-pair-selection-payload.ts +++ b/src/api/services/payload/build-pair-selection-payload.ts @@ -1,6 +1,6 @@ import { getIntersectingNodes } from '~api/services/figma/intersections/get-intersecting-nodes.ts'; import { createPolychromNode } from '~api/services/figma/nodes/create-polychrom-node.ts'; -// import { hasOnlyValidBlendModes } from '~api/services/figma/nodes/has-only-valid-blend-modes.ts'; +import { hasOnlyValidBlendModes } from '~api/services/figma/nodes/has-only-valid-blend-modes.ts'; import { isValidForBackground } from '~api/services/figma/nodes/is-valid-for-background.ts'; import { isValidForSelection } from '~api/services/figma/nodes/is-valid-for-selection.ts'; import { mapPolychromNodeTree } from '~api/services/figma/nodes/map-tree.ts'; @@ -48,12 +48,12 @@ export const buildPairSelectionPayload = ( }; } - // if (!hasOnlyValidBlendModes([bg, fg])) { - // return { - // colorSpace: figma.root.documentColorProfile, - // text: SelectionMessageTypes.unprocessedBlendModes, - // }; - // } + if (!hasOnlyValidBlendModes(bg) || !hasOnlyValidBlendModes(fg)) { + return { + colorSpace: figma.root.documentColorProfile, + text: SelectionMessageTypes.unprocessedBlendModes, + }; + } if (!isValidForSelection(fgSceneNode)) return { @@ -67,6 +67,7 @@ export const buildPairSelectionPayload = ( colorSpace: figma.root.documentColorProfile, selectedNodePairs: [ { + blendMode: 'NORMAL', children: [ mapPolychromNodeTree(getIntersectingNodes(bgSceneNode), (node) => ({ ...node, diff --git a/src/types/figma.ts b/src/types/figma.ts index 7d1871d..ca7b2d5 100644 --- a/src/types/figma.ts +++ b/src/types/figma.ts @@ -3,6 +3,7 @@ import { type UIColor } from '~types/common.ts'; export type FigmaPaint = Paint | (SolidPaint & UIColor); export interface PolychromNode { + blendMode: BlendMode; children: PolychromNode[]; fills: FigmaPaint[]; id: string; diff --git a/src/ui/components/App.tsx b/src/ui/components/App.tsx index cc209f8..efc2dda 100644 --- a/src/ui/components/App.tsx +++ b/src/ui/components/App.tsx @@ -38,7 +38,12 @@ export const App: React.FC = () => { {isP3 && ( -
+

P3

diff --git a/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx b/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx index 44d0acc..ba3b09d 100644 --- a/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx +++ b/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx @@ -9,7 +9,7 @@ export const UnprocessedBlendModesSelectionMessage = (): ReactElement => { }} className="mx-auto flex h-[200px] w-[250px] select-none items-end justify-center bg-[length:180px_180px] bg-center bg-no-repeat pt-2 text-center font-martianMono text-xxs text-secondary-75" > - The blending modes Plus Lighter and Plus Darker are not supported + The blending mode Plus Darker is not supported

); }; diff --git a/src/ui/services/blend-modes/map-figma-blend-to-canvas.ts b/src/ui/services/blend-modes/map-figma-blend-to-canvas.ts index 75c67f6..8e96afc 100644 --- a/src/ui/services/blend-modes/map-figma-blend-to-canvas.ts +++ b/src/ui/services/blend-modes/map-figma-blend-to-canvas.ts @@ -1,7 +1,10 @@ +import { notEmpty } from '~utils/not-empty.ts'; +import { type CSSProperties } from 'react'; + export const mapFigmaBlendToCanvas = ( - figmaBlend: BlendMode -): GlobalCompositeOperation => { - const mapping: Record = { + figmaBlend?: BlendMode +): CSSProperties['mixBlendMode'] => { + const mapping: Record = { COLOR: 'color', COLOR_BURN: 'color-burn', COLOR_DODGE: 'color-dodge', @@ -13,18 +16,17 @@ export const mapFigmaBlendToCanvas = ( LIGHTEN: 'lighten', // unsupported LINEAR_BURN: 'color-burn', - // unsupported - LINEAR_DODGE: 'lighter', + LINEAR_DODGE: 'plus-lighter', LUMINOSITY: 'luminosity', MULTIPLY: 'multiply', - NORMAL: 'source-over', + NORMAL: 'normal', OVERLAY: 'overlay', // only for layers, not for fills - PASS_THROUGH: 'source-over', + PASS_THROUGH: undefined, SATURATION: 'saturation', SCREEN: 'screen', SOFT_LIGHT: 'soft-light', }; - return mapping[figmaBlend]; + return notEmpty(figmaBlend) ? mapping[figmaBlend] : undefined; }; diff --git a/src/ui/services/blend/blend-colors.ts b/src/ui/services/blend/blend-colors.ts index 50de8db..acb3e62 100644 --- a/src/ui/services/blend/blend-colors.ts +++ b/src/ui/services/blend/blend-colors.ts @@ -8,7 +8,7 @@ import { getFillFromCtx } from '~ui/services/canvas/get-fill-from-ctx.ts'; import { renderSvgOnCanvas } from '~ui/services/canvas/render-svg-on-canvas.ts'; import { findFgAndBgNodes } from '~ui/services/figma/find-fg-and-bg-nodes.ts'; import { formatPolychromNodeId } from '~ui/services/figma/format-figma-node-id.ts'; -import { drawFillsOnSvg } from '~ui/services/svg/draw-fills-on-svg.ts'; +import { drawNodesOnSvg } from '~ui/services/svg/draw-nodes-on-svg.ts'; import { type ContrastConclusion } from '~ui/types'; import { calculateApcaScore } from '~utils/apca/calculate-apca-score.ts'; import { getActualFill } from '~utils/figma/get-actual-fill.ts'; @@ -122,7 +122,7 @@ const drawNodesOnContext = async ( svg.setAttribute('width', `${BACKGROUND_BOX.width}`); svg.setAttribute('height', `${BACKGROUND_BOX.height}`); - drawFillsOnSvg(svg, pair, FOREGROUND_BOX, BACKGROUND_BOX, colorSpace); + drawNodesOnSvg(svg, pair, FOREGROUND_BOX, BACKGROUND_BOX, colorSpace); await renderSvgOnCanvas(ctx, svg); }; diff --git a/src/ui/services/svg/draw-rect.ts b/src/ui/services/svg/draw-fill-as-rect.ts similarity index 58% rename from src/ui/services/svg/draw-rect.ts rename to src/ui/services/svg/draw-fill-as-rect.ts index 778a54d..870fca1 100644 --- a/src/ui/services/svg/draw-rect.ts +++ b/src/ui/services/svg/draw-fill-as-rect.ts @@ -1,14 +1,15 @@ import { type ColorSpace } from '~types/common.ts'; import { type FigmaPaint } from '~types/figma.ts'; +import { mapFigmaBlendToCanvas } from '~ui/services/blend-modes/map-figma-blend-to-canvas.ts'; import { determineFillStyle } from '~ui/services/blend/determine-fill-style.ts'; -import { isEmpty } from '~utils/not-empty.ts'; +import { isEmpty, notEmpty } from '~utils/not-empty.ts'; export interface CanvasRect { height: number; width: number; } -export const drawRect = ( +export const drawFillAsRect = ( fill: FigmaPaint, rectBox: CanvasRect, colorSpace: ColorSpace @@ -21,16 +22,23 @@ export const drawRect = ( 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; - // if (notEmpty(fill.blendMode)) { - // ctx.globalCompositeOperation = mapFigmaBlendToCanvas(fill.blendMode); - // } - svgRect.setAttribute('fill', fillStyle); - svgRect.setAttribute('opacity', `${fill.opacity?.toFixed(2) ?? 1}`); + + if (fill.opacity !== 1) { + svgRect.setAttribute('opacity', `${fill.opacity?.toFixed(2) ?? 1}`); + } return svgRect; }; diff --git a/src/ui/services/svg/draw-fills-on-svg.ts b/src/ui/services/svg/draw-fills-on-svg.ts deleted file mode 100644 index d9b593a..0000000 --- a/src/ui/services/svg/draw-fills-on-svg.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { type ColorSpace } from '~types/common.ts'; -import { type PolychromNode } from '~types/figma.ts'; -import { type CanvasRect, drawRect } from '~ui/services/svg/draw-rect.ts'; -import { isVisibleSolidFill } from '~ui/services/svg/is-visible-solid-fill.ts'; -import { isEmpty } from '~utils/not-empty.ts'; - -export const drawFillsOnSvg = ( - svg: SVGSVGElement, - pair: PolychromNode, - foregroundBox: CanvasRect, - backgroundBox: CanvasRect, - colorSpace: ColorSpace -): void => { - const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => { - const svgGroup = document.createElementNS( - 'http://www.w3.org/2000/svg', - 'g' - ); - - svgGroup.setAttribute('id', `layer-${node.id}`); - svgGroup.setAttribute('opacity', `${node.opacity?.toFixed(2) ?? 1}`); - - if (node.isSelected === true) { - svgGroup.setAttribute('class', 'selected-polychrom'); - } - - const visibleFills = node.fills.filter(isVisibleSolidFill); - - visibleFills.forEach((fill) => { - const svgRect = drawRect( - fill, - node.isSelected === true ? foregroundBox : backgroundBox, - colorSpace - ); - - if (isEmpty(svgRect)) return; - - svgGroup.appendChild(svgRect); - }); - - parentGroup.appendChild(svgGroup); - - node.children.forEach((childNode) => { - drawNode(childNode, svgGroup); - }); - }; - - drawNode(pair, svg); -}; diff --git a/src/ui/services/svg/draw-nodes-on-svg.ts b/src/ui/services/svg/draw-nodes-on-svg.ts new file mode 100644 index 0000000..9b1143a --- /dev/null +++ b/src/ui/services/svg/draw-nodes-on-svg.ts @@ -0,0 +1,71 @@ +import { type ColorSpace } from '~types/common.ts'; +import { type PolychromNode } from '~types/figma.ts'; +import { mapFigmaBlendToCanvas } from '~ui/services/blend-modes/map-figma-blend-to-canvas.ts'; +import { + type CanvasRect, + drawFillAsRect, +} from '~ui/services/svg/draw-fill-as-rect.ts'; +import { isVisibleSolidFill } from '~utils/figma/is-visible-solid-fill.ts'; +import { isEmpty, notEmpty } from '~utils/not-empty.ts'; + +export const drawNodesOnSvg = ( + svg: SVGSVGElement, + pair: PolychromNode, + foregroundBox: CanvasRect, + backgroundBox: CanvasRect, + colorSpace: ColorSpace +): void => { + const drawNode = (node: PolychromNode, parentGroup: SVGGElement): void => { + const svgGroup = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'g' + ); + + svgGroup.setAttribute('id', `layer-${node.id}`); + + if (node.opacity !== 1) { + svgGroup.setAttribute('opacity', `${node.opacity?.toFixed(2) ?? 1}`); + } + + const mappedBlendMode = mapFigmaBlendToCanvas(node.blendMode); + + if (notEmpty(mappedBlendMode)) { + svgGroup.setAttribute( + 'style', + `mix-blend-mode: ${mappedBlendMode}; isolation: isolate;` + ); + } + + const visibleFills = node.fills.filter(isVisibleSolidFill); + + visibleFills.forEach((fill) => { + const svgRect = drawFillAsRect( + fill, + node.isSelected === true ? foregroundBox : backgroundBox, + colorSpace + ); + + if (isEmpty(svgRect)) return; + + // Emulate pass-through blend mode behavior within an SVG structure. + // If a node's blend mode is 'PASS_THROUGH', the rectangle is added to the parent group + // to interact with outside elements. Otherwise, it's isolated within its own group, + // restricting its blending effects to that group. + // if (node.blendMode === 'PASS_THROUGH') { + // parentGroup.appendChild(svgRect); + // } else { + // svgGroup.appendChild(svgRect); + // } + + svgGroup.appendChild(svgRect); + }); + + parentGroup.appendChild(svgGroup); + + node.children.forEach((childNode) => { + drawNode(childNode, svgGroup); + }); + }; + + drawNode(pair, svg); +}; diff --git a/src/utils/figma/flatten-polychrom-nodes-tree.ts b/src/utils/figma/flatten-polychrom-nodes-tree.ts index 10e6f04..1d2eb41 100644 --- a/src/utils/figma/flatten-polychrom-nodes-tree.ts +++ b/src/utils/figma/flatten-polychrom-nodes-tree.ts @@ -7,7 +7,6 @@ export const flattenPolychromNodesTree = ( let flatNodes: PolychromNode[] = [nodesTree]; nodesTree.children.forEach((node) => { - // Update the nesting level based on the parent const updatedNode = { ...node, nestingLevel: parentNestingLevel + 1 }; flatNodes.push(updatedNode); diff --git a/src/ui/services/svg/is-visible-solid-fill.ts b/src/utils/figma/is-visible-solid-fill.ts similarity index 100% rename from src/ui/services/svg/is-visible-solid-fill.ts rename to src/utils/figma/is-visible-solid-fill.ts