├── .gitignore ├── LICENSE ├── README.md ├── media ├── button.svg └── hero.png ├── package-lock.json ├── package.json ├── src ├── constants │ └── index.ts ├── hooks │ ├── useDebounce.ts │ └── usePreviewBounds.ts ├── main.ts ├── store │ ├── createLight.ts │ ├── createPreview.ts │ ├── createPreviewBounds.ts │ ├── createSelection.ts │ ├── createShadowProps.ts │ ├── createTarget.ts │ └── useStore.ts ├── ui.tsx ├── ui │ ├── ColorInput │ │ ├── color-input.css │ │ ├── color-input.tsx │ │ └── normalize-hex-color.ts │ ├── Menu │ │ ├── OptionsPanel │ │ │ ├── Panel.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── options │ │ │ │ ├── Color.tsx │ │ │ │ ├── Parameters.tsx │ │ │ │ ├── Type.tsx │ │ │ │ └── parameters.css │ │ │ └── panel.css │ │ ├── menu.css │ │ └── menu.tsx │ └── Preview │ │ ├── components │ │ ├── Badge │ │ │ ├── badge.css │ │ │ └── badge.tsx │ │ ├── Light │ │ │ ├── BrightnessSlider.tsx │ │ │ ├── PositionDrag.tsx │ │ │ ├── brightness-slider.css │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── ShowAlignmentLines │ │ │ ├── lines.css │ │ │ └── lines.tsx │ │ ├── Target │ │ │ ├── Target.tsx │ │ │ ├── target.css │ │ │ ├── useElevationSlider.ts │ │ │ ├── useSelectionStyle.ts │ │ │ └── useShadowStyle.ts │ │ └── helpers │ │ │ └── align.ts │ │ ├── preview.css │ │ └── preview.tsx └── utils │ ├── color.ts │ ├── debounce.ts │ ├── grid.ts │ ├── math.ts │ ├── node.ts │ ├── selection.ts │ └── shadow.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | *.log 4 | *.css.d.ts 5 | build/ 6 | node_modules/ 7 | manifest.json 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexander Widua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [](https://www.figma.com/community/plugin/1068595505353552645/Beautiful-Shadows) 4 | 5 | Create beautiful, smooth shadows in Figma. 6 | The plugin allows the user to create realistic shadows by adding a 'light source' which casts a shadow on selected elements. 7 | 8 | Instead of thinking in abstract offsets, radius and spreads, the shadows are parameterized by `azimuth` (angle between light and element), `distance` (between light and element), `brightness` (of the light source) and `elevation` (of the shadow-receiving element). 9 | 10 | The shadow effect is achieved by layering and easing multiple drop-shadows to create a skeuomorphic umbra and penumbra. 11 | 12 | The plugin is cultivated by [Josh W. Comeau's](https://www.joshwcomeau.com/) amazing resource and read about [Designing beautiful shadows in CSS](https://www.joshwcomeau.com/css/designing-shadows/). 13 | 14 | ## 🌀 Misc 15 | 16 | This plugin uses the amazing [create-figma-plugin](https://github.com/yuanqing/create-figma-plugin) library. 17 | 18 | ## 📝 License 19 | 20 | [MIT](LICENSE) 21 | -------------------------------------------------------------------------------- /media/button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /media/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwidua/figma-beautiful-shadows/d4ce0e7b71cd95c78207c1a71f48977ce7cbb7d3/media/hero.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@alexwidua/create-figma-plugin-components": "^0.1.10", 4 | "@create-figma-plugin/ui": "^1.8.2", 5 | "@create-figma-plugin/utilities": "^1.8.2", 6 | "@react-spring/web": "^9.4.2", 7 | "@types/chroma-js": "^2.1.3", 8 | "@types/d3-ease": "^3.0.0", 9 | "@use-gesture/react": "^10.2.4", 10 | "chroma-js": "^2.1.2", 11 | "d3-ease": "^3.0.1", 12 | "preact": "^10", 13 | "stylelint": "^14.3.0", 14 | "stylelint-config-idiomatic-order": "^8.1.0", 15 | "zustand": "^3.6.9" 16 | }, 17 | "devDependencies": { 18 | "@create-figma-plugin/build": "^2.6.1", 19 | "@create-figma-plugin/tsconfig": "^2.6.1", 20 | "@figma/plugin-typings": "1.40.0", 21 | "@types/react": "^17.0.38", 22 | "react": "^17.0.2", 23 | "typescript": "^4" 24 | }, 25 | "scripts": { 26 | "build": "build-figma-plugin --typecheck --minify", 27 | "watch": "build-figma-plugin --typecheck --watch" 28 | }, 29 | "figma-plugin": { 30 | "editorType": [ 31 | "figma" 32 | ], 33 | "id": "1068595505353552645", 34 | "name": "Beautiful Shadows", 35 | "main": "src/main.ts", 36 | "ui": "src/ui.tsx", 37 | "relaunchButtons": { 38 | "editShadow": { 39 | "name": "Edit shadow", 40 | "main": "src/main.ts", 41 | "ui": "src/ui.tsx" 42 | } 43 | }, 44 | "networkAccess": { 45 | "allowedDomains": [ 46 | "none" 47 | ] 48 | } 49 | }, 50 | "stylelint": { 51 | "extends": "stylelint-config-idiomatic-order" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { ShadowType } from "../store/createShadowProps"; 2 | 3 | /** 4 | * Plugin window 5 | */ 6 | 7 | export const WINDOW_INITIAL_WIDTH = 375; 8 | export const WINDOW_INITIAL_HEIGHT = 375; 9 | export const WINDOW_MIN_WIDTH = 340; 10 | export const WINDOW_MIN_HEIGHT = 340; 11 | export const WINDOW_MAX_WIDTH = 900; 12 | export const WINDOW_MAX_HEIGHT = 900; 13 | 14 | /** 15 | * Performance 16 | */ 17 | export const DEBOUNCE_CANVAS_UPDATES = 8; // ms 18 | 19 | /** 20 | * Light source 21 | */ 22 | export const LIGHT_SIZE = 32; 23 | export const LIGHT_INITIAL_POSITION = { 24 | x: WINDOW_INITIAL_WIDTH / 2 - LIGHT_SIZE / 2, 25 | y: WINDOW_INITIAL_WIDTH / 2 - 148, 26 | }; 27 | export const LIGHT_INITIAL_BRIGHTNESS = 0.1; // value between 0-1 28 | export const LIGHT_MIN_BRIGHTNESS = 0.01; // value between 0-1 29 | 30 | /** 31 | * Target element which casts the shadow 32 | */ 33 | export const TARGET_INITIAL_ELEVATION = 0.15; 34 | 35 | /** 36 | * Background 37 | */ 38 | export const BACKGROUND_DEFAULT_COLOR = "#e5e5e5"; 39 | 40 | /** 41 | * Shadow 42 | */ 43 | export const SHADOW_DEFAULT_COLOR = "000000"; 44 | export const SHADOW_DEFAULT_TYPE: ShadowType = "DROP_SHADOW"; 45 | export const SHADOW_BASE_BLUR = 50; 46 | 47 | /** 48 | * Preview 49 | */ 50 | export const LIGHT_SNAP_TO_AXIS_TRESHOLD = 6; //px 51 | export const GRID_SIZE = 32; 52 | export const GRID_OPACITY = 0.25; 53 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'preact/hooks' 2 | 3 | function useDebounce(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500) 8 | 9 | return () => { 10 | clearTimeout(timer) 11 | } 12 | }, [value, delay]) 13 | 14 | return debouncedValue 15 | } 16 | 17 | export default useDebounce 18 | -------------------------------------------------------------------------------- /src/hooks/usePreviewBounds.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useState } from "preact/hooks"; 2 | import useDebounce from "./useDebounce"; 3 | import { WINDOW_INITIAL_WIDTH, WINDOW_INITIAL_HEIGHT } from "../constants"; 4 | 5 | export interface PreviewBounds { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | const DEBOUNCE_VALUE = 200; 11 | 12 | const usePreviewBounds = (ref: any): PreviewBounds => { 13 | const [previewBounds, setPreviewBounds] = useState({ 14 | width: WINDOW_INITIAL_WIDTH, 15 | height: WINDOW_INITIAL_HEIGHT, 16 | }); 17 | const debouncedValue = useDebounce(previewBounds, DEBOUNCE_VALUE); 18 | 19 | const getPreviewBounds = () => { 20 | if (!ref.current) return; 21 | const rect = ref.current.getBoundingClientRect(); 22 | 23 | setPreviewBounds({ 24 | width: rect.width, 25 | height: rect.height, 26 | }); 27 | }; 28 | 29 | useLayoutEffect(() => { 30 | getPreviewBounds(); 31 | }, []); 32 | 33 | useEffect(() => { 34 | window.addEventListener("resize", getPreviewBounds); 35 | return () => { 36 | window.removeEventListener("resize", getPreviewBounds); 37 | }; 38 | }, []); 39 | 40 | return debouncedValue; 41 | }; 42 | 43 | export default usePreviewBounds; 44 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { on, once, emit, showUI } from "@create-figma-plugin/utilities"; 2 | import { validateSelection, SelectionState } from "./utils/selection"; 3 | import { getCastedShadows } from "./utils/shadow"; 4 | import { hexToGL } from "./utils/color"; 5 | import { isSymbol } from "./utils/node"; 6 | import { WINDOW_INITIAL_WIDTH, WINDOW_INITIAL_HEIGHT } from "./constants"; 7 | 8 | // Types 9 | import { Preview } from "./store/createPreview"; 10 | import { Selection } from "./store/createSelection"; 11 | import { PreviewBounds } from "./hooks/usePreviewBounds"; 12 | 13 | const VERSION = 16; 14 | const PLUGIN_DATA_KEY = "beautiful_shadow"; 15 | const VALID_NODE_TYPES: Array = [ 16 | "BOOLEAN_OPERATION", 17 | "COMPONENT", 18 | "INSTANCE", 19 | "ELLIPSE", 20 | "FRAME", 21 | "GROUP", 22 | "LINE", 23 | "POLYGON", 24 | "RECTANGLE", 25 | "STAR", 26 | "TEXT", 27 | "VECTOR", 28 | ]; 29 | 30 | export interface PluginData extends Preview { 31 | version?: number; 32 | previewBounds: PreviewBounds; 33 | lightPosition: Vector; 34 | } 35 | 36 | type ErrorMessage = { [type in SelectionState]: string }; 37 | const ERROR_MSG: Pick = { 38 | MULTIPLE: "Select only one element or group elements together.", 39 | INVALID: "Element type not supported.", 40 | }; 41 | 42 | export default function () { 43 | /** 44 | * The preview variable holds all shadow information. 45 | * It gets updated everytime the user makes changes in the plugin UI. 46 | */ 47 | let pluginData: PluginData | undefined = undefined; 48 | 49 | /** 50 | * The nodeRef vairable holds a reference to the currently selected node. 51 | * We also store the existing node effects to restore them if the node is de-selected. 52 | */ 53 | let nodeRef: any | undefined = undefined; 54 | let existingNodeEffects: any = undefined; 55 | 56 | /** 57 | * When the plugin is closed or cancelled, by default we remove all applied node effects. 58 | * The APPLIED_SHADOW_EFFECTS flag skips this step to apply the changes. 59 | */ 60 | let APPLIED_SHADOW_EFFECTS: true | undefined; 61 | 62 | /** 63 | * Check if node has previously set shadow data and load 64 | */ 65 | function checkIfExistingShadowData(): PluginData | undefined { 66 | if (!nodeRef) return; 67 | const pluginData = nodeRef.getPluginData(PLUGIN_DATA_KEY); 68 | if (pluginData) { 69 | let data: PluginData; 70 | try { 71 | data = JSON.parse(pluginData); 72 | } catch (e) { 73 | return; 74 | } 75 | return data; 76 | } 77 | } 78 | 79 | /** 80 | * Handle selection change. 81 | * When the user (de)selects an element, we do two things: 82 | * 83 | * · See if shadow effects are applicable to the selected element 84 | * · If there are any overlapping nodes we can use to derive a background color 85 | * 86 | * We report our findings to the UI by emitting a SELECTION_CHANGE event. 87 | */ 88 | function handleSelectionChange(): void { 89 | const selection = figma.currentPage.selection; 90 | const state: SelectionState = validateSelection(selection, VALID_NODE_TYPES); 91 | if (state === "EMPTY") { 92 | console.error("*** Invalid selection (empty)"); 93 | cleanUpAndRestorePrevEffects(); 94 | } else if (state === "MULTIPLE" || state === "INVALID") { 95 | console.error("*** Invalid selection (multiple or invalid)"); 96 | cleanUpAndRestorePrevEffects(); 97 | figma.notify(ERROR_MSG[state]); 98 | } else if (state === "VALID" || state === "IS_WITHIN_COMPONENT") { 99 | if (nodeRef) cleanUpAndRestorePrevEffects(); 100 | nodeRef = selection[0]; 101 | existingNodeEffects = nodeRef.effects; 102 | } 103 | if (nodeRef?.removed) return; 104 | 105 | const data: Selection = { 106 | state, 107 | type: nodeRef?.type || undefined, 108 | width: nodeRef?.width || 0, 109 | height: nodeRef?.height || 0, 110 | cornerRadius: !isSymbol(nodeRef?.cornerRadius) ? nodeRef?.cornerRadius : 0, 111 | prevShadowEffects: checkIfExistingShadowData(), 112 | }; 113 | 114 | emit("SELECTION_CHANGE", data); 115 | } 116 | 117 | /** 118 | * Draw shadows ☀️ 119 | */ 120 | function drawShadows(): void { 121 | if (nodeRef?.removed) return; 122 | if (!nodeRef || !pluginData) return; 123 | const { azimuth, distance, elevation, brightness, shadowColor, shadowType } = pluginData; 124 | const color = hexToGL(shadowColor); 125 | const shadows = getCastedShadows({ 126 | numShadows: 6, 127 | azimuth, 128 | distance, 129 | elevation, 130 | brightness, 131 | color, 132 | shadowType, 133 | size: { width: nodeRef.width, height: nodeRef.height }, 134 | }); 135 | const stripExistingShadowEffects = nodeRef.effects.filter( 136 | (effect: Effect) => effect.type !== "DROP_SHADOW" && effect.type !== "INNER_SHADOW" 137 | ); 138 | nodeRef.effects = [...stripExistingShadowEffects, ...shadows]; 139 | } 140 | 141 | function updateSceneAndRedrawShadows(data: PluginData): void { 142 | pluginData = data; 143 | drawShadows(); 144 | } 145 | 146 | function cleanUpAndRestorePrevEffects(): void { 147 | if (nodeRef?.removed) return; 148 | if (nodeRef) nodeRef.effects = existingNodeEffects; 149 | nodeRef = undefined; 150 | existingNodeEffects = undefined; 151 | } 152 | 153 | /** 154 | * Register Figma event handlers 155 | */ 156 | figma.on("selectionchange", handleSelectionChange); 157 | figma.on("close", function () { 158 | if (APPLIED_SHADOW_EFFECTS) { 159 | nodeRef.setPluginData(PLUGIN_DATA_KEY, JSON.stringify({ ...pluginData, version: VERSION })); 160 | nodeRef.setRelaunchData({ 161 | editShadow: `Edit the shadow effect with Beautiful Shadows`, 162 | }); 163 | } else cleanUpAndRestorePrevEffects(); 164 | }); 165 | 166 | /** 167 | * Listen and act on updates from the UI side 168 | */ 169 | on("SHOW_MESSAGE", (msg: string) => figma.notify(msg)); 170 | on("UPDATE_SHADOWS", updateSceneAndRedrawShadows); 171 | on("RESIZE_WINDOW", function (windowSize: { width: number; height: number }) { 172 | const { width, height } = windowSize; 173 | figma.ui.resize(width, height); 174 | }); 175 | once("APPLY", function () { 176 | APPLIED_SHADOW_EFFECTS = true; 177 | figma.closePlugin(); 178 | }); 179 | 180 | once("CLOSE", function () { 181 | figma.closePlugin(); 182 | }); 183 | 184 | showUI({ 185 | width: WINDOW_INITIAL_WIDTH, 186 | height: WINDOW_INITIAL_HEIGHT, 187 | }); 188 | handleSelectionChange(); // emit selection to UI on plugin startup 189 | } 190 | -------------------------------------------------------------------------------- /src/store/createLight.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from 'zustand' 2 | import { Store } from './useStore' 3 | import { Alignment } from '../ui/Preview/components/helpers/align' 4 | import { 5 | LIGHT_SIZE, 6 | LIGHT_INITIAL_POSITION, 7 | LIGHT_INITIAL_BRIGHTNESS 8 | } from '../constants' 9 | 10 | export type Light = { 11 | size: number 12 | x: number 13 | y: number 14 | brightness: number 15 | alignment: Alignment 16 | positionPointerDown: boolean 17 | brightnessPointerDown: boolean 18 | shiftKeyDown: boolean 19 | } 20 | export interface LightState { 21 | light: Light 22 | setLight: (arg: LightState | Partial) => void 23 | } 24 | 25 | const createLight = (set: SetState) => ({ 26 | light: { 27 | size: LIGHT_SIZE, 28 | x: LIGHT_INITIAL_POSITION.x, 29 | y: LIGHT_INITIAL_POSITION.y, 30 | brightness: LIGHT_INITIAL_BRIGHTNESS, 31 | alignment: 'NONE' as Alignment, 32 | positionPointerDown: false, 33 | brightnessPointerDown: false, 34 | shiftKeyDown: false 35 | }, 36 | setLight: (light: LightState | Partial) => 37 | set((state) => ({ 38 | light: { ...state.light, ...light } 39 | })) 40 | }) 41 | 42 | export default createLight 43 | -------------------------------------------------------------------------------- /src/store/createPreview.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from 'zustand' 2 | import { Store } from './useStore' 3 | import { 4 | TARGET_INITIAL_ELEVATION, 5 | LIGHT_INITIAL_BRIGHTNESS, 6 | BACKGROUND_DEFAULT_COLOR 7 | } from '../constants' 8 | import { ShadowType } from './createShadowProps' 9 | 10 | export type Preview = { 11 | azimuth: number 12 | distance: number 13 | elevation: number 14 | brightness: number 15 | shadowColor: string 16 | shadowType: ShadowType 17 | backgroundColor?: string 18 | } 19 | export interface PreviewState { 20 | preview: Preview 21 | setPreview: (arg: Preview | Partial) => void 22 | } 23 | 24 | const createPreview = (set: SetState) => ({ 25 | preview: { 26 | azimuth: 0, 27 | distance: 0, 28 | elevation: TARGET_INITIAL_ELEVATION, 29 | brightness: LIGHT_INITIAL_BRIGHTNESS, 30 | shadowColor: '000000', 31 | shadowType: 'DROP_SHADOW' as ShadowType, 32 | backgroundColor: BACKGROUND_DEFAULT_COLOR 33 | }, 34 | setPreview: (preview: Preview | Partial) => 35 | set((state) => ({ preview: { ...state.preview, ...preview } })) 36 | }) 37 | 38 | export default createPreview 39 | -------------------------------------------------------------------------------- /src/store/createPreviewBounds.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from 'zustand' 2 | import { Store } from './useStore' 3 | import { PreviewBounds } from '../hooks/usePreviewBounds' 4 | 5 | export interface PreviewBoundsState { 6 | previewBounds: PreviewBounds 7 | setPreviewBounds: (args: PreviewBounds) => void 8 | } 9 | 10 | const createPreviewBounds = (set: SetState) => ({ 11 | previewBounds: { width: 0, height: 0 }, 12 | setPreviewBounds: (bounds: PreviewBounds) => 13 | set(() => ({ previewBounds: bounds })) 14 | }) 15 | 16 | export default createPreviewBounds 17 | -------------------------------------------------------------------------------- /src/store/createSelection.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from "zustand"; 2 | import { Store } from "./useStore"; 3 | import { SelectionState as _SelectionState } from "../utils/selection"; 4 | 5 | export type Selection = { 6 | state: _SelectionState; 7 | width: number; 8 | height: number; 9 | cornerRadius: number; 10 | type: NodeType; 11 | prevShadowEffects: any; 12 | }; 13 | export interface SelectionState { 14 | selection: Selection; 15 | setSelection: (arg: Selection) => void; 16 | } 17 | 18 | const createSelection = (set: SetState) => ({ 19 | selection: { 20 | state: "EMPTY" as _SelectionState, 21 | width: 0, 22 | height: 0, 23 | cornerRadius: 0, 24 | type: "RECTANGLE" as NodeType, 25 | prevShadowEffects: undefined, 26 | }, 27 | setSelection: (selection: Selection) => 28 | set(() => ({ 29 | selection: { ...selection }, 30 | })), 31 | }); 32 | 33 | export default createSelection; 34 | -------------------------------------------------------------------------------- /src/store/createShadowProps.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from 'zustand' 2 | import { Store } from './useStore' 3 | 4 | export interface ShadowProps { 5 | color: string 6 | type: ShadowType 7 | setColor: (arg: string) => void 8 | setType: (arg: ShadowType) => void 9 | toggleType: () => void 10 | } 11 | export type ShadowType = 'DROP_SHADOW' | 'INNER_SHADOW' 12 | 13 | const createShadowProps = (set: SetState) => ({ 14 | color: '000000', 15 | type: 'DROP_SHADOW' as ShadowType, 16 | setColor: (color: string) => set({ color }), 17 | setType: (type: ShadowType) => set({ type }), 18 | toggleType: () => 19 | set((state) => { 20 | const value: ShadowType = 21 | state.type === 'DROP_SHADOW' ? 'INNER_SHADOW' : 'DROP_SHADOW' 22 | return { type: value } 23 | }) 24 | }) 25 | 26 | export default createShadowProps 27 | -------------------------------------------------------------------------------- /src/store/createTarget.ts: -------------------------------------------------------------------------------- 1 | import { SetState } from 'zustand' 2 | import { Store } from './useStore' 3 | import { TARGET_INITIAL_ELEVATION } from '../constants' 4 | 5 | export type Target = { 6 | x: number 7 | y: number 8 | elevation: number 9 | elevationPointerDown: boolean 10 | } 11 | export interface TargetState { 12 | target: Target 13 | setTarget: (arg: TargetState | Partial) => void 14 | } 15 | 16 | const createTarget = (set: SetState) => ({ 17 | target: { 18 | x: 0, 19 | y: 0, 20 | elevation: TARGET_INITIAL_ELEVATION, 21 | elevationPointerDown: false 22 | }, 23 | setTarget: (target: TargetState | Partial) => 24 | set((state) => ({ target: { ...state.target, ...target } })) 25 | }) 26 | 27 | export default createTarget 28 | -------------------------------------------------------------------------------- /src/store/useStore.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | import createLight, { LightState } from './createLight' 3 | import createPreview, { PreviewState } from './createPreview' 4 | import createPreviewBounds, { PreviewBoundsState } from './createPreviewBounds' 5 | import createSelection, { SelectionState } from './createSelection' 6 | import createShadowProps, { ShadowProps } from './createShadowProps' 7 | import createTarget, { TargetState } from './createTarget' 8 | 9 | export type Store = LightState & 10 | PreviewState & 11 | PreviewBoundsState & 12 | SelectionState & 13 | ShadowProps & 14 | TargetState & { setEntireStore: (arg: any) => void } 15 | 16 | const useStore = create((set) => ({ 17 | ...createLight(set), 18 | ...createPreview(set), 19 | ...createPreviewBounds(set), 20 | ...createSelection(set), 21 | ...createShadowProps(set), 22 | ...createTarget(set), 23 | setEntireStore: (data: Store) => 24 | set({ 25 | ...createLight, 26 | ...createPreview, 27 | ...createPreviewBounds, 28 | ...createSelection, 29 | ...createShadowProps, 30 | ...createTarget, 31 | ...data 32 | }) 33 | })) 34 | 35 | export default useStore 36 | -------------------------------------------------------------------------------- /src/ui.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from "preact"; 2 | import useStore from "./store/useStore"; 3 | import { useRef, useEffect, useCallback } from "preact/hooks"; 4 | import { emit, on } from "@create-figma-plugin/utilities"; 5 | import { useWindowResize, render } from "@create-figma-plugin/ui"; 6 | import { clamp } from "./utils/math"; 7 | import chroma from "chroma-js"; 8 | import PreviewEditor from "./ui/Preview/preview"; 9 | import ColorInput from "./ui/ColorInput/color-input"; 10 | import Menu from "./ui/Menu/menu"; 11 | 12 | // Constants 13 | import { 14 | WINDOW_INITIAL_WIDTH, 15 | WINDOW_INITIAL_HEIGHT, 16 | WINDOW_MIN_WIDTH, 17 | WINDOW_MAX_WIDTH, 18 | WINDOW_MIN_HEIGHT, 19 | WINDOW_MAX_HEIGHT, 20 | LIGHT_INITIAL_POSITION, 21 | LIGHT_INITIAL_BRIGHTNESS, 22 | TARGET_INITIAL_ELEVATION, 23 | SHADOW_DEFAULT_COLOR, 24 | SHADOW_DEFAULT_TYPE, 25 | BACKGROUND_DEFAULT_COLOR, 26 | } from "./constants"; 27 | 28 | // Types 29 | import { Light } from "./store/createLight"; 30 | import { Target } from "./store/createTarget"; 31 | import { Selection } from "./store/createSelection"; 32 | import { PluginData } from "./main"; 33 | 34 | const Plugin = () => { 35 | const bounds = useRef(); 36 | makePluginResizeable(); 37 | const { light, target, color, setColor, previewBgColor, setPreview, setSelection, setEntireStore } = useStore( 38 | (state) => ({ 39 | light: state.light, 40 | target: state.target, 41 | color: state.color, 42 | setColor: state.setColor, 43 | previewBgColor: state.preview.backgroundColor, 44 | setPreview: state.setPreview, 45 | setSelection: state.setSelection, 46 | setEntireStore: state.setEntireStore, 47 | }) 48 | ); 49 | 50 | /** 51 | * 👂 Listen for changes FROM plugin (main.ts) 52 | * 53 | * · See if the selected element has shadows from an earlier sessions and if yes, update the preview with said values 54 | * · Listen for selection updates and style the target element respectively 55 | */ 56 | useEffect(() => { 57 | on("SELECTION_CHANGE", handleSelectionChange); 58 | on("LOAD_EXISTING_SHADOW_DATA", restorePrevEffectsAndSettings); 59 | }, []); 60 | 61 | const restorePrevEffectsAndSettings = useCallback((pluginData: PluginData) => { 62 | if (!pluginData) return; 63 | const x = pluginData.lightPosition?.x || LIGHT_INITIAL_POSITION.x; 64 | const y = pluginData.lightPosition?.y || LIGHT_INITIAL_POSITION.y; 65 | const color = pluginData.shadowColor || SHADOW_DEFAULT_COLOR; 66 | const type = pluginData.shadowType || SHADOW_DEFAULT_TYPE; 67 | const brightness = pluginData.brightness || LIGHT_INITIAL_BRIGHTNESS; 68 | const elevation = pluginData.elevation || TARGET_INITIAL_ELEVATION; 69 | 70 | const lightData: Pick = { 71 | x, 72 | y, 73 | brightness, 74 | }; 75 | const targetData: Pick = { elevation }; 76 | 77 | setEntireStore({ 78 | color, 79 | type, 80 | light: { ...light, ...lightData }, 81 | target: { ...target, ...targetData }, 82 | }); 83 | 84 | const restoreWindowSize = { 85 | width: Math.round(pluginData.previewBounds?.width) || WINDOW_INITIAL_WIDTH, 86 | height: Math.round(pluginData.previewBounds?.height) || WINDOW_INITIAL_HEIGHT, 87 | }; 88 | emit("RESIZE_WINDOW", restoreWindowSize); 89 | 90 | // Shadows created in version =<9 don't have the lightPosition property 91 | if (Object.keys(pluginData).length < 5) { 92 | emit("SHOW_MESSAGE", `Couldn't restore all previous shadow settings due to a newer version. Sorry!`); 93 | } else { 94 | emit("SHOW_MESSAGE", "Restored previous shadow settings."); 95 | } 96 | }, []); 97 | 98 | const handleSelectionChange = useCallback((selection: Selection) => { 99 | setSelection(selection); 100 | 101 | const { prevShadowEffects } = selection; 102 | if (prevShadowEffects) { 103 | restorePrevEffectsAndSettings(prevShadowEffects); 104 | } 105 | }, []); 106 | 107 | function handleShadowColorChange(newColor: string) { 108 | setColor(newColor); 109 | } 110 | 111 | function handleBgColorChange(newColor: string) { 112 | setPreview({ 113 | backgroundColor: newColor, 114 | }); 115 | } 116 | 117 | return ( 118 | 119 | 126 | 133 | 134 | 135 | ); 136 | }; 137 | 138 | const makePluginResizeable = () => { 139 | const onWindowResize = (windowSize: { width: number; height: number }) => { 140 | emit("RESIZE_WINDOW", windowSize); 141 | }; 142 | useWindowResize(onWindowResize, { 143 | minWidth: WINDOW_MIN_WIDTH, 144 | minHeight: WINDOW_MIN_HEIGHT, 145 | maxWidth: WINDOW_MAX_WIDTH, 146 | maxHeight: WINDOW_MAX_HEIGHT, 147 | }); 148 | return null; 149 | }; 150 | 151 | export default render(Plugin); 152 | -------------------------------------------------------------------------------- /src/ui/ColorInput/color-input.css: -------------------------------------------------------------------------------- 1 | .colorSwatch { 2 | position: absolute; 3 | z-index: var(--z-index-1); 4 | bottom: 1.5rem; 5 | left: 1.5rem; 6 | } 7 | 8 | .colorSwatch.round { 9 | width: 34px; 10 | height: 34px; 11 | } 12 | 13 | .colorSwatch.square { 14 | width: 30px; 15 | height: 30px; 16 | } 17 | 18 | .disabled { 19 | opacity: var(--opacity-30); 20 | } 21 | 22 | .color { 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | display: flex; 27 | overflow: hidden; 28 | width: 100%; 29 | height: 100%; 30 | border: 2px solid #fff; 31 | pointer-events: none; 32 | } 33 | 34 | .round .color { 35 | border-radius: 100%; 36 | box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.25); 37 | } 38 | 39 | .square .color { 40 | border-radius: 6px; 41 | } 42 | 43 | .colorFill { 44 | flex-grow: 1; 45 | } 46 | 47 | .hexColorSelector { 48 | position: absolute; 49 | top: 0; 50 | left: 0; 51 | width: 100%; 52 | height: 100%; 53 | opacity: 0; 54 | } 55 | 56 | .hexColorSelector:hover ~ .color { 57 | opacity: 0.9; 58 | } 59 | 60 | .hexColorSelector:focus ~ .color { 61 | border-color: var(--color-blue); 62 | } 63 | 64 | .disabled .hexColorSelector { 65 | cursor: not-allowed; 66 | } 67 | 68 | .input::-webkit-inner-spin-button, 69 | .input::-webkit-outer-spin-button { 70 | -webkit-appearance: none; 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/ColorInput/color-input.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Forked from https://github.com/yuanqing/create-figma-plugin/blob/main/packages/ui/src/components/textbox/textbox-color/textbox-color.tsx 3 | */ 4 | import { h, JSX } from "preact"; 5 | import { useCallback } from "preact/hooks"; 6 | import { createClassName } from "@create-figma-plugin/ui"; 7 | import { normalizeUserInputColor } from "./normalize-hex-color"; 8 | import { MIXED_STRING } from "@create-figma-plugin/utilities"; 9 | import styles from "./color-input.css"; 10 | 11 | const EMPTY_STRING = ""; 12 | 13 | export default function ColorInput({ 14 | disabled = false, 15 | name, 16 | hexColor, 17 | hasMixedColors, 18 | onHexColorInput, 19 | type = "round", 20 | ...rest 21 | }: any): JSX.Element { 22 | const handleHexColorSelectorInput = useCallback(function (event: JSX.TargetedEvent): void { 23 | const hexColor = event.currentTarget.value.slice(1).toUpperCase(); 24 | onHexColorInput(hexColor); 25 | }, []); 26 | 27 | const normalizedHexColor = 28 | hexColor === EMPTY_STRING || hexColor === MIXED_STRING ? "FFFFFF" : normalizeUserInputColor(hexColor); 29 | 30 | return ( 31 |
39 | 47 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/ColorInput/normalize-hex-color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Forked from https://github.com/yuanqing/create-figma-plugin/blob/main/packages/ui/src/components/textbox/textbox-color/private/normalize-hex-color.ts 3 | */ 4 | 5 | import { convertNamedColorToHexColor, isValidHexColor } from "@create-figma-plugin/utilities"; 6 | 7 | export function normalizeUserInputColor(string: string): null | string { 8 | const parsedNamedColor = convertNamedColorToHexColor(string); 9 | if (parsedNamedColor !== null) { 10 | return parsedNamedColor; 11 | } 12 | const hexColor = createHexColor(string).toUpperCase(); 13 | if (isValidHexColor(hexColor) === false) { 14 | return null; 15 | } 16 | return hexColor; 17 | } 18 | 19 | function createHexColor(string: string): string { 20 | switch (string.length) { 21 | case 0: { 22 | return ""; 23 | } 24 | case 1: { 25 | return Array(6).fill(string).join(""); 26 | } 27 | case 2: { 28 | return Array(3).fill(string).join(""); 29 | } 30 | case 3: 31 | case 4: 32 | case 5: { 33 | return `${string[0]}${string[0]}${string[1]}${string[1]}${string[2]}${string[2]}`; 34 | } 35 | case 6: { 36 | return string; 37 | } 38 | default: { 39 | return string.slice(0, 6); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/Panel.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file This file is taken from the '@alexwidua/create-figma-plugin-components' and 3 | * makes some minor changes to the title bar prop 4 | * 5 | * TODO: Update '@alexwidua/create-figma-plugin-components' to avoid redundancy 6 | */ 7 | import { h, ComponentChildren, RefObject } from 'preact' 8 | import { useState, useEffect, useRef } from 'preact/hooks' 9 | import { useDrag } from '@use-gesture/react' 10 | import { useSpring, animated } from '@react-spring/web' 11 | import { 12 | createClassName, 13 | IconButton, 14 | IconCross32 15 | } from '@create-figma-plugin/ui' 16 | import { CSSProperties } from 'react' 17 | import styles from './panel.css' 18 | 19 | export interface PanelProps { 20 | open: boolean 21 | title?: ComponentChildren 22 | children: ComponentChildren 23 | boundsRef: RefObject 24 | anchorRef: RefObject 25 | anchorMargin?: number 26 | anchorAlign?: 'LEFT' | 'RIGHT' 27 | onClose: () => void 28 | style?: CSSProperties 29 | } 30 | 31 | export function Panel({ 32 | open, 33 | title = 'Options', 34 | boundsRef, 35 | anchorRef, 36 | anchorMargin = 8, 37 | anchorAlign = 'LEFT', 38 | onClose, 39 | children, 40 | ...rest 41 | }: PanelProps) { 42 | const panelRef = useRef(null) 43 | 44 | /** 45 | * Spawn panel above anchorRef element 46 | */ 47 | useEffect(() => { 48 | if (!anchorRef?.current || !panelRef?.current) return 49 | const anchorRect = anchorRef.current.getBoundingClientRect() 50 | const panelRect = panelRef.current.getBoundingClientRect() 51 | 52 | const alignXToAnchorLeft = 53 | anchorRect.x + panelRect.width - window.innerWidth 54 | const alignXToAnchorRight = 55 | anchorRect.x + anchorRect.width - window.innerWidth 56 | const y = anchorRect.y - window.innerHeight - anchorMargin 57 | 58 | animate.set({ 59 | x: 60 | anchorAlign === 'LEFT' 61 | ? alignXToAnchorLeft 62 | : alignXToAnchorRight, 63 | y 64 | }) 65 | }, [panelRef, open]) 66 | 67 | const [{ x, y }, animate] = useSpring(() => ({ x: 0, y: 0 })) 68 | const drag = useDrag( 69 | ({ offset: [ox, oy] }) => { 70 | animate.set({ x: ox, y: oy }) 71 | }, 72 | { 73 | bounds: boundsRef, 74 | from: () => [x.get(), y.get()] 75 | } 76 | ) 77 | 78 | /** 79 | * Keep panel in bounds when plugin window is resizeable 80 | */ 81 | const [boundsRect, setBoundsRect] = useState({ width: 0, height: 0 }) 82 | useEffect(() => { 83 | const handleResize = () => { 84 | if (!boundsRef?.current) return 85 | const rect = boundsRef.current.getBoundingClientRect() 86 | setBoundsRect({ width: rect.width, height: rect.height }) 87 | } 88 | window.addEventListener('resize', handleResize) 89 | return () => { 90 | window.removeEventListener('resize', handleResize) 91 | } 92 | }, []) 93 | useEffect(() => { 94 | if (!panelRef.current || !boundsRect.width || !boundsRect.height) return 95 | 96 | const panelRect = panelRef.current.getBoundingClientRect() 97 | const outOfBoundsX = 98 | Math.abs(x.get() - panelRect.width) > window.innerWidth 99 | const outOfBoundsY = 100 | Math.abs(y.get() - panelRect.height) > window.innerHeight 101 | 102 | animate.set({ 103 | x: outOfBoundsX ? 0 - window.innerWidth + panelRect.width : x.get(), 104 | y: outOfBoundsY 105 | ? 0 - window.innerHeight + panelRect.height 106 | : y.get() 107 | }) 108 | }, [boundsRect]) 109 | 110 | return ( 111 | 120 |
121 | {title} 122 | 123 | 124 | 125 |
126 | {children} 127 |
128 | ) 129 | } 130 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/index.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .row:first-child { 7 | margin-right: 16px !important; 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Container, VerticalSpace } from "@create-figma-plugin/ui"; 3 | import Parameters from "./options/Parameters"; 4 | import Type from "./options/Type"; 5 | import { Panel } from "./Panel"; 6 | import styles from "./index.css"; 7 | 8 | const Options = ({ bounds, anchor, open, onClose }: any) => { 9 | return ( 10 | } 12 | boundsRef={bounds} 13 | anchorRef={anchor} 14 | open={open} 15 | onClose={onClose} 16 | anchorAlign="RIGHT" 17 | anchorMargin={0} 18 | > 19 | 20 | 21 |
22 | 23 |
24 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default Options; 31 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/options/Color.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact' 2 | import useStore from '../../../../store/useStore' 3 | import { TextboxColor } from '@alexwidua/create-figma-plugin-components' 4 | 5 | const Color = () => { 6 | const { color, setColor } = useStore((state) => ({ 7 | color: state.color, 8 | setColor: state.setColor 9 | })) 10 | 11 | function handleHexColorInput(event: JSX.TargetedEvent) { 12 | const newHexColor = event.currentTarget.value 13 | setColor(newHexColor) 14 | } 15 | 16 | return ( 17 |
18 | 24 |
25 | ) 26 | } 27 | 28 | export default Color 29 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/options/Parameters.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from "preact"; 2 | import useStore from "../../../../store/useStore"; 3 | import { useState, useEffect } from "preact/hooks"; 4 | import { percent, clamp, deriveXYFromAngle, degreeToRadian } from "../../../../utils/math"; 5 | import { TextboxNumeric } from "@create-figma-plugin/ui"; 6 | import styles from "./parameters.css"; 7 | 8 | // Types 9 | import { Preview } from "../../../../store/createPreview"; 10 | import { Light } from "../../../../store/createLight"; 11 | import { Target } from "../../../../store/createTarget"; 12 | 13 | const Parameters = () => { 14 | /** 15 | * 💾 Store 16 | */ 17 | const { previewBounds, lightSize, azimuth, distance, brightness, elevation, setPreview, setLight, setTarget } = 18 | useStore((state) => ({ 19 | previewBounds: state.previewBounds, 20 | lightSize: state.light.size, 21 | azimuth: state.preview.azimuth, 22 | distance: state.preview.distance, 23 | brightness: state.preview.brightness, 24 | elevation: state.preview.elevation, 25 | setPreview: state.setPreview, 26 | setLight: state.setLight, 27 | setTarget: state.setTarget, 28 | })); 29 | 30 | /** 31 | * 📐 Azimuth 32 | */ 33 | const [tempAzimuth, setTempAzimuth] = useState("0°"); 34 | const validateAzimuth = (value: null | number) => { 35 | if (value === null) return null; 36 | const valid = value >= 0 && value <= 360; 37 | if (valid) { 38 | const { dx, dy } = deriveXYFromAngle(value, distance); 39 | const adjustedX = previewBounds.width / 2 - lightSize / 2 - dx; 40 | const adjustedY = previewBounds.height / 2 - lightSize / 2 - dy; 41 | const data: Pick = { x: adjustedX, y: adjustedY }; 42 | setLight(data); 43 | } 44 | return valid; 45 | }; 46 | useEffect(() => { 47 | setTempAzimuth(Math.round(azimuth) + "°"); 48 | }, [azimuth]); 49 | 50 | /** 51 | * 📏 Distance 52 | */ 53 | const [tempDistance, setTempDistance] = useState("0"); 54 | const validateDistance = (value: null | number) => { 55 | if (value === null) return null; 56 | // TODO: Bounds math still not perfect... 57 | const { width, height } = previewBounds; 58 | const boundsX = width / 2; 59 | const boundsY = height / 2; 60 | const delta = degreeToRadian(azimuth); 61 | const cos = Math.cos(delta); //x 62 | const sin = Math.sin(delta); //u 63 | const bounds = Math.sqrt(Math.abs(boundsX * boundsX * cos) + Math.abs(boundsY * boundsY * sin)) - lightSize / 2; 64 | 65 | const valid = value >= 0 && value <= bounds; 66 | const data: Pick = { distance: valid ? value : bounds }; 67 | setPreview(data); 68 | return valid; 69 | }; 70 | 71 | useEffect(() => { 72 | setTempDistance(Math.round(distance).toString()); 73 | }, [distance]); 74 | 75 | /** 76 | * ☀️ Brightness 77 | */ 78 | const [tempBrightness, setTempBrightness] = useState("0"); 79 | const validateBrightness = (value: null | number) => { 80 | if (value === null) return null; 81 | const valid = value >= 0 && value <= 100; 82 | if (valid) { 83 | const data: Pick = { brightness: value / 100 }; // normalize value back to 0..1 84 | setLight(data); 85 | } 86 | return valid; 87 | }; 88 | useEffect(() => { 89 | setTempBrightness(percent(brightness) + "%"); 90 | }, [brightness]); 91 | 92 | /** 93 | * ⛰️ Elevation 94 | */ 95 | const [tempElevation, setTempElevation] = useState("0"); 96 | const validateElevation = (value: null | number) => { 97 | if (value === null) return null; 98 | const valid = value >= 0 && value <= 100; 99 | if (valid) { 100 | const data: Pick = { elevation: value / 100 }; // normalize value back to 0..1 101 | setTarget(data); 102 | } 103 | return valid; 104 | }; 105 | useEffect(() => { 106 | setTempElevation(clamp(percent(elevation), 0, 100) + "%"); 107 | }, [elevation]); 108 | 109 | return ( 110 |
111 | ) => setTempAzimuth(e.currentTarget.value)} 115 | value={tempAzimuth} 116 | suffix={"°"} 117 | validateOnBlur={validateAzimuth} 118 | noBorder 119 | /> 120 | ) => setTempDistance(e.currentTarget.value)} 124 | value={tempDistance} 125 | validateOnBlur={validateDistance} 126 | noBorder 127 | /> 128 | } 131 | onInput={(e: JSX.TargetedEvent) => setTempBrightness(e.currentTarget.value)} 132 | value={tempBrightness} 133 | suffix={"%"} 134 | validateOnBlur={validateBrightness} 135 | noBorder 136 | /> 137 | } 140 | onInput={(e: JSX.TargetedEvent) => setTempElevation(e.currentTarget.value)} 141 | value={tempElevation} 142 | suffix={"%"} 143 | validateOnBlur={validateElevation} 144 | noBorder 145 | /> 146 |
147 | ); 148 | }; 149 | 150 | /** 151 | * Icons 152 | */ 153 | const IconBrightness16 = ({ v = 0 }) => { 154 | const inc = Math.cos(v); 155 | return ( 156 | 157 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | ); 173 | }; 174 | 175 | const IconElevation16 = ({ v = 0 }) => { 176 | return ( 177 | 178 | 179 | 180 | 181 | ); 182 | }; 183 | 184 | export default Parameters; 185 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/options/Type.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact' 2 | import useStore from '../../../../store/useStore' 3 | import { Dropdown } from '@create-figma-plugin/ui' 4 | import { ShadowType } from '../../../../store/createShadowProps' 5 | 6 | type DropdownOption = { value: ShadowType; children: string } 7 | const options: DropdownOption[] = [ 8 | { value: 'DROP_SHADOW', children: 'Drop Shadow' }, 9 | { value: 'INNER_SHADOW', children: 'Inner Shadow' } 10 | ] 11 | 12 | const Type = () => { 13 | const { shadowType, setShadowType } = useStore((state) => ({ 14 | shadowType: state.type, 15 | setShadowType: state.setType 16 | })) 17 | 18 | const handleDropdownChange = (e: JSX.TargetedEvent) => { 19 | const newValue = e.currentTarget.value as ShadowType 20 | setShadowType(newValue) 21 | } 22 | 23 | return ( 24 |
25 | 31 |
32 | ) 33 | } 34 | 35 | export default Type 36 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/options/parameters.css: -------------------------------------------------------------------------------- 1 | .parameters { 2 | display: grid; 3 | grid-gap: 4px; 4 | grid-template-columns: 1fr 1fr; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/Menu/OptionsPanel/panel.css: -------------------------------------------------------------------------------- 1 | .panel { 2 | position: absolute; 3 | z-index: 99999; 4 | right: 0; 5 | bottom: 0; 6 | width: auto; 7 | height: auto; 8 | background: var(--color-white); 9 | border-radius: var(--border-radius-2); 10 | box-shadow: var(--box-shadow-modal); 11 | opacity: 0; 12 | pointer-events: none; 13 | touch-action: none; 14 | visibility: hidden; 15 | } 16 | 17 | .panel.open { 18 | opacity: 1; 19 | pointer-events: all; 20 | visibility: visible; 21 | } 22 | 23 | .titlebar { 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | padding: 4px 4px 4px 5px; 28 | border-bottom: 1px solid var(--color-black-6-translucent); 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/Menu/menu.css: -------------------------------------------------------------------------------- 1 | .menu { 2 | position: absolute; 3 | right: 1rem; 4 | bottom: 1rem; 5 | padding: var(--space-extra-small); 6 | background: #fff; 7 | border-radius: 8px; 8 | box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.05); 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/Menu/menu.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import useStore from '../../store/useStore' 3 | import { useRef, useState, useEffect, useCallback } from 'preact/hooks' 4 | import { emit } from '@create-figma-plugin/utilities' 5 | import { 6 | Button, 7 | Columns, 8 | IconButton, 9 | IconEllipsis32, 10 | IconCode32 11 | } from '@create-figma-plugin/ui' 12 | import OptionsPanel from './OptionsPanel' 13 | import styles from './menu.css' 14 | 15 | const Menu = ({ bounds }: any) => { 16 | const selection = useStore((state) => state.selection) 17 | 18 | const [optionsPanelOpen, setOptionsPanelOpen] = useState(false) 19 | const menuRef = useRef() 20 | 21 | const applyShadowsToSelectedCanvasElement = useCallback(() => { 22 | emit('APPLY') 23 | }, []) 24 | 25 | return ( 26 |
27 | 28 | setOptionsPanelOpen((prev) => !prev)} 30 | value={optionsPanelOpen}> 31 | 32 | 33 | 43 | 44 | setOptionsPanelOpen(false)} 49 | /> 50 |
51 | ) 52 | } 53 | 54 | export default Menu 55 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Badge/badge.css: -------------------------------------------------------------------------------- 1 | .badge { 2 | display: inline-block; 3 | padding: 1px 6px; 4 | background: var(--color-blue); 5 | border-radius: 3px; 6 | color: #fff; 7 | font-size: 10px; 8 | pointer-events: none; 9 | white-space: nowrap; 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Badge/badge.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import styles from './badge.css' 3 | 4 | const Badge = ({ 5 | visible = true, 6 | children, 7 | style, 8 | ...rest 9 | }: { 10 | visible?: boolean 11 | children: any 12 | style?: any 13 | }) => { 14 | return ( 15 |
19 | {children} 20 |
21 | ) 22 | } 23 | 24 | export default Badge 25 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Light/BrightnessSlider.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from 'preact' 2 | import useStore from '../../../../store/useStore' 3 | import { useEffect } from 'preact/hooks' 4 | import { useDrag } from '@use-gesture/react' 5 | import { useSpring, animated } from '@react-spring/web' 6 | import { stepped, normalize, denormalize, clamp } from '../../../../utils/math' 7 | import styles from './brightness-slider.css' 8 | import { LIGHT_INITIAL_BRIGHTNESS } from '../../../../constants' 9 | 10 | // Types 11 | import { Light } from '../../../../store/createLight' 12 | 13 | /** 14 | * Constants 15 | */ 16 | const brightnessDragMin = 0 17 | const brightnessDragMax = -20 18 | const sunRayMinWidth = 4 19 | const sunRayMaxWidth = 16 20 | const sunRayThickness = 1 21 | const sunRayRadius = 24 22 | const opacity_min = 0.4 23 | const opacity_max = 1 24 | 25 | const BrightnessSlider = ({ 26 | isHuggingTop, 27 | children 28 | }: { 29 | isHuggingTop: boolean 30 | children: any 31 | }) => { 32 | /** 33 | * 💾 Store 34 | */ 35 | const { brightness, positionPointerDown, brightnessPointerDown, setLight } = 36 | useStore((state) => ({ 37 | brightness: state.light.brightness, 38 | positionPointerDown: state.light.positionPointerDown, 39 | brightnessPointerDown: state.light.brightnessPointerDown, 40 | setLight: state.setLight 41 | })) 42 | 43 | const [{ y, opacity, translateSunRays, sunRayWidth }, animateSlider] = 44 | useSpring(() => ({ 45 | y: brightnessDragMax * LIGHT_INITIAL_BRIGHTNESS, 46 | opacity: opacity_min, 47 | translateSunRays: sunRayRadius * 1.5, 48 | sunRayWidth: sunRayMaxWidth * LIGHT_INITIAL_BRIGHTNESS 49 | })) 50 | const slide: any = useDrag( 51 | ({ event, down: brightnessPointerDown, shiftKey, offset: [_, oy] }) => { 52 | event.stopPropagation() 53 | const value = shiftKey 54 | ? stepped(oy, 2) 55 | : isHuggingTop 56 | ? Math.abs(oy) - Math.abs(brightnessDragMax) 57 | : oy 58 | const normalized = Math.min( 59 | normalize(value, brightnessDragMin, brightnessDragMax), 60 | 1 61 | ) 62 | const data: Pick = { 63 | brightness: normalized, 64 | brightnessPointerDown 65 | } 66 | setLight(data) 67 | }, 68 | { 69 | bounds: { bottom: brightnessDragMin, top: brightnessDragMax }, 70 | from: () => 71 | !isHuggingTop 72 | ? [0, y.get()] 73 | : [0, -y.get() - Math.abs(brightnessDragMax)] 74 | } 75 | ) 76 | 77 | useEffect(() => { 78 | const denormalized = denormalize( 79 | brightness, 80 | brightnessDragMin, 81 | brightnessDragMax 82 | ) 83 | animateSlider.set({ 84 | y: denormalized, 85 | opacity: clamp(brightness, opacity_min, opacity_max), 86 | translateSunRays: Math.abs(denormalized) + sunRayRadius, 87 | sunRayWidth: clamp( 88 | Math.abs(denormalized), 89 | sunRayMinWidth, 90 | sunRayMaxWidth 91 | ) 92 | }) 93 | }, [brightness]) 94 | 95 | // Show a few sunrays that visualize the current brightness value during drag. 96 | const sunRayNum = 8 97 | const sunRays = Array.from({ length: sunRayNum }, (el, i) => { 98 | const deg = -90 + (360 / sunRayNum) * i 99 | return ( 100 | 114 | ) 115 | }) 116 | 117 | return ( 118 | 119 | 125 | {sunRays} 126 |
127 |
128 | 137 | {children} 138 | 139 |
140 | 141 | ) 142 | } 143 | 144 | export default BrightnessSlider 145 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Light/PositionDrag.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import useStore from "../../../../store/useStore"; 3 | import { useRef, useEffect } from "preact/hooks"; 4 | import { useDrag } from "@use-gesture/react"; 5 | import { useSpring, animated } from "@react-spring/web"; 6 | import { stepped, deriveXYFromAngle } from "../../../../utils/math"; 7 | import { alignGridToCenter } from "../../../../utils/grid"; 8 | import align from "../helpers/align"; 9 | import { 10 | WINDOW_INITIAL_WIDTH, 11 | WINDOW_INITIAL_HEIGHT, 12 | LIGHT_SIZE, 13 | LIGHT_SNAP_TO_AXIS_TRESHOLD, 14 | GRID_SIZE, 15 | } from "../../../../constants"; 16 | 17 | // Types 18 | import { Light } from "../../../../store/createLight"; 19 | 20 | const PositionDrag = ({ children, style, ...rest }: { style?: any; children: any }) => { 21 | /** 22 | * 💾 Store 23 | */ 24 | const { previewBounds, azimuth, position, distance, size, positionPointerDown, setLight } = useStore((state) => ({ 25 | previewBounds: state.previewBounds, 26 | azimuth: state.preview.azimuth, 27 | position: { x: state.light.x, y: state.light.y }, 28 | distance: state.preview.distance, 29 | size: state.light.size, 30 | positionPointerDown: state.light.positionPointerDown, 31 | setLight: state.setLight, 32 | })); 33 | 34 | let prevRef = useRef(previewBounds); 35 | 36 | /** 37 | * ✋ Handle drag gesture and translation 38 | * TODO: Initial position? 39 | */ 40 | const [{ x, y }, animateLightPosition] = useSpring(() => ({ 41 | x: WINDOW_INITIAL_WIDTH / 2 - size / 2, 42 | y: WINDOW_INITIAL_HEIGHT / 4 - size / 2, 43 | })); 44 | const drag: any = useDrag( 45 | ({ down: positionPointerDown, shiftKey: shiftKeyDown, offset: [ox, oy] }) => { 46 | const alignX = previewBounds.width / 2 - size / 2; 47 | const alignY = previewBounds.height / 2 - size / 2; 48 | const snapToGrid = { 49 | x: stepped(ox, GRID_SIZE) - GRID_SIZE + alignGridToCenter(previewBounds.width, GRID_SIZE), 50 | y: stepped(oy, GRID_SIZE) - GRID_SIZE + alignGridToCenter(previewBounds.height, GRID_SIZE), 51 | }; 52 | const value = shiftKeyDown ? snapToGrid : { x: ox, y: oy }; 53 | const { position, alignment } = align(value.x, value.y, alignX, alignY, LIGHT_SNAP_TO_AXIS_TRESHOLD); 54 | const data: Pick = { 55 | ...position, 56 | alignment, 57 | positionPointerDown, 58 | shiftKeyDown, 59 | }; 60 | setLight(data); 61 | }, 62 | { 63 | bounds: { 64 | left: 0, 65 | top: 0, 66 | right: previewBounds.width - size, 67 | bottom: previewBounds.height - size, 68 | }, 69 | from: () => [x.get(), y.get()], 70 | } 71 | ); 72 | 73 | /** 74 | * 👂 Listen for azimuth and position changes and update position. 75 | */ 76 | useEffect(() => { 77 | animateLightPosition.start({ 78 | x: position.x, 79 | y: position.y, 80 | immediate: positionPointerDown, 81 | }); 82 | }, [position]); 83 | 84 | useEffect(() => { 85 | if (positionPointerDown) return; 86 | let __distance = distance; 87 | if (__distance == 0) __distance = 0.1; // TODO: Refactor this fix. Prevents azimuth from resetting when distance is manually set to 0. 88 | const { dx, dy } = deriveXYFromAngle(azimuth, __distance); 89 | const adjustedX = previewBounds.width / 2 - size / 2 - dx; 90 | const adjustedY = previewBounds.height / 2 - size / 2 - dy; 91 | animateLightPosition.start({ 92 | x: adjustedX, 93 | y: adjustedY, 94 | }); 95 | setLight({ x: adjustedX, y: adjustedY }); 96 | }, [distance]); 97 | 98 | // Keep light in bounds when resizing window 99 | useEffect(() => { 100 | if (previewBounds.width === 0 || previewBounds.height === 0) return; 101 | const outOfBoundsX = position.x + LIGHT_SIZE > previewBounds.width; 102 | const outOfBoundsY = position.y + LIGHT_SIZE > previewBounds.height; 103 | if (outOfBoundsX || outOfBoundsY) { 104 | const x = outOfBoundsX ? previewBounds.width - LIGHT_SIZE : position.x; 105 | const y = outOfBoundsY ? previewBounds.height - LIGHT_SIZE : position.y; 106 | const data: Pick = { x, y }; 107 | setLight(data); 108 | } 109 | }, [previewBounds]); 110 | 111 | return ( 112 | 128 | {children} 129 | 130 | ); 131 | }; 132 | 133 | export default PositionDrag; 134 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Light/brightness-slider.css: -------------------------------------------------------------------------------- 1 | .slider { 2 | position: absolute; 3 | z-index: 100; 4 | top: -50%; 5 | left: 50%; 6 | width: 100%; 7 | height: 100%; 8 | opacity: 0; 9 | transform: translate3d(-50%, 0, 0); 10 | transition: opacity 0.1s; 11 | } 12 | 13 | .slider:hover { 14 | opacity: 1 !important; 15 | } 16 | 17 | /* increase hover area for slide handle */ 18 | .hover { 19 | position: absolute; 20 | top: -100%; 21 | left: 0; 22 | width: 100%; 23 | height: 200%; 24 | } 25 | 26 | .hover:hover + .slider { 27 | opacity: 1 !important; 28 | } 29 | 30 | .handle { 31 | position: absolute; 32 | top: 0; 33 | left: 50%; 34 | display: block; 35 | width: 12px; 36 | height: 12px; 37 | background: #fff; 38 | border-radius: 100%; 39 | box-shadow: 0px 0px 0px 1px var(--color-blue) inset; 40 | touch-action: none; 41 | transition: opacity 0.1s; 42 | } 43 | 44 | .handle[data-down="true"]::before { 45 | position: absolute; 46 | 47 | top: 100%; 48 | left: calc(50% + 1px); 49 | height: 32px; 50 | border-left: 1px dashed var(--color-blue); 51 | content: ""; 52 | transform: translateX(calc(-50% - 1px)); 53 | } 54 | 55 | .glow { 56 | position: absolute; 57 | top: 0; 58 | left: 0; 59 | width: 32px; 60 | height: 32px; 61 | background: #fff; 62 | border-radius: 100%; 63 | box-shadow: 0px 0px 32px 32px #ffffff; 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Light/index.css: -------------------------------------------------------------------------------- 1 | .light { 2 | position: absolute; 3 | z-index: 100; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | background: #fff; 9 | border-radius: 100%; 10 | box-shadow: 0px 0px 0px 1px var(--color-blue) inset; 11 | pointer-events: none; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Light/index.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import useStore from '../../../../store/useStore' 3 | import PositionDraggable from './PositionDrag' 4 | import BrightnessSlider from './BrightnessSlider' 5 | import Badge from '../Badge/badge' 6 | import styles from './index.css' 7 | 8 | const Light = ({ ...rest }) => { 9 | const { positionY, brightness, brightnessPointerDown } = useStore( 10 | (state) => ({ 11 | positionY: state.light.y, 12 | brightness: state.light.brightness, 13 | brightnessPointerDown: state.light.brightnessPointerDown 14 | }) 15 | ) 16 | const label = `Brightness ${Math.round(brightness * 100)}%` 17 | const isHuggingTop = positionY < 32 // used as a trigger to rotate light so brightness slider doesnt slide out of bounds 18 | 19 | return ( 20 | 25 | 26 | 36 | {label} 37 | 38 | 39 |
40 | 41 | ) 42 | } 43 | 44 | export default Light 45 | -------------------------------------------------------------------------------- /src/ui/Preview/components/ShowAlignmentLines/lines.css: -------------------------------------------------------------------------------- 1 | .line { 2 | position: absolute; 3 | z-index: 100; 4 | display: block; 5 | background: var(--color-red); 6 | pointer-events: none; 7 | 8 | --line-width: 1px; 9 | --icon-size: 6px; 10 | } 11 | 12 | .horizontal { 13 | left: 50%; 14 | width: var(--line-width); 15 | height: 100%; 16 | transform: translateX(-50%); 17 | } 18 | 19 | .vertical { 20 | top: 50%; 21 | width: 100%; 22 | height: var(--line-width); 23 | transform: translateY(-50%); 24 | } 25 | 26 | .horizontal::before, 27 | .horizontal::after, 28 | .vertical::before, 29 | .vertical::after { 30 | position: absolute; 31 | width: var(--icon-size); 32 | height: var(--icon-size); 33 | background-image: url("data:image/svg+xml,%3Csvg width='100%' height='100%' viewBox='0 0 8 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1C1.69785 1.69785 2.84781 2.84781 4 4M7 7C6.2921 6.2921 5.14495 5.14495 4 4M4 4L7 1M4 4L1 7' stroke='%23f24822' stroke-width='1'/%3E%3C/svg%3E%0A"); 34 | content: ' '; 35 | } 36 | 37 | .horizontal::before { 38 | top: 0; 39 | left: 50%; 40 | transform: translate3d(-50%, -50%, 0); 41 | } 42 | .horizontal::after { 43 | bottom: 0; 44 | left: 50%; 45 | transform: translate3d(-50%, 50%, 0); 46 | } 47 | 48 | .vertical::before { 49 | top: 50%; 50 | left: 0; 51 | transform: translate3d(-50%, -50%, 0); 52 | } 53 | .vertical::after { 54 | top: 50%; 55 | right: 0; 56 | transform: translate3d(50%, -50%, 0); 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/Preview/components/ShowAlignmentLines/lines.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, h } from 'preact' 2 | import useStore from '../../../../store/useStore' 3 | import styles from './lines.css' 4 | 5 | const ShowAlignmentLines = () => { 6 | const { 7 | previewBounds, 8 | lightSize, 9 | lightPosition, 10 | lightAlignment, 11 | positionPointerDown 12 | } = useStore((state) => ({ 13 | previewBounds: state.previewBounds, 14 | lightSize: state.light.size, 15 | lightPosition: { x: state.light.x, y: state.light.y }, 16 | lightAlignment: state.light.alignment, 17 | positionPointerDown: state.light.positionPointerDown 18 | })) 19 | const offset = lightSize / 2 20 | 21 | const isVisible = positionPointerDown 22 | const isCentered = lightAlignment === 'CENTER' 23 | const isHorizontal = lightAlignment === 'HORIZONTAL' 24 | const isVertical = lightAlignment === 'VERTICAL' 25 | const isAbove = lightPosition.y < previewBounds.height / 2 26 | const isLefthand = lightPosition.x < previewBounds.width / 2 27 | 28 | const horizontal = { 29 | opacity: isVisible && (isCentered || isHorizontal) ? 1 : 0, 30 | top: isCentered 31 | ? 0 32 | : isAbove 33 | ? lightPosition.y + offset 34 | : previewBounds.height / 2, 35 | height: isCentered 36 | ? '100%' 37 | : isAbove 38 | ? previewBounds.height / 2 - lightPosition.y - offset 39 | : lightPosition.y - previewBounds.height / 2 + offset 40 | } 41 | 42 | const vertical = { 43 | opacity: isVisible && (isCentered || isVertical) ? 1 : 0, 44 | left: isCentered 45 | ? 0 46 | : isLefthand 47 | ? lightPosition.x + offset 48 | : previewBounds.width / 2, 49 | width: isCentered 50 | ? '100%' 51 | : isLefthand 52 | ? previewBounds.width / 2 - lightPosition.x - offset 53 | : lightPosition.x - previewBounds.width / 2 + offset 54 | } 55 | 56 | return ( 57 | 58 |
61 |
64 |
65 | ) 66 | } 67 | 68 | export default ShowAlignmentLines 69 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Target/Target.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from 'preact' 2 | import useElevationSlider from './useElevationSlider' 3 | import useSelectionStyle from './useSelectionStyle' 4 | import useShadowStyle from './useShadowStyle' 5 | import useStore from '../../../../store/useStore' 6 | import { animated, to } from '@react-spring/web' 7 | import Badge from '../Badge/badge' 8 | import styles from './target.css' 9 | 10 | // Types 11 | import { Target } from '../../../../store/createTarget' 12 | 13 | const Target = ({ ...rest }) => { 14 | /** 15 | * 💾 Store 16 | */ 17 | const { elevation, elevationPointerDown, toggleShadowType } = useStore( 18 | (state) => ({ 19 | elevation: state.target.elevation, 20 | elevationPointerDown: state.target.elevationPointerDown, 21 | toggleShadowType: state.toggleType 22 | }) 23 | ) 24 | const [scale, slide] = useElevationSlider() 25 | const selectionStyle = useSelectionStyle() 26 | const shadowStyle = useShadowStyle() 27 | const label = `Elevation ${Math.round(elevation * 100)} 28 | %` 29 | 30 | return ( 31 | 32 | 41 | {label} 42 | 43 | s) 50 | }} 51 | onDoubleClickCapture={() => toggleShadowType()} 52 | //@ts-ignore next-line 53 | {...slide()} 54 | {...rest} 55 | /> 56 | 57 | ) 58 | } 59 | 60 | export default Target 61 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Target/target.css: -------------------------------------------------------------------------------- 1 | .target { 2 | position: absolute; 3 | z-index: 10; 4 | top: 50%; 5 | left: 50%; 6 | background: #fff; 7 | cursor: ns-resize; 8 | touch-action: none; 9 | transform: translate3d(-50%, -50%, 0); 10 | transition: height 0.2s, width 0.2s, border-radius 0.2s; 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Target/useElevationSlider.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'preact/hooks' 2 | import useStore from '../../../../store/useStore' 3 | import { useDrag } from '@use-gesture/react' 4 | import { useSpring } from '@react-spring/web' 5 | import { stepped, normalize, denormalize } from '../../../../utils/math' 6 | import { Target } from '../../../../store/createTarget' 7 | 8 | // Constants 9 | const DRAG_RANGE = 50 10 | 11 | const useElevationSlider = () => { 12 | /** 13 | * 💾 Store 14 | */ 15 | const { elevation, elevationPointerDown, setTarget } = useStore( 16 | (state) => ({ 17 | elevation: state.target.elevation, 18 | elevationPointerDown: state.target.elevationPointerDown, 19 | setTarget: state.setTarget 20 | }) 21 | ) 22 | 23 | /** 24 | * ✋ Handle drag gesture and translation 25 | */ 26 | const [{ scale }, animate] = useSpring(() => ({ scale: 1 })) 27 | const slide = useDrag( 28 | ({ down: elevationPointerDown, shiftKey, offset: [_, oy] }) => { 29 | const value = shiftKey ? stepped(oy, 10) : oy 30 | const normalized = normalize(value, DRAG_RANGE, -DRAG_RANGE) 31 | 32 | const data: Pick = { 33 | elevation: normalized, 34 | elevationPointerDown 35 | } 36 | setTarget(data) 37 | 38 | if (!elevationPointerDown && oy === DRAG_RANGE) { 39 | setTarget({ elevation: 0 }) 40 | } 41 | }, 42 | { 43 | bounds: { top: -DRAG_RANGE, bottom: DRAG_RANGE }, 44 | from: () => [ 45 | 0, 46 | denormalize( 47 | normalize(scale.get(), 1.125, 1.375), 48 | DRAG_RANGE, 49 | -DRAG_RANGE 50 | ) 51 | ] 52 | } 53 | ) 54 | 55 | /** 56 | * 👂 Listen for position changes and fire queue translation 57 | */ 58 | useEffect(() => { 59 | const denormalized = denormalize(elevation, DRAG_RANGE, -DRAG_RANGE) 60 | const damp = 4 61 | const dampedDenormalized = 62 | normalize(denormalized, DRAG_RANGE * damp, -DRAG_RANGE * damp) + 63 | 0.75 64 | animate.start({ 65 | scale: dampedDenormalized, 66 | immediate: elevationPointerDown 67 | }) 68 | }, [elevation]) 69 | 70 | return [scale, slide] 71 | } 72 | 73 | export default useElevationSlider 74 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Target/useSelectionStyle.ts: -------------------------------------------------------------------------------- 1 | import useStore from "../../../../store/useStore"; 2 | import { resizeAndRetainAspectRatio } from "../../../../utils/math"; 3 | import { SelectionState } from "../../../../utils/selection"; 4 | 5 | // Constants 6 | export const TARGET_WIDTH = 100; 7 | export const TARGET_HEIGHT = 100; 8 | 9 | const useSelectionStyle = () => { 10 | const { 11 | state, 12 | width, 13 | height, 14 | cornerRadius, 15 | }: { 16 | state: SelectionState; 17 | width: number; 18 | height: number; 19 | cornerRadius: number; 20 | } = useStore((state) => ({ 21 | state: state.selection.state, 22 | width: state.selection.width, 23 | height: state.selection.height, 24 | cornerRadius: state.selection.cornerRadius, 25 | })); 26 | const isSelected = state === "VALID"; 27 | const { 28 | width: elementWidth, 29 | height: elementHeight, 30 | ratio, 31 | } = resizeAndRetainAspectRatio(width, height, TARGET_WIDTH, TARGET_HEIGHT); 32 | const selectionStyles = { 33 | border: isSelected ? "1px solid var(--color-blue)" : "0px solid rgba(0,0,0,0.0)", 34 | width: elementWidth || TARGET_WIDTH, 35 | height: elementHeight || TARGET_HEIGHT, 36 | minWidth: 32, 37 | minHeight: 32, 38 | borderRadius: cornerRadius * ratio || 6, 39 | }; 40 | return selectionStyles; 41 | }; 42 | 43 | export default useSelectionStyle; 44 | -------------------------------------------------------------------------------- /src/ui/Preview/components/Target/useShadowStyle.ts: -------------------------------------------------------------------------------- 1 | import chroma from 'chroma-js' 2 | import useStore from '../../../../store/useStore' 3 | import { getCastedShadows } from '../../../../utils/shadow' 4 | 5 | const useShadow = () => { 6 | const { shadowType, azimuth, distance, elevation, brightness, color } = 7 | useStore((state) => ({ 8 | shadowType: state.type, 9 | azimuth: state.preview.azimuth, 10 | distance: state.preview.distance, 11 | elevation: state.preview.elevation, 12 | brightness: state.preview.brightness, 13 | color: state.color 14 | })) 15 | 16 | const toGL = chroma(color).gl() 17 | const GLArray = { 18 | r: toGL[0], 19 | g: toGL[1], 20 | b: toGL[2], 21 | a: toGL[3] 22 | } 23 | 24 | const shadows = getCastedShadows({ 25 | numShadows: 6, 26 | azimuth, 27 | distance, 28 | elevation, 29 | brightness, 30 | color: GLArray, 31 | shadowType, 32 | size: { width: 100, height: 100 } 33 | }) 34 | const shadow = shadows.map( 35 | (shadow) => 36 | `${shadowType === 'INNER_SHADOW' ? 'inset' : ''} ${ 37 | shadow.offset.x 38 | }px ${shadow.offset.y}px ${shadow.radius}px rgba(${chroma 39 | .gl( 40 | shadow.color.r, 41 | shadow.color.g, 42 | shadow.color.b, 43 | shadow.color.a 44 | ) 45 | .rgba()})` 46 | ) 47 | 48 | return { boxShadow: shadow.toString() } 49 | } 50 | 51 | export default useShadow 52 | -------------------------------------------------------------------------------- /src/ui/Preview/components/helpers/align.ts: -------------------------------------------------------------------------------- 1 | export type Alignment = 'NONE' | 'CENTER' | 'HORIZONTAL' | 'VERTICAL' 2 | export default function align( 3 | x: number, 4 | y: number, 5 | alignX: number, 6 | alignY: number, 7 | treshold: number 8 | ): { position: Vector; alignment: Alignment } { 9 | let position = { x, y } 10 | let alignment: Alignment = 'NONE' 11 | const centeredX = x > alignX - treshold && x < alignX + treshold 12 | const centeredY = y > alignY - treshold && y < alignY + treshold 13 | const both = centeredX && centeredY 14 | if (both) { 15 | position = { x: alignX, y: alignY } 16 | alignment = 'CENTER' 17 | } else if (centeredX) { 18 | position = { x: alignX, y } 19 | alignment = 'HORIZONTAL' 20 | } else if (centeredY) { 21 | position = { x, y: alignY } 22 | alignment = 'VERTICAL' 23 | } else position = { x, y } 24 | return { position, alignment } 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/Preview/preview.css: -------------------------------------------------------------------------------- 1 | .preview { 2 | position: relative; 3 | overflow: hidden; 4 | width: 100%; 5 | height: calc(100%); 6 | transition: background-position 0.8s; 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/Preview/preview.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import { useEffect, useCallback } from 'preact/hooks' 3 | import usePreviewBounds, { PreviewBounds } from '../../hooks/usePreviewBounds' 4 | import { forwardRef } from 'preact/compat' 5 | import useStore from '../../store/useStore' 6 | import { emit } from '@create-figma-plugin/utilities' 7 | import { debounce } from '../../utils/debounce' 8 | import { 9 | angleFromLightToTarget, 10 | distanceFromLightToTarget 11 | } from '../../utils/math' 12 | import { alignGridToCenter } from '../../utils/grid' 13 | import Target from './components/Target/target' 14 | import Light from './components/Light' 15 | import ShowAlignmentLines from './components/ShowAlignmentLines/lines' 16 | import styles from './preview.css' 17 | 18 | // Constants 19 | import { 20 | DEBOUNCE_CANVAS_UPDATES, 21 | GRID_SIZE, 22 | GRID_OPACITY 23 | } from '../../constants' 24 | 25 | // Types 26 | import { Preview } from '../../store/createPreview' 27 | import { PluginData } from '../../main' 28 | 29 | const Preview = forwardRef(({ children }: any, ref) => { 30 | const { width, height }: PreviewBounds = usePreviewBounds(ref) 31 | useEffect(() => { 32 | setPreviewBounds({ width, height }) 33 | }, [width, height]) 34 | 35 | /** 36 | * 💾 Store 37 | */ 38 | const { 39 | type, 40 | color, 41 | backgroundColor, 42 | light, 43 | target, 44 | selection, 45 | previewBounds, 46 | positionPointerDown, 47 | shiftKeyDown, 48 | setPreview, 49 | setPreviewBounds 50 | } = useStore((state) => ({ 51 | color: state.color, 52 | type: state.type, 53 | backgroundColor: state.preview.backgroundColor, 54 | light: state.light, 55 | target: state.target, 56 | selection: state.selection, 57 | previewBounds: state.previewBounds, 58 | positionPointerDown: state.light.positionPointerDown, 59 | shiftKeyDown: state.light.shiftKeyDown, 60 | setPreview: state.setPreview, 61 | setPreviewBounds: state.setPreviewBounds 62 | })) 63 | 64 | /** 65 | * 👂 Listen for changes from the Light or Target component and update the preview 66 | */ 67 | useEffect(() => { 68 | const targetPosition = { 69 | // assume that target is always centered 70 | x: width / 2 - light.size / 2, 71 | y: height / 2 - light.size / 2 72 | } 73 | const lightPosition = { x: light.x, y: light.y } 74 | const azimuth = angleFromLightToTarget(targetPosition, lightPosition) 75 | const distance = distanceFromLightToTarget( 76 | targetPosition, 77 | lightPosition 78 | ) 79 | const elevation = target.elevation 80 | const brightness = light.brightness 81 | const shadowColor = color 82 | const shadowType = type 83 | 84 | const update: Preview = { 85 | azimuth, 86 | distance, 87 | elevation, 88 | brightness, 89 | shadowColor, 90 | shadowType 91 | } 92 | setPreview(update) 93 | 94 | const pluginData: PluginData = { 95 | ...update, 96 | lightPosition, 97 | previewBounds 98 | } 99 | debounceCanvasUpdate(pluginData) 100 | }, [light, target, color, type, selection, previewBounds]) 101 | 102 | const debounceCanvasUpdate = useCallback( 103 | debounce( 104 | (data) => emit('UPDATE_SHADOWS', data), 105 | DEBOUNCE_CANVAS_UPDATES 106 | ), 107 | [] 108 | ) 109 | 110 | /** 111 | * Show grid 112 | */ 113 | const grid = { 114 | backgroundImage: `radial-gradient(rgba(0,0,0,${ 115 | shiftKeyDown ? (positionPointerDown ? GRID_OPACITY : 0) : 0 116 | }) 1px, transparent 0)`, 117 | backgroundSize: `${GRID_SIZE}px ${GRID_SIZE}px`, 118 | backgroundPosition: `${alignGridToCenter( 119 | width, 120 | GRID_SIZE 121 | )}px ${alignGridToCenter(height, GRID_SIZE)}px` 122 | } 123 | 124 | return ( 125 |
132 | 133 | 134 | 135 | {children} 136 |
137 | ) 138 | }) 139 | 140 | export default Preview 141 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | import chroma, { Color } from 'chroma-js' 2 | 3 | export function hexToGL(color: Color | string): RGBA { 4 | const hexToRGBA = chroma(color).gl() 5 | return { 6 | r: hexToRGBA[0], 7 | g: hexToRGBA[1], 8 | b: hexToRGBA[2], 9 | a: hexToRGBA[3] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | export function debounce(callback: (...args: any[]) => any, wait: number) { 2 | let timeout = 0 3 | return (...args: Parameters): ReturnType => { 4 | let result: any 5 | clearTimeout(timeout) 6 | timeout = setTimeout(() => { 7 | result = callback(...args) 8 | }, wait) 9 | return result 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/grid.ts: -------------------------------------------------------------------------------- 1 | export function alignGridToCenter(viewport: number, gridSize: number): number { 2 | const howMuchGridFitsIntoViewport = viewport / gridSize 3 | const getPositionOfMostCenterDot = 4 | (howMuchGridFitsIntoViewport / 2) * gridSize 5 | const shift = viewport - getPositionOfMostCenterDot - gridSize / 2 6 | 7 | // return remainder to keep the shift motion as reduced as possible 8 | return Math.round(shift % gridSize) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/math.ts: -------------------------------------------------------------------------------- 1 | export function stepped(value: number, steps: number) { 2 | return Math.ceil(value / steps) * steps 3 | } 4 | 5 | export function clamp(num: number, min: number, max: number): number { 6 | return Math.min(Math.max(num, min), max) 7 | } 8 | 9 | export function normalize(value: number, min: number, max: number): number { 10 | return (value - min) / (max - min) 11 | } 12 | 13 | export function denormalize(value: number, min: number, max: number): number { 14 | return value * (max - min) + min 15 | } 16 | 17 | export function percent(value: number, roundValue: boolean = true) { 18 | return roundValue ? Math.round(value * 100) : value * 100 19 | } 20 | 21 | export function round(value: number, decimals: number = 0) { 22 | return Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals) 23 | } 24 | 25 | export function angleFromLightToTarget(vec1: Vector, vec2: Vector): number { 26 | const deg = Math.atan2(vec1.y - vec2.y, vec1.x - vec2.x) * (180 / Math.PI) 27 | return deg < 0 ? 360 + deg : deg 28 | } 29 | 30 | export function distanceFromLightToTarget(vec1: Vector, vec2: Vector): number { 31 | const p1 = vec1.x - vec2.x 32 | const p2 = vec1.y - vec2.y 33 | //return (Math.sqrt(p1 * p1 + p2 * p2) * 180) / Math.PI 34 | return Math.sqrt(p1 * p1 + p2 * p2) 35 | } 36 | 37 | export function deriveXYFromAngle( 38 | angle: number, 39 | distance: number 40 | ): { dx: number; dy: number } { 41 | const theta = angle * (Math.PI / 180) 42 | const dx = distance * Math.cos(theta) 43 | const dy = distance * Math.sin(theta) 44 | return { dx, dy } 45 | } 46 | 47 | export function degreeToRadian(degree: number) { 48 | return degree * (Math.PI / 180) 49 | } 50 | 51 | export function resizeAndRetainAspectRatio( 52 | ogWidth: number, 53 | ogHeight: number, 54 | maxWidth: number, 55 | maxHeight: number 56 | ) { 57 | const ratio = Math.min(maxWidth / ogWidth, maxHeight / ogHeight) 58 | return { 59 | width: ogWidth * ratio, 60 | height: ogHeight * ratio, 61 | ratio 62 | } 63 | } 64 | 65 | export function dottedGridOffset(viewport: number, gridSize: number): number { 66 | const howMuchDotsFitIntoViewport = viewport / gridSize 67 | const getPositionOfMostCenterDot = 68 | (howMuchDotsFitIntoViewport / 2) * gridSize 69 | const shift = viewport - getPositionOfMostCenterDot - gridSize / 2 70 | return shift % gridSize 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/node.ts: -------------------------------------------------------------------------------- 1 | export function isSymbol(property: any) { 2 | return typeof property === "symbol"; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/selection.ts: -------------------------------------------------------------------------------- 1 | export type SelectionState = 2 | | "MULTIPLE" 3 | | "VALID" 4 | | "INVALID" 5 | | "HAS_COMPONENT_CHILD" 6 | | "IS_WITHIN_COMPONENT" 7 | | "IS_WITHIN_INSTANCE" 8 | | "EMPTY"; 9 | 10 | /** 11 | * Checks if current selection is empty, multiple, valid or updateable. 12 | * @param selection - Current page selection 13 | * @returns {SelectionState} 14 | */ 15 | export function validateSelection( 16 | selection: ReadonlyArray, 17 | validNodeTypes: Array 18 | ): SelectionState { 19 | if (selection.length) { 20 | if (selection.length > 1) { 21 | return "MULTIPLE"; 22 | } 23 | const node: SceneNode = selection[0]; 24 | if (validNodeTypes.indexOf(node.type) >= 0) { 25 | if (isWithinNodeType(node, "COMPONENT")) { 26 | // 27 | // TODO: Remove these guards? 28 | // When trying to derive the background color of the shadow node, (deeply) nested components 29 | // caused a lot of issues hence I disabled shadows for components/nested components altogether. 30 | // This was a poor decision though as it is very unexpected for the user. 31 | // In a future refactor, I should either refactor the deriveBgColor or remove it altogether. 32 | // 33 | // return 'IS_WITHIN_COMPONENT' 34 | return "VALID"; 35 | } else if (node.type === "GROUP" && hasComponentChild(node)) { 36 | // return "HAS_COMPONENT_CHILD"; 37 | return "VALID"; 38 | } else { 39 | return "VALID"; 40 | } 41 | } else { 42 | return "INVALID"; 43 | } 44 | } else { 45 | return "EMPTY"; 46 | } 47 | } 48 | 49 | /** 50 | * Check if node is parented under certain node type. 51 | * @param node 52 | * @param type 53 | * @returns 54 | */ 55 | export function isWithinNodeType(node: SceneNode, type: NodeType): boolean { 56 | const parent = node.parent; 57 | if (parent === null || parent.type === "DOCUMENT" || parent.type === "PAGE") { 58 | return false; 59 | } 60 | if (parent.type === type) { 61 | return true; 62 | } 63 | return isWithinNodeType(parent, type); 64 | } 65 | 66 | /** 67 | * Search group for component child nodes, that would throw a re-componentizing error. 68 | * @param selection 69 | * @returns - truthy value if component child has been found 70 | */ 71 | export function hasComponentChild(selection: SceneNode): boolean | undefined { 72 | let hasComponent; 73 | if (selection.type === "COMPONENT") { 74 | return true; 75 | } else if (selection.type !== "GROUP") { 76 | return false; 77 | } 78 | selection.children.some((child) => (hasComponent = hasComponentChild(child))); 79 | return hasComponent; 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/shadow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file This is where the magic happens. Generate an array of shadow objects, 3 | * which is consumed by both the UI and canvas/main.ts. 4 | * 5 | * A lot of the values and easing is based on trial & error and gut feeling. 6 | * The technique of tinting the shadows is adapted from Josh W. Comeau's amazing 7 | * read about designing shadows in CSS: https://www.joshwcomeau.com/css/designing-shadows/ 8 | */ 9 | 10 | import { normalize, round, degreeToRadian } from "./math"; 11 | import { easeQuadOut, easeQuadIn, easeCubicInOut } from "d3-ease"; 12 | import { SHADOW_BASE_BLUR } from "../constants"; 13 | import { ShadowType } from "../store/createShadowProps"; 14 | 15 | interface ShadowParameter { 16 | numShadows: number; 17 | azimuth: number; 18 | distance: number; 19 | elevation: number; 20 | brightness: number; 21 | color: RGBA; 22 | shadowType: ShadowType; 23 | size: { width: number; height: number }; 24 | } 25 | 26 | export function getCastedShadows({ 27 | numShadows, 28 | azimuth, 29 | distance, 30 | elevation, 31 | brightness, 32 | color, 33 | shadowType, 34 | size, 35 | }: ShadowParameter): DropShadowEffect[] | InnerShadowEffect[] { 36 | const azimuthRad = degreeToRadian(azimuth); 37 | 38 | /** 39 | * Match shadow between UI and canvas. 40 | * The UI element is always 100*100px, whereas the canvas element size is indefinite. 41 | * To create the same shadow offset between the UI and canvas layer, we need to scale the offset respectively. 42 | */ 43 | const { width, height } = size; 44 | const longestSide = Math.max(width, height); 45 | const factor = longestSide / 100; // ui element is 100*100 by default 46 | const scale = distance * factor; // 20 -> arbitrary value that felt good 47 | 48 | const increaseRadiusWithDistance = Math.max(scale / 100, 0.1); // values are purely based on gut feel 49 | 50 | const shadows: DropShadowEffect[] | InnerShadowEffect[] = Array.from({ length: numShadows }, (_, i) => { 51 | const type: any = shadowType; 52 | // opacity/ shadow alpha 53 | const easeOpacity = easeCubicInOut(normalize(i, 0, numShadows)); 54 | const a = round(brightness - brightness * easeOpacity, 2); 55 | 56 | // offset 57 | const easeOffset = easeQuadIn(normalize(i, 0, numShadows)) * 5; 58 | const x = round(Math.cos(azimuthRad) * (scale * elevation * easeOffset)); 59 | const y = round(Math.sin(azimuthRad) * (scale * elevation * easeOffset)); 60 | 61 | // radius/ shadow blur 62 | const easeRadius = easeQuadOut(normalize(i, 0, numShadows)); 63 | const radius = round(SHADOW_BASE_BLUR * increaseRadiusWithDistance * easeRadius * (elevation * 2)); 64 | 65 | return { 66 | type, 67 | blendMode: "NORMAL", 68 | visible: true, 69 | color: { ...color, a }, 70 | offset: { 71 | x, 72 | y, 73 | }, 74 | radius, 75 | spread: 0, 76 | }; 77 | }); 78 | // due to the way the easing function works, we'll always end up with an almost transparent 0px 0px 0px 0px shadow. we'll just filter that one out... 79 | let _shadows: any[] = shadows; 80 | let filtered = _shadows.filter((shadow) => !(shadow.offset.x === 0 && shadow.offset.y === 0 && shadow.radius === 0)); 81 | return filtered as DropShadowEffect[] | InnerShadowEffect[]; 82 | } 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@create-figma-plugin/tsconfig", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@figma", "node_modules/@types"] 5 | }, 6 | "include": ["src/**/*.ts", "src/**/*.tsx"] 7 | } 8 | --------------------------------------------------------------------------------