From b3ea959b4d2db5712ac68c0f26f316ab631e7355 Mon Sep 17 00:00:00 2001 From: Yuri Mikhin Date: Mon, 30 Oct 2023 22:13:50 +0300 Subject: [PATCH] Improve opacity for blending. --- package.json | 2 +- pnpm-lock.yaml | 8 +- .../figma/nodes/has-only-valid-blend-modes.ts | 26 +++ .../figma/nodes/is-valid-for-background.ts | 2 + .../figma/nodes/is-valid-for-selection.ts | 2 + .../build-general-selection-payload.ts | 51 ++++-- .../payload/build-pair-selection-payload.ts | 17 +- src/types/messages.ts | 1 + src/types/selection.ts | 2 +- src/ui/components/AppContent.tsx | 7 + .../UnprocessedBlendModesSelectionMessage.tsx | 15 ++ .../colors/render-and-blend-colors.ts | 161 ++++++++++++++---- src/ui/stores/selected-nodes.ts | 10 ++ src/utils/colors/formatters.ts | 8 +- 14 files changed, 255 insertions(+), 57 deletions(-) create mode 100644 src/api/services/figma/nodes/has-only-valid-blend-modes.ts create mode 100644 src/ui/components/UnprocessedBlendModesSelectionMessage.tsx diff --git a/package.json b/package.json index dca95e3..a6306ed 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "pnpm run /^test:/" }, "dependencies": { - "@figma/plugin-typings": "^1.77.0", + "@figma/plugin-typings": "^1.79.0", "@floating-ui/react": "^0.25.2", "@nanostores/react": "^0.7.1", "@testing-library/react": "^14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902bbff..66ce0a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: '@figma/plugin-typings': - specifier: ^1.77.0 - version: 1.77.0 + specifier: ^1.79.0 + version: 1.79.0 '@floating-ui/react': specifier: ^0.25.2 version: 0.25.2(react-dom@18.2.0)(react@18.2.0) @@ -1245,10 +1245,10 @@ packages: } engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } - /@figma/plugin-typings@1.77.0: + /@figma/plugin-typings@1.79.0: resolution: { - integrity: sha512-mSm+/jpmSJpG8jXEctzkiCVdn6IPoMjxuy8oDWmmSLpYtRIKfTjN01shtyrMAHDLY4klVa9F36f+QC0bsavoow==, + integrity: sha512-Nzi+ShWOs/0IHWQqUwBS4xydglgcgqNEKPSUhTXM+io4o4vSspwpKWy7XBrl5azqqoCozzBa10aPhwXsA+tJfg==, } dev: false 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 new file mode 100644 index 0000000..d0fc6b3 --- /dev/null +++ b/src/api/services/figma/nodes/has-only-valid-blend-modes.ts @@ -0,0 +1,26 @@ +// import { type FigmaNode, 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: FigmaNode[]): boolean => +// nodes.every( +// (node) => +// node.fills +// .filter((fill) => isVisibleSolidFill(fill)) +// .every(hasValidBlendMode) && +// !unprocessedBlendModes.includes(node.blendMode) +// ); diff --git a/src/api/services/figma/nodes/is-valid-for-background.ts b/src/api/services/figma/nodes/is-valid-for-background.ts index 040d60a..2d27749 100644 --- a/src/api/services/figma/nodes/is-valid-for-background.ts +++ b/src/api/services/figma/nodes/is-valid-for-background.ts @@ -3,6 +3,8 @@ import { getActualNodeFill } from '~utils/get-actual-node-fill.ts'; import { getActualNode } from '~utils/get-actual-node.ts'; import { isEmpty } from '~utils/not-empty.ts'; +// TODO: Improve if there are any non solid visible fills with opacity. + export const isValidForBackground = (nodes: FigmaNode[]): boolean => { const actualNode = getActualNode(nodes); diff --git a/src/api/services/figma/nodes/is-valid-for-selection.ts b/src/api/services/figma/nodes/is-valid-for-selection.ts index dd5ae8e..62d6674 100644 --- a/src/api/services/figma/nodes/is-valid-for-selection.ts +++ b/src/api/services/figma/nodes/is-valid-for-selection.ts @@ -1,6 +1,8 @@ import { getActualNodeFill } from '~utils/get-actual-node-fill.ts'; import { notEmpty } from '~utils/not-empty.ts'; +// TODO: Improve if there are any non solid visible fills with opacity. + export const isValidForSelection = (node: SceneNode): boolean => { if (!node.visible) { return false; diff --git a/src/api/services/payload/build-general-selection-payload.ts b/src/api/services/payload/build-general-selection-payload.ts index 89292ae..e8a4654 100644 --- a/src/api/services/payload/build-general-selection-payload.ts +++ b/src/api/services/payload/build-general-selection-payload.ts @@ -1,12 +1,25 @@ import { getIntersectingNodes } from '~api/services/figma/intersections/get-intersecting-nodes.ts'; import { createFigmaNode } from '~api/services/figma/nodes/create-figma-node.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 SelectionChangeEvent, SelectionMessageTypes, } from '~types/messages.ts'; -import { isEmpty, notEmpty } from '~utils/not-empty.ts'; +import { type SelectedNodes } from '~types/selection.ts'; +import { notEmpty } from '~utils/not-empty.ts'; + +enum PairState { + InvalidBackground = 'Invalid background', + InvalidBlendMode = 'Has invalid blend mode', +} + +const isSelectedNodes = ( + pair: PairState | SelectedNodes +): pair is SelectedNodes => { + return notEmpty((pair as SelectedNodes).intersectingNodes); +}; export const buildGeneralSelectionPayload = ( selection: readonly SceneNode[] @@ -15,31 +28,45 @@ export const buildGeneralSelectionPayload = ( .filter(isValidForSelection) .map((selectedNode) => { const intersectingNodes = getIntersectingNodes(selectedNode); + const selectedFigmaNode = createFigmaNode(selectedNode); + + // if (!hasOnlyValidBlendModes([selectedFigmaNode, ...intersectingNodes])) { + // return PairState.InvalidBlendMode; + // } if (isValidForBackground(intersectingNodes)) { return { intersectingNodes: getIntersectingNodes(selectedNode), - selectedNode: [createFigmaNode(selectedNode)], + selectedNodeWithIntersectingNodes: [selectedFigmaNode], }; } else { - return null; + return PairState.InvalidBackground; } }); const isSingleInvalidBackground = - selectedNodePairs.some(isEmpty) && selectedNodePairs.length === 1; + selectedNodePairs.some((pair) => pair === PairState.InvalidBackground) && + selectedNodePairs.length === 1; const areAllInvalidBackgrounds = - selectedNodePairs.length > 1 && selectedNodePairs.every(isEmpty); + selectedNodePairs.length > 1 && + selectedNodePairs.every((pair) => pair === PairState.InvalidBackground); + + if (isSingleInvalidBackground || areAllInvalidBackgrounds) { + return { + colorSpace: figma.root.documentColorProfile, + text: SelectionMessageTypes.invalidBackground, + }; + } - const invalidBackground = - isSingleInvalidBackground || areAllInvalidBackgrounds; + // if (selectedNodePairs.some((pair) => pair === PairState.InvalidBlendMode)) { + // return { + // colorSpace: figma.root.documentColorProfile, + // text: SelectionMessageTypes.unprocessedBlendModes, + // }; + // } return { colorSpace: figma.root.documentColorProfile, - ...(invalidBackground - ? { text: SelectionMessageTypes.invalidBackground } - : { - selectedNodePairs: selectedNodePairs.filter(notEmpty), - }), + selectedNodePairs: selectedNodePairs.filter(isSelectedNodes), }; }; diff --git a/src/api/services/payload/build-pair-selection-payload.ts b/src/api/services/payload/build-pair-selection-payload.ts index 2aed9a6..0d1233a 100644 --- a/src/api/services/payload/build-pair-selection-payload.ts +++ b/src/api/services/payload/build-pair-selection-payload.ts @@ -1,6 +1,7 @@ import { areNodesIntersecting } from '~api/services/figma/intersections/are-nodes-intersecting.ts'; import { getIntersectingNodes } from '~api/services/figma/intersections/get-intersecting-nodes.ts'; import { createFigmaNode } from '~api/services/figma/nodes/create-figma-node.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 { sortNodesByLayers } from '~api/services/figma/nodes/sort-nodes-by-layers.ts'; @@ -39,6 +40,13 @@ export const buildPairSelectionPayload = ( }; } + // if (!hasOnlyValidBlendModes([bg, fg])) { + // return { + // colorSpace: figma.root.documentColorProfile, + // text: SelectionMessageTypes.unprocessedBlendModes, + // }; + // } + const fgSceneNode = fg.id === firstFigmaNode.id ? firstNode : secondNode; const bgSceneNode = bg.id === firstFigmaNode.id ? firstNode : secondNode; @@ -48,13 +56,13 @@ export const buildPairSelectionPayload = ( selectedNodePairs: [], }; - if (areNodesIntersecting(firstNode, secondNode)) { + if (areNodesIntersecting(bgSceneNode, fgSceneNode)) { return { colorSpace: figma.root.documentColorProfile, selectedNodePairs: [ { intersectingNodes: getIntersectingNodes(fgSceneNode), - selectedNode: [fg], + selectedNodeWithIntersectingNodes: [fg], }, ], }; @@ -64,7 +72,10 @@ export const buildPairSelectionPayload = ( selectedNodePairs: [ { intersectingNodes: [bg, ...getIntersectingNodes(bgSceneNode)], - selectedNode: [fg, ...getIntersectingNodes(fgSceneNode)], + selectedNodeWithIntersectingNodes: [ + fg, + ...getIntersectingNodes(fgSceneNode), + ], }, ], }; diff --git a/src/types/messages.ts b/src/types/messages.ts index f471265..84d09d8 100644 --- a/src/types/messages.ts +++ b/src/types/messages.ts @@ -20,6 +20,7 @@ export interface Message { export enum SelectionMessageTypes { invalidBackground = 'invalidBackground', + unprocessedBlendModes = 'unprocessedBlendModes', } export interface SelectionChangePayload { diff --git a/src/types/selection.ts b/src/types/selection.ts index e8f6c06..bb27deb 100644 --- a/src/types/selection.ts +++ b/src/types/selection.ts @@ -2,5 +2,5 @@ import { type FigmaNode } from './figma.ts'; export interface SelectedNodes { intersectingNodes: FigmaNode[]; - selectedNode: FigmaNode[]; + selectedNodeWithIntersectingNodes: FigmaNode[]; } diff --git a/src/ui/components/AppContent.tsx b/src/ui/components/AppContent.tsx index 86eb760..250bf3e 100644 --- a/src/ui/components/AppContent.tsx +++ b/src/ui/components/AppContent.tsx @@ -3,11 +3,13 @@ import { EmptySelectionMessage } from '~ui/components/EmptySelectionMessage.tsx' import { InvalidBackgroundSelectionMessage } from '~ui/components/InvalidBackgroundSelectionMessage.tsx'; import { Selection } from '~ui/components/Selection.tsx'; import { SelectionsList } from '~ui/components/SelectionsList.tsx'; +import { UnprocessedBlendModesSelectionMessage } from '~ui/components/UnprocessedBlendModesSelectionMessage.tsx'; import { $contrastConclusion, $isEmptySelection, $isInvalidBackground, $isMultiSelection, + $isUnprocessedBlendModes, } from '~ui/stores/selected-nodes.ts'; import { isEmpty } from '~utils/not-empty.ts'; import { type ReactElement } from 'react'; @@ -16,12 +18,17 @@ export const AppContent = (): ReactElement => { const isInvalidBackground = useStore($isInvalidBackground); const isEmptySelection = useStore($isEmptySelection); const isMultiSelection = useStore($isMultiSelection); + const isUnprocessedBlendModes = useStore($isUnprocessedBlendModes); const contrastConclusion = useStore($contrastConclusion); if (isInvalidBackground) { return ; } + if (isUnprocessedBlendModes) { + return ; + } + if (isEmptySelection) { return ; } diff --git a/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx b/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx new file mode 100644 index 0000000..44d0acc --- /dev/null +++ b/src/ui/components/UnprocessedBlendModesSelectionMessage.tsx @@ -0,0 +1,15 @@ +import layersImage from '~ui/assets/layers@2x.webp'; +import { type ReactElement } from 'react'; + +export const UnprocessedBlendModesSelectionMessage = (): ReactElement => { + return ( +

+ The blending modes Plus Lighter and Plus Darker are not supported +

+ ); +}; diff --git a/src/ui/services/colors/render-and-blend-colors.ts b/src/ui/services/colors/render-and-blend-colors.ts index a5f578e..3bc0db8 100644 --- a/src/ui/services/colors/render-and-blend-colors.ts +++ b/src/ui/services/colors/render-and-blend-colors.ts @@ -20,17 +20,17 @@ interface CanvasRect { } const BACKGROUND_BOX = { - height: 2, - width: 2, + height: 20, + width: 20, x: 0, y: 0, }; const FOREGROUND_BOX = { - height: 1, - width: 1, - x: 1, - y: 1, + height: 10, + width: 10, + x: 0, + y: 10, }; const CanvasColorSpace: Record = { @@ -46,9 +46,26 @@ export interface ContrastConclusion { id: string; } +interface Layer { + // blendMode?: BlendMode; + fills: FigmaPaint[]; + opacity?: number; +} + // remove any non-alphabetical or non-numeric characters const formatFigmaNodeID = (id: string): string => id.replace(/[^a-z0-9]/gi, ''); +const isBlended = (node?: FigmaNode, fill?: FigmaPaint): boolean => { + return ( + (node != null && node.opacity !== 1) || (fill != null && fill.opacity !== 1) + ); +}; + +const isVisibleSolidFill = (fill: FigmaPaint): boolean => + fill.visible === true && + (notEmpty(fill.opacity) ? fill.opacity > 0 : true) && + fill.type === 'SOLID'; + export type ContrastConclusionList = ContrastConclusion[]; export const renderAndBlendColors = ( @@ -83,14 +100,16 @@ const summarizeTheColorsForPair = ( if (isEmpty(fgColorData) || isEmpty(bgColorData)) return null; - const { fill: fgFill, node: fgNode } = getNodeAndFill(pair.selectedNode); + const { fill: fgFill, node: fgNode } = getNodeAndFill( + pair.selectedNodeWithIntersectingNodes + ); const { fill: bgFill, node: bgNode } = getNodeAndFill(pair.intersectingNodes); const isFgBlended = isBlended(fgNode, fgFill); const isBgBlended = isBlended(bgNode, bgFill); const apca = calculateApcaScore(fgColorData, bgColorData, colorSpace); - const nodeId = pair.selectedNode[0]?.id; + const nodeId = pair.selectedNodeWithIntersectingNodes[0]?.id; const id = notEmpty(nodeId) ? formatFigmaNodeID(nodeId) : nanoid(); canvas.remove(); @@ -111,7 +130,13 @@ const renderNodesOnCanvas = ( colorSpace: ColorSpace ): void => { drawNodes(ctx, pair.intersectingNodes, BACKGROUND_BOX, colorSpace); - drawNodes(ctx, pair.selectedNode, FOREGROUND_BOX, colorSpace); + + drawNodes( + ctx, + pair.selectedNodeWithIntersectingNodes, + FOREGROUND_BOX, + colorSpace + ); }; const getNodeAndFill = ( @@ -128,12 +153,6 @@ const getNodeAndFill = ( }; }; -const isBlended = (node?: FigmaNode, fill?: FigmaPaint): boolean => { - return ( - (node != null && node.opacity !== 1) || (fill != null && fill.opacity !== 1) - ); -}; - const createContrastConclusion = ( id: string, apcaScore: number, @@ -148,24 +167,60 @@ const createContrastConclusion = ( id, }); -const isVisibleSolidFill = (fill: FigmaPaint): boolean => - fill.visible === true && - (notEmpty(fill.opacity) ? fill.opacity > 0 : true) && - fill.type === 'SOLID'; +const drawResultFill = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + tempColorData: { + alpha: number; + b: number; + g: number; + r: number; + } | null, + layer: Layer, + colorSpace: ColorSpace +): void => { + const resultFill: FigmaPaint = { + // blendMode: layer.blendMode, + color: { + b: tempColorData?.b ?? 0, + g: tempColorData?.g ?? 0, + r: tempColorData?.r ?? 0, + }, + opacity: (layer.opacity ?? 1) * (tempColorData?.alpha ?? 1), + // opacity: tempColorData?.alpha ?? 1, + type: 'SOLID', + }; + + drawRect(ctx, x, y, width, height, resultFill, colorSpace); +}; const drawFillsOnContext = ( ctx: CanvasRenderingContext2D, - layers: Array<{ - fills: FigmaPaint[]; - opacity?: number; - }>, + layers: Layer[], { height, width, x, y }: CanvasRect, colorSpace: ColorSpace ): void => { - layers.forEach((layer) => { - layer.fills.filter(isVisibleSolidFill).forEach((fill) => { - drawRect(ctx, x, y, width, height, fill, colorSpace, layer.opacity); + const tempY = y + height * 2; + + layers.forEach((layer, index) => { + const tempX = x + width * index; + + const visibleFills = layer.fills.filter(isVisibleSolidFill); + + visibleFills.forEach((fill) => { + drawRect(ctx, tempX, tempY, width, height, fill, colorSpace); }); + + // Retrieve color data of the filled region. + const tempColorData = getColorData( + getFillFromCtx(ctx, tempX, tempY, colorSpace) + ); + + // Draw the resulting fill on the main drawing area using the retrieved color data. + drawResultFill(ctx, x, y, width, height, tempColorData, layer, colorSpace); }); }; @@ -175,8 +230,9 @@ const drawNodes = ( { height, width, x, y }: CanvasRect, colorSpace: ColorSpace ): void => { - const fillsFromIntersectingNodes = nodes + const formattedIntersectingNodes = nodes .map((node) => ({ + // blendMode: node.blendMode, fills: node.fills, opacity: node.opacity, })) @@ -185,7 +241,7 @@ const drawNodes = ( drawFillsOnContext( ctx, - fillsFromIntersectingNodes, + formattedIntersectingNodes, { height, width, x, y }, colorSpace ); @@ -198,14 +254,17 @@ const drawRect = ( width: number, height: number, fill: FigmaPaint, - colorSpace: ColorSpace, - opacity?: number + colorSpace: ColorSpace ): void => { const fillStyle = determineFillStyle(fill, colorSpace); + if (isEmpty(fillStyle)) return; ctx.fillStyle = fillStyle; - ctx.globalAlpha = opacity ?? 1; + + // if (notEmpty(fill.blendMode)) { + // ctx.globalCompositeOperation = mapFigmaBlendToCanvas(fill.blendMode); + // } ctx.fillRect(x, y, width, height); }; @@ -221,6 +280,10 @@ const determineFillStyle = ( return `color(display-p3 ${r} ${g} ${b} / ${fill.opacity ?? 1})`; } + if (fill.opacity === 1) { + return formatHex({ b, g, mode: 'rgb', r }); + } + return formatHex8({ alpha: fill.opacity, b, @@ -256,13 +319,45 @@ const getFillFromCtx = ( const getColorData = ( fill: Uint8ClampedArray ): { + alpha: number; b: number; g: number; r: number; } | null => { - const [r, g, b] = fill; + const [r, g, b, alpha] = fill; if (isEmpty(r) || isEmpty(g) || isEmpty(b)) return null; - return convert255ScaleRGBtoDecimal({ b, g, r }); + return convert255ScaleRGBtoDecimal({ alpha, b, g, r }); +}; + +export const mapFigmaBlendToCanvas = ( + figmaBlend: BlendMode +): GlobalCompositeOperation => { + const mapping: Record = { + COLOR: 'color', + COLOR_BURN: 'color-burn', + COLOR_DODGE: 'color-dodge', + DARKEN: 'darken', + DIFFERENCE: 'difference', + EXCLUSION: 'exclusion', + HARD_LIGHT: 'hard-light', + HUE: 'hue', + LIGHTEN: 'lighten', + // unsupported + LINEAR_BURN: 'color-burn', + // unsupported + LINEAR_DODGE: 'lighter', + LUMINOSITY: 'luminosity', + MULTIPLY: 'multiply', + NORMAL: 'source-over', + OVERLAY: 'overlay', + // only for layers, not for fills + PASS_THROUGH: 'source-over', + SATURATION: 'saturation', + SCREEN: 'screen', + SOFT_LIGHT: 'soft-light', + }; + + return mapping[figmaBlend]; }; diff --git a/src/ui/stores/selected-nodes.ts b/src/ui/stores/selected-nodes.ts index 653b8fa..e042cb9 100644 --- a/src/ui/stores/selected-nodes.ts +++ b/src/ui/stores/selected-nodes.ts @@ -45,6 +45,16 @@ export const $isInvalidBackground = computed($userSelection, (selection) => { ); }); +export const $isUnprocessedBlendModes = computed( + $userSelection, + (selection) => { + return ( + 'text' in selection && + selection.text === SelectionMessageTypes.unprocessedBlendModes + ); + } +); + export const $isEmptySelection = computed( $contrastConclusion, (selection) => selection?.length === 0 diff --git a/src/utils/colors/formatters.ts b/src/utils/colors/formatters.ts index 5d33be4..19a02e0 100644 --- a/src/utils/colors/formatters.ts +++ b/src/utils/colors/formatters.ts @@ -19,12 +19,14 @@ export const convertDecimalRGBto255Scale = (color: { }; export const convert255ScaleRGBtoDecimal = (color: { + alpha?: number; b: number; g: number; r: number; -}): { b: number; g: number; r: number } => { - const { b, g, r } = color; - return { b: b / 255, g: g / 255, r: r / 255 }; +}): { alpha: number; b: number; g: number; r: number } => { + const { alpha, b, g, r } = color; + + return { alpha: (alpha ?? 255) / 255, b: b / 255, g: g / 255, r: r / 255 }; }; export const formatForOklchDisplay = (oklch: Oklch): string => {