= { 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 |
172 | );
173 | };
174 |
175 | const IconElevation16 = ({ v = 0 }) => {
176 | return (
177 |
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 |
--------------------------------------------------------------------------------