├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── .yarnignore ├── .yarnrc ├── README.md ├── code ├── AutoAnimatedState.tsx ├── PreventLayoutIdGeneration.tsx ├── Switch.tsx ├── SwitchOverrideExamples.tsx ├── SwitchToStateAction.tsx ├── actions.ts ├── canvas.tsx ├── controls.ts ├── hooks │ ├── useDoubleTap.ts │ └── useLongPress.ts ├── index.ts ├── placeholderState.tsx ├── store │ ├── globalStore.ts │ └── keyStore.ts ├── thumbnailStyles.ts ├── transitions.ts ├── useWhyDidYouUpdate.ts └── utils │ ├── addAnimatableWrapperToNodeIfNeeded.tsx │ ├── calculateRect.ts │ ├── equalizeArrayLength.ts │ ├── extractEventHandlersFromProps.ts │ ├── nodeHelpers.ts │ ├── omit.ts │ ├── pick.ts │ ├── propNameHelpers.ts │ ├── randomID.ts │ └── styleParsing.ts ├── design └── document.json ├── metadata ├── artwork.png └── icon.png ├── package.json ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | Icon\r 4 | .vscode 5 | 6 | # Framer 7 | .cache 8 | .config.json 9 | .backups 10 | .framer-lock 11 | .project.log 12 | yarn-error.log 13 | build 14 | dist 15 | node_modules 16 | .npmrc 17 | *.framerxproj 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "singleQuote": false, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /.yarnignore: -------------------------------------------------------------------------------- 1 | /build 2 | .DS_Store 3 | Icon\r 4 | .cache 5 | .config.json 6 | .backups 7 | .project.log 8 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | 2 | --link-folder "/Users/tisho/Library/Application Support/Framer X/yarn-link-folder" 3 | disable-self-update-check true 4 | yarn-path "/Applications/Framer X.app/Contents/Resources/Server/lib/framerYarn.js" 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Switch 2 | 3 | `Switch` is a utility component that lets you switch between different states of an element on the canvas using animated transitions. It comes with an additional `SwitchToStateAction` component, which acts as a hotspot that can change the current state of a `Switch` without writing any code. 4 | 5 | **[→ View documentation on GitHub](https://github.com/tisho/Switch.framerfx)** 6 | 7 | **[→ File an issue on GitHub](https://github.com/tisho/Switch.framerfx/issues/new)** 8 | 9 | # Latest Release 10 | 11 | 3/26/2021 12 | 13 | - FIX: Compatibility fixes for latest version of Framer Motion that broke 14 | certain gradient, border and cross-dissolve transitions 15 | 16 | 3/24/2021 17 | 18 | - FIX: Compatibility fixes for latest version of Framer Motion that broke Auto 19 | Animate transitions 20 | 21 | **[→ See past releases](#past-releases)** 22 | 23 | # Examples 24 | 25 | **[→ See examples in Framer Web (requires free account)](https://framer.com/projects/new?duplicate=3bx2ztPRfzFiMJ8hWdmw)** 26 | 27 | **[→ Download examples (.framerx file)](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-examples.framerx)** 28 | 29 | ## Bottom Sheet 30 | 31 | ![Bottom Sheet Example](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-bottom-sheet-low.gif) 32 | 33 | ## Tabs 34 | 35 | ![Tabs Example](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-tabs-low.gif) 36 | 37 | ## Carousel 38 | 39 | ![Carousel Example](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-carousel.gif) 40 | 41 | ## Tooltip 42 | 43 | ![Tooltip](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-tooltip.gif) 44 | 45 | ## Toggle 46 | 47 | ![Toggle](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-toggle.gif) 48 | 49 | # Usage 50 | 51 | ## Interactive Switch 52 | 53 | Interactive Switches can manage their own state. Use them when you need to have a Switch change its own state when it's tapped / hovered over. Here are a few examples of components that are a good fit for an interactive Switch: 54 | 55 | - A button with a normal / hover / active state 56 | - An on/off toggle 57 | - A checkbox 58 | - A tooltip that expands on hover 59 | 60 | ### Creating an Interactive Switch 61 | 62 | 1. Drag and drop a Switch component onto the canvas. 63 | 64 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-interactive-1.png) 65 | 66 | 2. Connect it to the states you want to switch between. 67 | 68 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-interactive-2.png) 69 | 70 | 3. In the properties panel, set "Interactive" to "Yes". 71 | 72 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-interactive-3.png) 73 | 74 | 4. Choose the trigger for the state change, and the target action. 75 | 76 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-interactive-4.png) 77 | 78 | 5. Customize the transition to use when switching between states. 79 | 80 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-interactive-5.png) 81 | 82 | 6. Preview your prototype. 83 | 84 | ## Controlled Switch 85 | 86 | Controlled Switches rely on other elements to set their state. Here are a few use cases that are a good fit for a controlled Switch: 87 | 88 | - A bottom sheet that changes its contents based on a button being pressed. 89 | - Tabbed navigation 90 | - A carousel with external Previous / Next buttons 91 | 92 | ### Creating a Controlled Switch 93 | 94 | 1. Drag and drop a Switch component onto the canvas. 95 | 96 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-1.png) 97 | 98 | 2. Connect it to the states you want to switch between. 99 | 100 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-2.png) 101 | 102 | 3. Choose a unique name for your switch. This name will let other `SwitchToStateAction` hotspots control the state of your switch. 103 | 104 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-3.png) 105 | 106 | 4. Drag and drop a `SwitchToStateAction` component onto the canvas. This component will act as a hotspot that changes the state of a Switch component when the user interacts with it. 107 | 108 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-4.png) 109 | 110 | 5. Change the name of the target `Switch` to the name you used in step 3, then pick a trigger, and a target state. 111 | 112 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-5.png) 113 | 114 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-6.png) 115 | 116 | 6. Customize the transition to use when switching between states. 117 | 118 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-controlled-7.png) 119 | 120 | 7. Preview your prototype. 121 | 122 | # Auto-Animating Between States 123 | 124 | The "Auto-Animate" transition works differently from the other available state transitions. When triggered, elements shared between two states will animate their properties smoothly between each state. If there are elements in the next state that don't exist in the current one, they'll fade in, and elements that should no longer be shown will fade out. 125 | 126 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-example-fancy-add-menu.gif) 127 | 128 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-example-icon-morph.gif) 129 | 130 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-example-item-list.gif) 131 | 132 | ## How Element Matching Works 133 | 134 | Before an auto-animate transition begins, the Switch component looks for matching elements between the current and the next state. To figure out if an element matches another element, it looks at: 135 | 136 | - the element's name in the layer panel 137 | - its type (Frame, Stack, code component, etc) 138 | - its order in the layer list 139 | - what level of the layer hierarchy it sits in (i.e. whether it's nested in another Frame) 140 | 141 | Note: Names of Graphic layers are currently not recognized properly. To transition between Graphic layers, wrap them in a Frame and name the Frame. 142 | 143 | ## Supported Transitions Between Matching Elements 144 | 145 | If an element is found to be matching another element, we'll create an animated transition for the following properties: 146 | 147 | - Size 148 | - Position (including layout in Stacks) 149 | - Rotation 150 | - Opacity 151 | - Background (linear gradients, radial gradients, and image fill transitions are all supported) 152 | - Border (radius, color and width) 153 | - Shadow (up to 10 box / inner shadows are supported) 154 | 155 | If the two matching elements are of a different type (e.g. a transition between a Frame and a Graphic), or are of a type where a smooth transition isn't possible (e.g. transitions between two Text layers), the two elements will be animated using a cross-dissolve. 156 | 157 | ## Customizing Auto-Animation Transition Timing 158 | 159 | Each auto-animate transition groups transitioning elements in 3 categories: 160 | 161 | - Morphing Elements: Elements that exist both in the current and the next state 162 | - Entering Elements: Elements that only exist in the next state 163 | - Exiting Elements: Elements that only exist in the current state 164 | 165 | Each group can have different transition timing. Morphing elements always use the timing options defined right below the "Transition" property when it's set to "Auto-Animate". The Enter and Exit transitions will have their own options right below (by default, they're set to a 0.3s dissolve). 166 | 167 | Additionally, auto-animate lets you take advantage of Framer Motion's ability to stagger or delay animations of nested elements. 168 | 169 | ![](https://tisho-co.s3.amazonaws.com/img/framer-switch/switch-auto-animate-transition-options.png) 170 | 171 | ## Auto-Animations Between Code Components 172 | 173 | Since code components can render arbitrary HTML, it's hard for Switch to figure out if content rendered by the code component should also be auto-animated. To get around this issue, whenever it encounters a Code Component, the Switch will only animate its position and size. It will then determine if any of the component's props have changed between the different states, and if they have, it will pass the new props to the component. 174 | 175 | Matching code components will not get unmounted and remounted between states, just passed new props. This is especially powerful with components that are expensive to fully re-render, like maps and videos. 176 | 177 | If this behavior doesn't match what you expect, you can set the `morphCodeComponentPropsOnly` prop on a Switch instance to `false` through an Override, and we'll fall back to a cross-dissolve between the two states of the code component. 178 | 179 | # Using Switch in Code 180 | 181 | Use these in Overrides, or when you use the `Switch` component from code. 182 | 183 | ## Switch Overrides 184 | 185 | | Prop | Type | Description | 186 | | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 187 | | `autoAssignIdentifier` | boolean | When `true`, the Switch instance will get its own random identifier automatically. Default: `false` | 188 | | `identifier` | string | The name of the Switch instance. Make sure this is unique. | 189 | | `initialState` | number | The index of the initial state of the Switch. Default: `0` | 190 | | `overflow` | boolean | Whether content outside the bounds of the container will be shown or not. Default: `true` | 191 | | `onSwitch` | function | A callback function that will be called whenever the Switch component switches its state. The function is passed in the current state index, the previous state index, and the identifier of the Switch component in this order. | 192 | | `shouldTrigger` | function | A callback function that will be called right before a trigger is fired. Returning `false` from this callback will stop the trigger from firing. All original arguments to the trigger will be passed down to the function (e.g. `event`) | 193 | | `transition` | enum | The transition to use when switching states. Can be one of: `instant`, `autoanimate`, `dissolve`, `zoom`, `zoomout`, `zoomin`, `swapup`, `swapdown`, `swapleft`, `swapright`, `slidehorizontal`, `slidevertical`, `slideup`, `slidedown`, `slideleft`, `slideright`, `pushhorizontal`, `pushvertical`, `pushup`, `pushdown`, `pushleft`, `pushright`. | 194 | 195 | ## SwitchToStateAction Overrides 196 | 197 | | Prop | Type | Description | 198 | | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 199 | | `targetType` | enum | Defines whether the SwitchToStateAction will target the `closest` switch, or target a specifically `named` Switch. Possible values are: `closest`, `named`. Default is `named`. | 200 | | `target` | string | The name of the Switch instance to target. Make sure this is unique and matches the name of an existing Switch. Only used when `targetType` is set to `named`. | 201 | | `shouldTrigger` | function | A callback function that will be called right before a trigger is fired. Returning `false` from this callback will stop the trigger from firing. All original arguments to the trigger will be passed down to the function (e.g. `event`) | 202 | 203 | ## Controlling Switches with the `useSwitch` Hook 204 | 205 | To control Switches from code, first import the `useSwitch` hook at the top of your file: 206 | 207 | ``` 208 | import { useSwitch } from "@framer/tishogeorgiev.switch/code" 209 | ``` 210 | 211 | **Note on Framer Web**: Sometimes Web will throw an error in the in-app preview saying it can't find `useSwitch`. This is because it hasn't had time to reload all of its dependencies. When this happens, refresh your browser and it should be fine. 212 | 213 | **Note**: You will likely get a red underline under `@framer/tishogeorgiev.switch/code`, with an error message like "Cannot find module '@framer/tishogeorgiev.switch/code'". You can safely ignore this error, as the code will work anyway. If it bothers you, import `useSwitch` like this, instead: 214 | 215 | ``` 216 | // @ts-ignore 217 | import { useSwitch } from "@framer/tishogeorgiev.switch/code" 218 | ``` 219 | 220 | Then call the `useSwitch()` hook in your code component or override: 221 | 222 | ```typescript 223 | export function SwitchButton(): Override { 224 | const controls = useSwitch() 225 | 226 | return { 227 | onTap: () => { 228 | controls.setSwitchState("sharedSwitch", 1) 229 | }, 230 | } 231 | } 232 | ``` 233 | 234 | Note: you can **only** call this hook from inside a React component or override. Calling it from a different place in your code could result in unexpected behavior. [Read more about the rules of hook usage](https://reactjs.org/docs/hooks-rules.html). 235 | 236 | The `useSwitch` hook will return a controls object with the following functions: 237 | 238 | - `controls.getSwitches() => string[]` 239 | 240 | Returns an array of identifiers for all registered Switches. 241 | 242 | - `controls.getSwitchStateIndex(identifier: string) => number` 243 | 244 | Returns the index of the current state of a Switch. 245 | 246 | - `controls.setSwitchState(identifier: string, state: string | number)` 247 | 248 | Sets the current state of a Switch. You can use either the name of the target state (which will be the same as the name of its layer in the Layers panel), or its numerical state index. 249 | 250 | - `controls.setSwitchStateIndex(identifier: string, state: number)` 251 | 252 | **(Deprecated in favor of `setSwitchState`)** Sets the current state index of a Switch. If the index isn't valid, it will still be accepted, but the targeted Switch will remain set to its last known good state. 253 | 254 | - `controls.setNextSwitchState(identifier: string, options: { wrapAround?: boolean } = {})` 255 | 256 | Sets the current state of a Switch to the next available one (the order is the same as the order in which you connected the states). The second `options` parameter is optional, and allows you to specify whether the state will wrap around if trying to advance past the last one. By default, `wrapAround` is true. 257 | 258 | Compatibility note: This method used to be called `setNextSwitchStateIndex` in versions prior to v1.8.0. The old name is still supported. 259 | 260 | - `controls.setPreviousSwitchState(identifier: string, options: { wrapAround?: boolean } = {})` 261 | 262 | Sets the current state of a Switch to the previous one (one behind the current one). The second `options` parameter is optional, and allows you to specify whether the state will wrap around if trying to go back beyond the first one. By default, `wrapAround` is true. 263 | 264 | Compatibility note: This method used to be called `setPreviousSwitchStateIndex` in versions prior to v1.8.0. The old name is still supported. 265 | 266 | - `controls.getAllSwitchStates(identifier: string) => string[]` 267 | 268 | Returns an array of all names states for a Switch. Frames that haven't been explicitly named might have `undefined` as their name. 269 | 270 | - `controls.registerSwitchStates(identifier: string, states: string[])` 271 | 272 | Registers a list of named states for a particular Switch identifier. For internal use only. 273 | 274 | # Past Releases 275 | 276 | 12/17/2020 277 | 278 | - FIX: Compatibility fixes for latest version of Framer that resolve issues 279 | with animating border radii and gradient fills. 280 | 281 | 10/30/2020 282 | 283 | - FIX: Fixed an issue where border radius wasn't being parsed correctly when using Auto Animate (Thanks, @astegmaier). 284 | 285 | 10/07/2020 286 | 287 | - FIX: Fixed an issue that prevented Auto Animate from running on latest version of Framer Motion (Thanks, Victor). 288 | 289 | 9/08/2020 290 | 291 | - FIX: Addressed compatibility issues with the version of Framer Motion 2 used in an upcoming release of Framer (Thanks @hemlok). **NOTE** If your prototype suddenly has glitchy/broken animations, you should update to this version (open the insert menu by pressing `I`, then go to the Switch package and wait for the "Update" button to show up). Let me know if you continue to have problems after. 292 | 293 | 9/01/2020 294 | 295 | - FIX: Fixed an issue that could cause border radius properties to not be parsed correctly. 296 | 297 | 7/17/2020 298 | 299 | - FIX: Fixed an issue that could prevent Auto-Animate from working on some versions of Framer Web. 300 | 301 | 5/29/2020 302 | 303 | - NEW: Added the ability for SwitchToStateAction to control the closest parent Switch, instead of needing to specify the name of a Switch to control. This helps tremendously when using Switch in reusable components, or multi-step animations, and takes away the need to even set a name for most Switches. I've reconfigured a lot of the existing examples to take advantage of this, so take a look if you're wondering how to set it up. 304 | 305 | 5/26/2020 306 | 307 | - FIX: Fixed an issue that prevented Switch containers from receiving focus when clicked, which sometimes prevented keyboard triggers from working (thanks @Si). 308 | 309 | 5/14/2020 310 | 311 | - FIX: Fixed an issue that prevented animated borders and box shadows from animating to the right color when using shared colors (thanks @Joe Preston). 312 | 313 | 4/6/2020 314 | 315 | - FIX: Improved the way Switch loads in order to work around some issues in Framer that could cause the Switch component to disappear from the project entirely, leaving existing instances on the canvas disconnected from their states. 316 | - FIX: Fixed a regression that made Switches show an error when you first toggle the "Interactive" property. 317 | 318 | 3/24/2020 319 | 320 | - FIX: Fixed an issue that kept layers visible during an auto-animate transition, even when they were hidden from the layers panel in the target state (thanks @davidhoeller). 321 | - FIX: Fixed text and graphic layers flickering on first render, and significantly reduced flickering between text/graphics that stayed the same between states (thanks @Benjamin Frenzel). 322 | 323 | 3/23/2020 324 | 325 | - NEW: Added two new triggers: Double Tap and Long Press, paving the way for more gestures. 326 | - FIX: The zoom transition was missing transition props (thanks @Johannes Tutsch). 327 | - FIX: Fixed an issue that made states using the slide transition to appear above layers that are technically placed above the Switch in the view hierarchy (thanks @JeppeVolander) 328 | - FIX: Fixed Stacks and Graphics in Framer Web / Beta appearing in the wrong position when using Auto-Animate. 329 | - FIX: Fixed layers with Overrides or Interactions added in Framer Web / Beta appearing in the wrong position when using Auto-Animate. 330 | - FIX: Fixed an issue that made states render twice if they have Overrides or Interactions applied, which made "After Delay" triggers also trigger twice. 331 | 332 | 2/13/2020 333 | 334 | - FIX: Fixed an issue that prevented a Switch container from properly following layout constraints in responsive mode (thanks @Bojan Kocijan). 335 | 336 | 2/5/2020 337 | 338 | - FIX: Fixed a few issues related to using Switch from a code component. 339 | - FIX: Fixed draggable components breaking when used with auto-animate. 340 | - FIX: Fixed an issue that prevented `setSwitchState` from switching to an index if the name for the state for that index was undefined. 341 | 342 | 2/4/2020 343 | 344 | - FIX: Fixed custom easing curves not working in tween transitions (thanks @David Ikuye). 345 | - NEW: Added more easing curve presets, including most of the ones on https://easings.net. You can find them all under the Easing dropdown when customizing a tween transition. 346 | 347 | 1/31/2020 348 | 349 | - FIX: Fixed auto-animate breaking in shared link previews on Framer Web (thanks @Alexis). 350 | 351 | 1/10/2020 352 | 353 | - FIX: Fixed an issue where an entering or cross-dissolving element would always animate to opacity: 1, even if its target opacity was different. 354 | - FIX: Fixed an issue where the position of an entering element wasn't computed correctly when its container itself was moving/resizing. 355 | 356 | 1/8/2020 357 | 358 | - FIX: Fixed transitions that include colors defined as shared colors (thanks @CharlieWandCo). 359 | 360 | 1/2/2020 361 | 362 | - NEW: Named states. You can now refer to states by the name you've given them in the Layers panel, instead of by numerical index. You can now set a trigger action to "Specific State Name" and just type the name of the layer for the target state on your canvas. Note that default layer names, such as "Frame", are not supported, so make sure you explicitly set a name in the Layers panel. 363 | - NEW: Related to the named states support, you can now use the new `setSwitchState` function from code to set a Switch to either a named state, or a numerical index. See more in the [Using Switch in Code](#using-switch-in-code) section. 364 | - FIX: Auto-animate transitions between Text and Graphic layers should now look noticeably better. 365 | - FIX: Auto-animate transitions between Stacks with Overrides applied are now handled correctly. 366 | - FIX: Auto-animate transitions between more complex Graphic layers sometimes produced odd animations. This should now be fixed. 367 | 368 | 12/15/2019 369 | 370 | - **NEW: Auto-Animate (Magic Move)** added to the list of available transitions between states. Read on to [find out how Auto Animate works](#auto-animating-between-states), and definitely let me know if you spot any bugs when using the auto-animate transition. 371 | - NEW: Switch names can now be set to "Auto" if you never intend to set their state from a hotspot or through code, and don't want the Switch state to be shared with another Switch. 372 | - NEW: Added "After Delay" to the list of available triggers, which lets you switch to another state after a specified amount of time. The time is counted from the moment the hotspot (`SwitchToStateAction`) or the Switch component is mounted on the current view. 373 | - NEW: Added two new actions available through the `useSwitch` hook: `setNextSwitchStateIndex` and `setPreviousSwitchStateIndex`, allowing you to quickly set the next/previous state of a Switch. 374 | - FIX: Any time a Switch updated its state, all Switches in view would re-render, regardless of whether the update was intended for them, or not. This is no longer the case. 375 | 376 | 12/8/2019 377 | 378 | - FIX: Ensured that default props are correctly passed to a Switch component when used directly from code. 379 | 380 | 11/28/2019 381 | 382 | - FIX: Fixed an issue where the connecting the first state of a Switch would cause an error. 383 | - FIX: Fixed an issue that prevented event handlers you set through Overrides from firing if the same trigger wasn't also set on the Switch instance. 384 | 385 | 11/27/2019 386 | 387 | - NEW: Added two new triggers: "Key Down" and "Key Up", which allow you to switch states using keyboard shortcuts. You can use single keys as shortcuts, or more complex shortcuts like `ctrl+o, cmd+shift+t`. Switch uses the popular [hotkeys-js](https://github.com/jaywcjlove/hotkeys) library, so take a look at some of the examples on its Github page to get an idea of the syntax. 388 | 389 | 11/26/2019 390 | 391 | - **BREAKING CHANGE:** `SwitchToStateAction` now lets you define actions for multiple triggers, similar to a fully interactive Switch. Unfortunately, this change isn't compatible with the previous version of this package, so if you update to the latest version, make sure you rewire all hotspots you're using. 392 | - NEW: Code-level API for controlling Switches using the new `useSwitch` hook. More details in the docs below. 393 | - NEW: Added a `shouldTrigger` callback to SwitchToStateAction, which will be called whenever a trigger is about to be executed. If `shouldTrigger` returns `false`, the trigger won't fire. Check out the "Slide to Unlock" example in the updated examples project. 394 | - FIX: Fixed an issue that prevented Switches to use plain Stacks or some code component instances as states. 395 | - FIX: If an invalid state index is passed to the Switch, it will now stay at the last known good state, instead of reverting to the placeholder state. 396 | 397 | 11/21/2019 398 | 399 | - NEW: Added `onSwitch` callback to Switch component, which will be called when the current state changes. 400 | 401 | # Get In Touch 402 | 403 | - **@tisho** on Twitter 404 | - **@tisho** on the [Framer Slack](https://www.framer.com/community/) 405 | -------------------------------------------------------------------------------- /code/AutoAnimatedState.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useState, useEffect, memo, cloneElement } from "react" 3 | import { Size, useAnimation } from "framer" 4 | import * as Framer from "framer" 5 | import { getCache } from "./store/keyStore" 6 | import { pick } from "./utils/pick" 7 | import { randomID } from "./utils/randomID" 8 | import { 9 | getBackgroundColorPair, 10 | getBorderPair, 11 | getBorderRadius, 12 | getBoxShadowPair, 13 | getOpacity, 14 | getRotate, 15 | isBackgroundTransitionAnimatable, 16 | filterOutAbsolutePositioningProps, 17 | } from "./utils/styleParsing" 18 | import { 19 | canAnimateNodeChildren, 20 | getNodeName, 21 | getNodeId, 22 | getNodeType, 23 | getNodeRect, 24 | getNodeChildren, 25 | isNodeVisible, 26 | nodeWithIdAndKey, 27 | isNodeAnimatable, 28 | isSameComponent, 29 | } from "./utils/nodeHelpers" 30 | import { addAnimatableWrapperToNodeIfNeeded } from "./utils/addAnimatableWrapperToNodeIfNeeded" 31 | 32 | const motionSupportsLayoutProp = "AnimateLayoutFeature" in Framer 33 | const motionNeedsInitialVariant = !("createCrossfader" in Framer) 34 | 35 | const propsForPositionReset = { 36 | top: null, 37 | right: null, 38 | bottom: null, 39 | left: null, 40 | center: null, 41 | } 42 | 43 | const _AutoAnimatedState = ({ 44 | source, 45 | target, 46 | transitionPropsForElement, 47 | sourceParentSize, 48 | targetParentSize, 49 | direction, 50 | morphCodeComponentPropsOnly = true, 51 | parentContext = null, 52 | sourceKey = null, 53 | keyCache = null, 54 | transitionKey = null, 55 | }) => { 56 | const [id, _] = useState(randomID()) 57 | const keySourceCache = keyCache || getCache(id) 58 | const getSourceKey = keySourceCache.getSourceKey 59 | 60 | // Ensure both source and target have an id and key, even if they're auto-generated 61 | source = nodeWithIdAndKey(source) 62 | target = nodeWithIdAndKey(target) 63 | 64 | // The transition key will be used to create a unique name for the 65 | // initial/next variant used in animating the state transition. 66 | // All children will share the same transition key, so options like 67 | // staggerChildren can take effect. 68 | const tkey = transitionKey || `${source.key}-${target.key}` 69 | 70 | const sourceNodeType = getNodeType(source) 71 | const targetNodeType = getNodeType(target) 72 | 73 | const useAbsolutePositioning = !( 74 | ["StackLegacyContainer", "Stack"].indexOf(sourceNodeType) > -1 || 75 | ["StackLegacyContainer", "Stack"].indexOf(targetNodeType) > -1 || 76 | parentContext === "Stack" 77 | ) 78 | 79 | const controls = useAnimation() 80 | const initialVariantName = `__switch_initial_${tkey}` 81 | const nextVariantName = `__switch_next_${tkey}` 82 | const isRoot = transitionKey === null 83 | 84 | const sourcePositionAndSizeProps = getNodeRect(source, sourceParentSize) 85 | const targetPositionAndSizeProps = getNodeRect(target, targetParentSize) 86 | 87 | const shouldAnimateChildren = 88 | canAnimateNodeChildren(source) && canAnimateNodeChildren(target) 89 | 90 | const sourceStateChildren = shouldAnimateChildren 91 | ? getNodeChildren(source) 92 | : [] 93 | const targetStateChildren = shouldAnimateChildren 94 | ? getNodeChildren(target) 95 | : [] 96 | 97 | const morphingChildrenPairs = [] 98 | const morphingChildrenIds = [] 99 | const enteringChildrenIds = [] 100 | const exitingChildrenIds = [] 101 | 102 | const sourceStateChildrenIds = sourceStateChildren.map(getNodeId) 103 | 104 | targetStateChildren.forEach((child) => { 105 | const name = getNodeName(child) 106 | const id = getNodeId(child) 107 | 108 | // if the child isn't visible in the target state, skip it, so it can be marked for exiting 109 | if (!isNodeVisible(child)) { 110 | return 111 | } 112 | 113 | // find the first match by name that's not already in the morphing children list 114 | const match = sourceStateChildren.find((otherChild) => { 115 | const otherName = getNodeName(otherChild) 116 | const otherId = getNodeId(otherChild) 117 | 118 | return ( 119 | name === otherName && 120 | morphingChildrenIds.indexOf(otherId) === -1 121 | ) 122 | }) 123 | 124 | if (match) { 125 | const otherId = getNodeId(match) 126 | morphingChildrenIds.push(otherId) 127 | morphingChildrenPairs.push({ source: otherId, target: id }) 128 | return 129 | } 130 | 131 | // if there's no match, this child is entering the scene 132 | enteringChildrenIds.push(id) 133 | }) 134 | 135 | // exiting children will be all children from the current state that haven't been tagged as morphing 136 | sourceStateChildren.forEach((child) => { 137 | const id = getNodeId(child) 138 | if (morphingChildrenIds.indexOf(id) === -1) { 139 | exitingChildrenIds.push(id) 140 | } 141 | }) 142 | 143 | // put together final hierarchy 144 | 145 | // step 1: replace morphing children with their equivalents from current state 146 | // morphing children will be evaluated separately, so the fact that we're using 147 | // the source child in this stage of the hierarchy doesn't mean much. 148 | const targetHierarchy = targetStateChildren.map((child) => { 149 | const id = getNodeId(child) 150 | const morphingPair = morphingChildrenPairs.find((c) => c.target === id) 151 | 152 | if (morphingPair) { 153 | return sourceStateChildren.find( 154 | (c) => getNodeId(c) === morphingPair.source 155 | ) 156 | } 157 | return child 158 | }) 159 | 160 | // step 2: place exiting children back into the hierarchy 161 | exitingChildrenIds.forEach((id) => { 162 | const index = sourceStateChildrenIds.indexOf(id) 163 | let closestMorphingChildId 164 | 165 | for (let i = index; i >= 0; i--) { 166 | if (morphingChildrenIds.indexOf(sourceStateChildrenIds[i]) !== -1) { 167 | closestMorphingChildId = sourceStateChildrenIds[i] 168 | break 169 | } 170 | } 171 | 172 | const indexOfClosestMorphingChild = targetHierarchy.findIndex( 173 | (c) => getNodeId(c) === closestMorphingChildId 174 | ) 175 | const child = sourceStateChildren.find((c) => getNodeId(c) === id) 176 | 177 | if (typeof indexOfClosestMorphingChild !== "undefined") { 178 | targetHierarchy.splice(indexOfClosestMorphingChild + 1, 0, child) 179 | } else { 180 | targetHierarchy.unshift(child) 181 | } 182 | }) 183 | 184 | // ------------ Build Final Hierarchy with Animated Elements -------------- 185 | 186 | const animatedHierarchy = targetHierarchy.map((child) => { 187 | const id = getNodeId(child) 188 | const morphingPair = morphingChildrenPairs.find((c) => c.source === id) 189 | if (morphingPair) { 190 | const targetChild = targetStateChildren.find( 191 | (c) => getNodeId(c) === morphingPair.target 192 | ) 193 | 194 | const key = getSourceKey(targetChild.key, child.key) 195 | 196 | return React.createElement(AutoAnimatedState, { 197 | key, 198 | sourceKey: key, 199 | transitionKey: tkey, 200 | source: child, 201 | target: targetChild, 202 | transitionPropsForElement, 203 | sourceParentSize: Size( 204 | sourcePositionAndSizeProps.width, 205 | sourcePositionAndSizeProps.height 206 | ), 207 | targetParentSize: Size( 208 | targetPositionAndSizeProps.width, 209 | targetPositionAndSizeProps.height 210 | ), 211 | direction, 212 | parentContext: 213 | ["StackLegacyContainer", "Stack"].indexOf(sourceNodeType) > 214 | -1 || 215 | ["StackLegacyContainer", "Stack"].indexOf(targetNodeType) > 216 | -1 217 | ? "Stack" 218 | : null, 219 | keyCache: keySourceCache, 220 | }) 221 | } 222 | 223 | const positionAndSize = getNodeRect(child, sourcePositionAndSizeProps) 224 | 225 | const wrappedChild = addAnimatableWrapperToNodeIfNeeded(child, { 226 | ...propsForPositionReset, 227 | width: "100%", 228 | height: "100%", 229 | }) 230 | 231 | if (enteringChildrenIds.indexOf(id) !== -1) { 232 | const positionAndSizeInTarget = getNodeRect( 233 | child, 234 | targetPositionAndSizeProps 235 | ) 236 | let props = { 237 | ...propsForPositionReset, 238 | ...positionAndSizeInTarget, 239 | ...transitionPropsForElement({ 240 | source: child, 241 | sourceRect: positionAndSize, 242 | transition: "enter", 243 | transitionKey: tkey, 244 | useAbsolutePositioning, 245 | }), 246 | } 247 | 248 | props = useAbsolutePositioning 249 | ? props 250 | : filterOutAbsolutePositioningProps(props) 251 | 252 | return cloneElement(wrappedChild, props) 253 | } 254 | 255 | if (exitingChildrenIds.indexOf(id) !== -1) { 256 | let props = { 257 | ...propsForPositionReset, 258 | ...positionAndSize, 259 | ...transitionPropsForElement({ 260 | source: child, 261 | sourceRect: positionAndSize, 262 | transition: "exit", 263 | transitionKey: tkey, 264 | useAbsolutePositioning, 265 | }), 266 | } 267 | 268 | props = useAbsolutePositioning 269 | ? props 270 | : filterOutAbsolutePositioningProps(props) 271 | 272 | return cloneElement(wrappedChild, props) 273 | } 274 | }) 275 | 276 | // ------------ Set Up Transition Effect -------------- 277 | 278 | // Decide which transition to run 279 | const hasNonAnimatableTransitions = 280 | sourceNodeType === targetNodeType && 281 | !isBackgroundTransitionAnimatable(source, target) 282 | 283 | const shouldCrossDissolve = 284 | sourceNodeType !== targetNodeType || 285 | hasNonAnimatableTransitions || 286 | !isNodeAnimatable(source) || 287 | !isNodeAnimatable(target) 288 | 289 | const shouldMorphComponentProps = 290 | morphCodeComponentPropsOnly && 291 | sourceNodeType === "ComponentContainer" && 292 | targetNodeType === "ComponentContainer" && 293 | isSameComponent(source, target) 294 | 295 | useEffect(() => { 296 | // We only need to start the variant transition at the root level. 297 | // animations deeper in the hierarchy will be automatically triggered, 298 | // because they share the same variant name through the transition key. 299 | if (!isRoot) { 300 | return 301 | } 302 | 303 | if ( 304 | source.type === target.type && 305 | getNodeId(source) === getNodeId(target) 306 | ) { 307 | // skip animation entirely if we're not transitioning to a new state 308 | controls.set(nextVariantName) 309 | } else { 310 | // delay animation until after the next paint / layout, so animations 311 | // can start from the correct values 312 | requestAnimationFrame(() => 313 | setTimeout(() => { 314 | controls.start(nextVariantName) 315 | }, 0) 316 | ) 317 | } 318 | }, [source, target, controls, initialVariantName, nextVariantName]) 319 | 320 | // ------------ Cross-Dissolving Elements -------------- 321 | 322 | if (shouldCrossDissolve && !shouldMorphComponentProps) { 323 | const enteringChildVariants = { 324 | [initialVariantName]: { 325 | opacity: 0, 326 | display: "block", 327 | }, 328 | [nextVariantName]: { 329 | ...targetPositionAndSizeProps, 330 | opacity: getOpacity(target.props.style || {}), 331 | display: "block", 332 | scaleX: 1, 333 | scaleY: 1, 334 | translateX: [0, 0], 335 | }, 336 | } 337 | 338 | const exitingChildVariants = { 339 | [initialVariantName]: { 340 | opacity: getOpacity(source.props.style || {}), 341 | display: "block", 342 | }, 343 | [nextVariantName]: { 344 | ...pick(targetPositionAndSizeProps, ["top", "left"]), 345 | scaleX: 346 | targetPositionAndSizeProps.width / 347 | sourcePositionAndSizeProps.width, 348 | scaleY: 349 | targetPositionAndSizeProps.height / 350 | sourcePositionAndSizeProps.height, 351 | opacity: 0, 352 | transitionEnd: { 353 | translateX: -9999, 354 | }, 355 | }, 356 | } 357 | 358 | const enterTransitionProps = transitionPropsForElement({ 359 | source, 360 | target, 361 | transition: "cross-dissolve-enter", 362 | }) 363 | 364 | const exitTransitionProps = transitionPropsForElement({ 365 | source, 366 | target, 367 | transition: "cross-dissolve-exit", 368 | }) 369 | 370 | const key = getSourceKey(sourceKey, source.key) 371 | const keyForEnteringChild = 372 | direction === 1 ? `__enter_${key}` : `__exit_${key}` 373 | 374 | const sameSourceAndTarget = source.key === target.key 375 | 376 | let enteringChildProps = { 377 | key: keyForEnteringChild, 378 | ...propsForPositionReset, 379 | ...pick(sourcePositionAndSizeProps, ["top", "left"]), 380 | ...pick(targetPositionAndSizeProps, ["width", "height"]), 381 | originX: 0, 382 | originY: 0, 383 | scaleX: 384 | sourcePositionAndSizeProps.width / 385 | targetPositionAndSizeProps.width, 386 | scaleY: 387 | sourcePositionAndSizeProps.height / 388 | targetPositionAndSizeProps.height, 389 | variants: { 390 | ...(target.props.variants || {}), 391 | ...enteringChildVariants, 392 | }, 393 | animate: isRoot ? controls : undefined, 394 | ...enterTransitionProps, 395 | } 396 | 397 | if (motionNeedsInitialVariant) { 398 | enteringChildProps.initial = initialVariantName 399 | } 400 | 401 | enteringChildProps = useAbsolutePositioning 402 | ? enteringChildProps 403 | : filterOutAbsolutePositioningProps(enteringChildProps) 404 | 405 | const keyForExitingChild = 406 | direction === 1 ? `__exit_${key}` : `__enter_${key}` 407 | 408 | let exitingChildProps = { 409 | key: keyForExitingChild, 410 | // disable magic motion for same source-target transitions, because otherwise 411 | // the layout of the exiting child will get cached by motion, and any subsequent 412 | // animations will use it as a starting point, which will cause a noticeable flicker. 413 | _canMagicMotion: !sameSourceAndTarget, 414 | ...propsForPositionReset, 415 | ...sourcePositionAndSizeProps, 416 | originX: 0, 417 | originY: 0, 418 | scaleX: 1, 419 | scaleY: 1, 420 | variants: { 421 | ...(source.props.variants || {}), 422 | ...exitingChildVariants, 423 | }, 424 | animate: isRoot ? controls : undefined, 425 | ...exitTransitionProps, 426 | } 427 | 428 | if (motionNeedsInitialVariant) { 429 | exitingChildProps.initial = initialVariantName 430 | } 431 | 432 | exitingChildProps = useAbsolutePositioning 433 | ? exitingChildProps 434 | : filterOutAbsolutePositioningProps(exitingChildProps) 435 | 436 | const wrappedSource = addAnimatableWrapperToNodeIfNeeded( 437 | source, 438 | { 439 | ...propsForPositionReset, 440 | width: "100%", 441 | height: "100%", 442 | }, 443 | shouldAnimateChildren ? animatedHierarchy : [] 444 | ) 445 | 446 | const wrappedTarget = addAnimatableWrapperToNodeIfNeeded( 447 | target, 448 | { 449 | ...propsForPositionReset, 450 | ...pick(targetPositionAndSizeProps, ["width", "height"]), 451 | }, 452 | shouldAnimateChildren ? animatedHierarchy : [] 453 | ) 454 | 455 | const enteringElement = cloneElement(wrappedTarget, enteringChildProps) 456 | const exitingElement = cloneElement(wrappedSource, exitingChildProps) 457 | 458 | return ( 459 | <> 460 | {exitingElement} 461 | {enteringElement} 462 | 463 | ) 464 | } 465 | 466 | // ------------ Variants for Morphing Elements -------------- 467 | 468 | const { 469 | sourceProps: initialVariant, 470 | targetProps: nextVariant, 471 | } = getPropTransitionsBetweenNodes( 472 | source, 473 | target, 474 | sourceParentSize, 475 | targetParentSize, 476 | parentContext 477 | ) 478 | 479 | let transitionProps = { 480 | ...propsForPositionReset, 481 | ...sourcePositionAndSizeProps, 482 | _border: null, 483 | style: { 484 | ...source.props.style, 485 | }, 486 | variants: { 487 | ...(target.props.variants || {}), 488 | [nextVariantName]: nextVariant, 489 | }, 490 | animate: isRoot ? controls : undefined, 491 | ...transitionPropsForElement({ 492 | source, 493 | target, 494 | transition: "morph", 495 | }), 496 | } 497 | 498 | if (motionNeedsInitialVariant) { 499 | transitionProps.initial = initialVariantName 500 | transitionProps.variants[initialVariantName] = initialVariant 501 | } 502 | 503 | transitionProps = 504 | !useAbsolutePositioning && sourceNodeType !== "Stack" 505 | ? filterOutAbsolutePositioningProps(transitionProps) 506 | : transitionProps 507 | 508 | const key = getSourceKey(sourceKey, source.key) 509 | 510 | // ------------ Stacks -------------- 511 | 512 | if (sourceNodeType === "StackLegacyContainer") { 513 | const containerProps = { 514 | key, 515 | id: null, 516 | ...propsForPositionReset, 517 | ...sourcePositionAndSizeProps, 518 | top: 0, 519 | left: 0, 520 | _border: null, 521 | } 522 | 523 | const sourceStack = React.Children.toArray(source.props.children)[0] 524 | const targetStack = React.Children.toArray(target.props.children)[0] 525 | 526 | const { 527 | sourceProps: stackInitialVariant, 528 | targetProps: stackNextVariant, 529 | } = getPropTransitionsBetweenNodes( 530 | sourceStack, 531 | targetStack, 532 | sourcePositionAndSizeProps, 533 | targetPositionAndSizeProps, 534 | parentContext 535 | ) 536 | 537 | let stackProps = { 538 | key, 539 | id: null, 540 | ...transitionProps, 541 | top: sourcePositionAndSizeProps.top, 542 | left: sourcePositionAndSizeProps.left, 543 | variants: { 544 | [initialVariantName]: stackInitialVariant, 545 | [nextVariantName]: { 546 | ...stackNextVariant, 547 | top: targetPositionAndSizeProps.top, 548 | left: targetPositionAndSizeProps.left, 549 | }, 550 | }, 551 | } 552 | 553 | stackProps = 554 | parentContext === "Stack" 555 | ? filterOutAbsolutePositioningProps(stackProps) 556 | : stackProps 557 | 558 | return cloneElement( 559 | source, 560 | containerProps, 561 | cloneElement(sourceStack, stackProps, ...animatedHierarchy) 562 | ) 563 | } 564 | 565 | // ------------ Code Components -------------- 566 | 567 | if (shouldMorphComponentProps) { 568 | const sourceComponent = React.Children.toArray(source.props.children)[0] 569 | const targetComponent = React.Children.toArray(target.props.children)[0] 570 | 571 | const layoutTransitionProps = motionSupportsLayoutProp 572 | ? { layout: !useAbsolutePositioning } 573 | : { 574 | positionTransition: useAbsolutePositioning 575 | ? false 576 | : transitionPropsForElement({ 577 | source, 578 | target, 579 | transition: "morph", 580 | }).transition, 581 | } 582 | 583 | const containerProps = { 584 | ...transitionProps, 585 | ...layoutTransitionProps, 586 | key, 587 | } 588 | 589 | const wrappedSource = addAnimatableWrapperToNodeIfNeeded( 590 | source, 591 | { 592 | ...propsForPositionReset, 593 | width: "100%", 594 | height: "100%", 595 | id: target.props.id, 596 | key, 597 | }, 598 | [ 599 | cloneElement(sourceComponent, { 600 | ...targetComponent.props, 601 | key, 602 | }), 603 | ] 604 | ) 605 | 606 | return <>{cloneElement(wrappedSource, containerProps)} 607 | } 608 | 609 | // ------------ All Other Morphable Elements -------------- 610 | 611 | const wrappedTarget = addAnimatableWrapperToNodeIfNeeded(target, { 612 | ...propsForPositionReset, 613 | width: "100%", 614 | height: "100%", 615 | key, 616 | }) 617 | 618 | // `positionTransition` is deprecated in Motion 2, and produces unexpected results when 619 | // used inside of Framer. 620 | const layoutTransitionProps = motionSupportsLayoutProp 621 | ? { layout: !useAbsolutePositioning } 622 | : { 623 | positionTransition: useAbsolutePositioning 624 | ? false 625 | : transitionPropsForElement({ 626 | source, 627 | target, 628 | transition: "morph", 629 | }).transition, 630 | } 631 | 632 | return cloneElement( 633 | wrappedTarget, 634 | { 635 | id: null, 636 | ...transitionProps, 637 | ...layoutTransitionProps, 638 | key, 639 | }, 640 | [{animatedHierarchy}] 641 | ) 642 | } 643 | 644 | _AutoAnimatedState.displayName = "AutoAnimatedState" 645 | export const AutoAnimatedState = memo(_AutoAnimatedState) as any 646 | 647 | const getPropTransitionsBetweenNodes = ( 648 | source, 649 | target, 650 | sourceParentSize, 651 | targetParentSize, 652 | parentContext 653 | ) => { 654 | const targetPositionAndSizeProps = getNodeRect(target, targetParentSize) 655 | 656 | const [ 657 | sourceBackgroundProps, 658 | targetBackgroundProps, 659 | ] = getBackgroundColorPair(source.props, target.props) 660 | 661 | const [sourceBoxShadow, targetBoxShadow] = getBoxShadowPair( 662 | source.props, 663 | target.props 664 | ) 665 | 666 | const [sourceBorder, targetBorder] = getBorderPair( 667 | source.props, 668 | target.props 669 | ) 670 | 671 | const sourceProps = { 672 | opacity: getOpacity(source.props.style), 673 | rotate: getRotate(source.props.style), 674 | ...getBorderRadius(source.props.style), 675 | ...sourceBackgroundProps, 676 | boxShadow: sourceBoxShadow, 677 | ...sourceBorder, 678 | } 679 | 680 | let targetProps = { 681 | ...targetPositionAndSizeProps, 682 | opacity: getOpacity(target.props.style), 683 | rotate: getRotate(target.props.style), 684 | ...getBorderRadius(target.props.style), 685 | ...targetBackgroundProps, 686 | boxShadow: targetBoxShadow, 687 | ...targetBorder, 688 | } 689 | 690 | targetProps = 691 | parentContext === "Stack" 692 | ? filterOutAbsolutePositioningProps(targetProps) 693 | : targetProps 694 | 695 | return { 696 | sourceProps, 697 | targetProps, 698 | } 699 | } 700 | -------------------------------------------------------------------------------- /code/PreventLayoutIdGeneration.tsx: -------------------------------------------------------------------------------- 1 | import * as Framer from "framer" 2 | import * as React from "react" 3 | 4 | const _PreventLayoutIdGeneration = (props: { 5 | children: React.ReactNode | React.ReactNode[] 6 | }) => { 7 | const context = React.useRef({ 8 | getLayoutId: (args) => null, 9 | persistLayoutIdCache: () => {}, 10 | top: false, 11 | }).current 12 | 13 | // LayoutIdContext may not exist on Framer if Switch is used in an old version of Framer. 14 | // @ts-ignore 15 | if (Framer.LayoutIdContext) { 16 | return ( 17 | // @ts-ignore 18 | 19 | ) 20 | } 21 | 22 | return <>{props.children} 23 | } 24 | 25 | export const PreventLayoutIdGeneration = _PreventLayoutIdGeneration as any 26 | -------------------------------------------------------------------------------- /code/Switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | createElement, 4 | useEffect, 5 | useRef, 6 | useState, 7 | useMemo, 8 | memo, 9 | } from "react" 10 | import { addCallback } from "reactn" 11 | import { 12 | Frame, 13 | addPropertyControls, 14 | ControlType, 15 | AnimatePresence, 16 | RenderTarget, 17 | Size, 18 | ControlDescription, 19 | } from "framer" 20 | import hotkeys, { KeyHandler } from "hotkeys-js" 21 | import { actions } from "./store/globalStore" 22 | import { placeholderState } from "./placeholderState" 23 | import { TRANSITIONS, DEFAULT_TWEEN, DEFAULT_SPRING } from "./transitions" 24 | import { omit } from "./utils/omit" 25 | import { colors as thumbnailColors } from "./thumbnailStyles" 26 | import { 27 | eventTriggerProps, 28 | keyEventTriggerProps, 29 | automaticEventTriggerProps, 30 | eventTriggerPropertyControls, 31 | transitionPropertyControls, 32 | } from "./controls" 33 | import { extractEventHandlersFromProps } from "./utils/extractEventHandlersFromProps" 34 | import { AutoAnimatedState } from "./AutoAnimatedState" 35 | import { sanitizePropName } from "./utils/propNameHelpers" 36 | import { randomID } from "./utils/randomID" 37 | import { PreventLayoutIdGeneration } from "./PreventLayoutIdGeneration" 38 | 39 | // ------------------- Switch Component ------------------- 40 | 41 | function _Switch(props) { 42 | const { 43 | children, 44 | autoAssignIdentifier, 45 | identifier = "", 46 | transition = "instant", 47 | overflow = true, 48 | initialState = 0, 49 | isInteractive, 50 | onSwitch, 51 | morphCodeComponentPropsOnly, 52 | ...rest 53 | } = props 54 | 55 | if (RenderTarget.current() === RenderTarget.thumbnail) { 56 | return 57 | } 58 | 59 | const [currentStateIndex, setCurrentStateIndex] = useState(initialState) 60 | 61 | const [randomIdentifier] = useState(() => randomID()) 62 | 63 | const [id, setId] = useState( 64 | autoAssignIdentifier ? randomIdentifier : identifier 65 | ) 66 | 67 | useEffect(() => { 68 | setId(autoAssignIdentifier ? randomIdentifier : identifier) 69 | }, [autoAssignIdentifier, identifier]) 70 | 71 | const { 72 | getSwitchStateIndex, 73 | getAllSwitchStates, 74 | setSwitchStateIndex, 75 | registerSwitchStates, 76 | } = actions 77 | 78 | const states = React.Children.toArray(children).map( 79 | (c) => c.props.name || "" 80 | ) 81 | const sanitizedIdentifier = sanitizePropName(id) 82 | const current = 83 | typeof currentStateIndex === "undefined" 84 | ? initialState 85 | : currentStateIndex 86 | 87 | // the current index ref will be used to calculate direction 88 | const currentIndexRef = useRef(current) 89 | const previousIndexRef = useRef(current) 90 | 91 | const previous = currentIndexRef.current 92 | const atWrapBoundary = 93 | (previous === states.length - 1 && current === 0) || 94 | (previous === 0 && current === states.length - 1) 95 | let direction = previous <= current ? 1 : -1 96 | 97 | // at the wrap boundary directions are intentionally reversed, 98 | // so that going from 0 to the last state looks like going back, 99 | // instead of going forward 100 | if (atWrapBoundary) { 101 | direction = -direction 102 | } 103 | 104 | if (children[current]) { 105 | currentIndexRef.current = current 106 | } else if (children[previous]) { 107 | currentIndexRef.current = previous 108 | } else { 109 | currentIndexRef.current = initialState 110 | } 111 | 112 | // ensure that previousIndexRef always points to the true previous index 113 | // i.e. even if you re-render the same state, previousIndexRef won't change 114 | // this is needed to pass the correct source/target for AutoAnimatedState 115 | if (currentIndexRef.current !== previous) { 116 | previousIndexRef.current = previous 117 | } 118 | 119 | if ( 120 | currentIndexRef.current !== previous && 121 | typeof onSwitch !== "undefined" 122 | ) { 123 | onSwitch(currentIndexRef.current, previous, sanitizedIdentifier) 124 | } 125 | 126 | const child = children[currentIndexRef.current] 127 | 128 | useEffect(() => { 129 | return addCallback(({ __switch }) => { 130 | const updatedIndex = __switch[sanitizedIdentifier] 131 | if (currentIndexRef.current !== updatedIndex) { 132 | setCurrentStateIndex(updatedIndex) 133 | } 134 | }) 135 | }, [sanitizedIdentifier]) 136 | 137 | // update the state for this element if the user manually 138 | // changes the initial state from the property controls 139 | useEffect(() => { 140 | setSwitchStateIndex(sanitizedIdentifier, initialState) 141 | }, [initialState, sanitizedIdentifier]) 142 | 143 | // store a registry of available states, so the SwitchToStateAction 144 | // instances can figure out what the next/previous state is 145 | useEffect(() => { 146 | registerSwitchStates(sanitizedIdentifier, states) 147 | }, [children, sanitizedIdentifier]) 148 | 149 | // Extract event handlers from props 150 | // Note: extract runs hooks for some gesture-related events, so it's 151 | // important to NOT run it conditionally 152 | let [ 153 | eventHandlers, 154 | keyEvents, 155 | automaticEvents, 156 | ] = extractEventHandlersFromProps( 157 | props, 158 | { getSwitchStateIndex, getAllSwitchStates, setSwitchStateIndex }, 159 | sanitizedIdentifier 160 | ) 161 | 162 | // reset the results of the previous call if this switch isn't interactive 163 | if (!isInteractive) { 164 | eventHandlers = {} 165 | keyEvents = [] 166 | automaticEvents = [] 167 | } 168 | 169 | const automaticEventProps = Object.keys(props) 170 | .filter((prop) => automaticEventTriggerProps.indexOf(prop) !== -1) 171 | .map((prop) => props[prop]) 172 | 173 | // execute automatic (delay) event triggers 174 | useEffect(() => { 175 | if (RenderTarget.current() !== RenderTarget.preview) { 176 | return 177 | } 178 | 179 | const timeouts = automaticEvents.map(({ handler }) => handler()) 180 | 181 | return () => { 182 | timeouts.forEach(clearTimeout) 183 | } 184 | }, [...automaticEventProps, sanitizedIdentifier]) 185 | 186 | // attach key event handlers 187 | const keyEventProps = Object.keys(props) 188 | .filter((prop) => keyEventTriggerProps.indexOf(prop) !== -1) 189 | .map((prop) => props[prop]) 190 | 191 | useEffect(() => { 192 | if (RenderTarget.current() !== RenderTarget.preview) { 193 | return 194 | } 195 | 196 | keyEvents.forEach(({ hotkey, options, handler }) => 197 | hotkeys(hotkey, options, handler as KeyHandler) 198 | ) 199 | 200 | return () => { 201 | keyEvents.forEach(({ hotkey, handler }) => 202 | hotkeys.unbind(hotkey, handler as KeyHandler) 203 | ) 204 | } 205 | }, [...keyEventProps, sanitizedIdentifier]) 206 | 207 | const transitionPropsForElement = ({ 208 | source, 209 | sourceRect, 210 | target, 211 | transition, 212 | useAbsolutePositioning, 213 | transitionKey, 214 | }) => { 215 | if (transition === "enter") { 216 | return TRANSITIONS[props.enterTransition](source.props, props, { 217 | transitionKey, 218 | sourceRect, 219 | useAbsolutePositioning, 220 | }) 221 | } 222 | 223 | if (transition === "exit") { 224 | return TRANSITIONS[props.exitTransition](source.props, props, { 225 | transitionKey, 226 | sourceRect, 227 | useAbsolutePositioning, 228 | }) 229 | } 230 | 231 | if ( 232 | transition === "cross-dissolve-enter" || 233 | transition === "cross-dissolve-exit" 234 | ) { 235 | return TRANSITIONS.crossdissolve(source.props, props, { 236 | direction: transition, 237 | }) 238 | } 239 | 240 | return TRANSITIONS.morph(source.props, props) 241 | } 242 | 243 | const size = useMemo(() => { 244 | if (child) { 245 | return Size(child.props.width, child.props.height) 246 | } 247 | }, [child]) 248 | 249 | // if not connected to anything, show placeholder 250 | if (!child) { 251 | return createElement(placeholderState, { 252 | title: "No states", 253 | label: "Add views for each state by connecting them on the Canvas", 254 | }) 255 | } 256 | 257 | if (RenderTarget.current() !== RenderTarget.preview) { 258 | return ( 259 | 266 | {child} 267 | 268 | ) 269 | } 270 | 271 | if (transition === "autoanimate") { 272 | return ( 273 | 282 | 283 | 284 | 297 | 298 | 299 | 300 | ) 301 | } 302 | 303 | return ( 304 | 314 | 315 | 316 | 327 | {child} 328 | 329 | 330 | 331 | 332 | ) 333 | } 334 | 335 | const defaultProps = { 336 | overflow: true, 337 | autoAssignIdentifier: false, 338 | identifier: "sharedSwitch", 339 | initialState: 0, 340 | isInteractive: false, 341 | // Specifies how code components will be handled during auto-animate. 342 | // When this is true, the auto animator will try to preserve code component 343 | // instances between states and only throw new props at them. When it's false, 344 | // code components will cross-dissolve between instances in the source / target state. 345 | // Switch this to `false` with an override if code components don't seem to behave 346 | // as expected during auto animate transitions. 347 | morphCodeComponentPropsOnly: true, 348 | transition: "instant", 349 | transitionConfigType: "default", 350 | transitionType: "spring", 351 | enterTransition: "enterdissolve", 352 | enterTransitionConfigType: "default", 353 | enterTransitionType: "tween", 354 | exitTransition: "exitdissolve", 355 | exitTransitionConfigType: "default", 356 | exitTransitionType: "tween", 357 | damping: DEFAULT_SPRING.damping, 358 | mass: DEFAULT_SPRING.mass, 359 | stiffness: DEFAULT_SPRING.stiffness, 360 | duration: DEFAULT_TWEEN.duration, 361 | ease: "easeOut", 362 | customEase: "0.25, 0.1, 0.25, 1", 363 | enterDamping: DEFAULT_SPRING.damping, 364 | enterMass: DEFAULT_SPRING.mass, 365 | enterStiffness: DEFAULT_SPRING.stiffness, 366 | enterDuration: DEFAULT_TWEEN.duration, 367 | enterEase: "easeOut", 368 | enterCustomEase: "0.25, 0.1, 0.25, 1", 369 | exitDamping: DEFAULT_SPRING.damping, 370 | exitMass: DEFAULT_SPRING.mass, 371 | exitStiffness: DEFAULT_SPRING.stiffness, 372 | exitDuration: DEFAULT_TWEEN.duration, 373 | exitEase: "easeOut", 374 | exitCustomEase: "0.25, 0.1, 0.25, 1", 375 | staggerChildren: 0, 376 | delayChildren: 0, 377 | 378 | // Auto-generated from the following code: 379 | // 380 | // JSON.stringify( 381 | // Object.keys(eventTriggerPropertyControls).reduce((res, prop) => { 382 | // if ("defaultValue" in eventTriggerPropertyControls[prop]) { 383 | // res[prop] = eventTriggerPropertyControls[prop].defaultValue 384 | // } 385 | // return res 386 | // }, {}) 387 | // ) 388 | 389 | afterDelayAction: "unset", 390 | afterDelaySpecificIndex: 0, 391 | afterDelaySpecificName: "", 392 | afterDelayDelay: 0, 393 | onTapAction: "unset", 394 | onTapSpecificIndex: 0, 395 | onTapSpecificName: "", 396 | onTapStartAction: "unset", 397 | onTapStartSpecificIndex: 0, 398 | onTapStartSpecificName: "", 399 | onTapCancelAction: "unset", 400 | onTapCancelSpecificIndex: 0, 401 | onTapCancelSpecificName: "", 402 | onHoverStartAction: "unset", 403 | onHoverStartSpecificIndex: 0, 404 | onHoverStartSpecificName: "", 405 | onHoverEndAction: "unset", 406 | onHoverEndSpecificIndex: 0, 407 | onHoverEndSpecificName: "", 408 | onDragStartAction: "unset", 409 | onDragStartSpecificIndex: 0, 410 | onDragStartSpecificName: "", 411 | onDragEndAction: "unset", 412 | onDragEndSpecificIndex: 0, 413 | onDragEndSpecificName: "", 414 | onDoubleTapAction: "unset", 415 | onDoubleTapSpecificIndex: 0, 416 | onDoubleTapSpecificName: "", 417 | onLongPressAction: "unset", 418 | onLongPressSpecificIndex: 0, 419 | onLongPressSpecificName: "", 420 | onLongPressDuration: 0.5, 421 | onKeyDownAction: "unset", 422 | onKeyDownSpecificIndex: 0, 423 | onKeyDownSpecificName: "", 424 | onKeyDownKey: "", 425 | onKeyUpAction: "unset", 426 | onKeyUpSpecificIndex: 0, 427 | onKeyUpSpecificName: "", 428 | onKeyUpKey: "", 429 | } 430 | 431 | _Switch.defaultProps = { 432 | height: 240, 433 | width: 240, 434 | ...defaultProps, 435 | } 436 | 437 | _Switch.displayName = "Switch" 438 | const __Switch = memo(_Switch) 439 | 440 | export const Switch = (props) => <__Switch {...props} /> 441 | 442 | // ------------------- Property Controls ------------------ 443 | 444 | addPropertyControls(Switch, { 445 | overflow: { 446 | type: ControlType.Boolean, 447 | title: "Overflow", 448 | defaultValue: defaultProps.overflow, 449 | enabledTitle: "Visible", 450 | disabledTitle: "Hidden", 451 | }, 452 | 453 | children: { 454 | title: "States", 455 | type: ControlType.Array, 456 | propertyControl: { 457 | type: ControlType.ComponentInstance, 458 | }, 459 | }, 460 | 461 | autoAssignIdentifier: { 462 | title: "Name", 463 | type: ControlType.Boolean, 464 | enabledTitle: "Auto", 465 | disabledTitle: "Set", 466 | defaultValue: defaultProps.autoAssignIdentifier, 467 | }, 468 | 469 | identifier: { 470 | title: " ", 471 | type: ControlType.String, 472 | defaultValue: defaultProps.identifier, 473 | hidden: (props) => props.autoAssignIdentifier, 474 | }, 475 | 476 | initialState: { 477 | title: "Initial State", 478 | type: ControlType.Number, 479 | displayStepper: true, 480 | defaultValue: defaultProps.initialState, 481 | }, 482 | 483 | // Event Handling 484 | 485 | isInteractive: { 486 | title: "Interactive", 487 | type: ControlType.Boolean, 488 | enabledTitle: "Yes", 489 | disabledTitle: "No", 490 | defaultValue: defaultProps.isInteractive, 491 | }, 492 | 493 | ...eventTriggerPropertyControls, 494 | 495 | // Transition Options 496 | 497 | transition: { 498 | title: "Transition", 499 | type: ControlType.Enum, 500 | options: [ 501 | "instant", 502 | "autoanimate", 503 | "dissolve", 504 | "zoom", 505 | "zoomout", 506 | "zoomin", 507 | "swapup", 508 | "swapdown", 509 | "swapleft", 510 | "swapright", 511 | "slidehorizontal", 512 | "slidevertical", 513 | "slideup", 514 | "slidedown", 515 | "slideleft", 516 | "slideright", 517 | "pushhorizontal", 518 | "pushvertical", 519 | "pushup", 520 | "pushdown", 521 | "pushleft", 522 | "pushright", 523 | ], 524 | optionTitles: [ 525 | "Instant", 526 | "Auto Animate (Magic Move)", 527 | "Dissolve", 528 | "Zoom (Direction-aware)", 529 | "Zoom Out", 530 | "Zoom In", 531 | "Swap ↑", 532 | "Swap ↓", 533 | "Swap ←", 534 | "Swap →", 535 | "Slide ←→ (Direction-aware)", 536 | "Slide ↑↓ (Direction-aware)", 537 | "Slide ↑", 538 | "Slide ↓", 539 | "Slide ←", 540 | "Slide →", 541 | "Push ←→ (Direction-aware)", 542 | "Push ↑↓ (Direction-aware)", 543 | "Push ↑", 544 | "Push ↓", 545 | "Push ←", 546 | "Push →", 547 | ], 548 | defaultValue: defaultProps.transition, 549 | }, 550 | 551 | // -- start: default/morph transition options -- 552 | 553 | transitionConfigType: { 554 | ...transitionPropertyControls.transitionConfigType, 555 | defaultValue: defaultProps["transitionConfigType"], 556 | hidden: (props) => props["transition"] === "instant", 557 | }, 558 | 559 | transitionType: { 560 | ...transitionPropertyControls.transitionType, 561 | defaultValue: defaultProps["transitionType"], 562 | hidden: (props) => 563 | props["transition"] === "instant" || 564 | props["transitionConfigType"] === "default", 565 | }, 566 | 567 | damping: { 568 | ...transitionPropertyControls.damping, 569 | hidden: (props) => 570 | props["transition"] === "instant" || 571 | props["transitionType"] !== "spring" || 572 | props["transitionConfigType"] === "default", 573 | defaultValue: defaultProps["damping"], 574 | }, 575 | 576 | mass: { 577 | ...transitionPropertyControls.mass, 578 | hidden: (props) => 579 | props["transition"] === "instant" || 580 | props["transitionType"] !== "spring" || 581 | props["transitionConfigType"] === "default", 582 | defaultValue: defaultProps["mass"], 583 | }, 584 | 585 | stiffness: { 586 | ...transitionPropertyControls.stiffness, 587 | hidden: (props) => 588 | props["transition"] === "instant" || 589 | props["transitionType"] !== "spring" || 590 | props["transitionConfigType"] === "default", 591 | defaultValue: defaultProps["stiffness"], 592 | }, 593 | 594 | duration: { 595 | ...transitionPropertyControls.duration, 596 | hidden: (props) => 597 | props["transition"] === "instant" || 598 | props["transitionType"] !== "tween" || 599 | props["transitionConfigType"] === "default", 600 | defaultValue: defaultProps["duration"], 601 | }, 602 | 603 | ease: { 604 | ...transitionPropertyControls.ease, 605 | hidden: (props) => 606 | props["transition"] === "instant" || 607 | props["transitionType"] !== "tween" || 608 | props["transitionConfigType"] === "default", 609 | defaultValue: defaultProps["ease"], 610 | }, 611 | 612 | customEase: { 613 | ...transitionPropertyControls.customEase, 614 | hidden: (props) => 615 | props["transition"] === "instant" || 616 | props["transitionType"] !== "tween" || 617 | props["transitionConfigType"] === "default" || 618 | props["ease"] !== "custom", 619 | defaultValue: defaultProps["customEase"], 620 | }, 621 | 622 | // -- end: default/morph transition options -- 623 | 624 | enterTransition: { 625 | title: "Enter Transition", 626 | type: ControlType.Enum, 627 | options: ["enterdissolve", "growdissolve", "enterInstant"], 628 | optionTitles: ["Dissolve", "Grow", "Instant"], 629 | defaultValue: defaultProps.enterTransition, 630 | hidden: (props) => props.transition !== "autoanimate", 631 | }, 632 | 633 | // -- start: enter transition options 634 | 635 | enterTransitionConfigType: { 636 | ...transitionPropertyControls.transitionConfigType, 637 | defaultValue: defaultProps["transitionConfigType"], 638 | hidden: (props) => 639 | props["transition"] !== "autoanimate" || 640 | props["enterTransition"] === "enterInstant", 641 | }, 642 | 643 | enterTransitionType: { 644 | ...transitionPropertyControls.transitionType, 645 | defaultValue: defaultProps["transitionType"], 646 | hidden: (props) => 647 | props["transition"] !== "autoanimate" || 648 | props["enterTransition"] === "enterInstant" || 649 | props["enterTransitionConfigType"] === "default", 650 | }, 651 | 652 | enterDamping: { 653 | ...transitionPropertyControls.damping, 654 | hidden: (props) => 655 | props["transition"] !== "autoanimate" || 656 | props["enterTransition"] === "enterInstant" || 657 | props["enterTransitionType"] !== "spring" || 658 | props["enterTransitionConfigType"] === "default", 659 | defaultValue: defaultProps["enterDamping"], 660 | }, 661 | 662 | enterMass: { 663 | ...transitionPropertyControls.mass, 664 | hidden: (props) => 665 | props["transition"] !== "autoanimate" || 666 | props["enterTransition"] === "enterInstant" || 667 | props["enterTransitionType"] !== "spring" || 668 | props["enterTransitionConfigType"] === "default", 669 | defaultValue: defaultProps["enterMass"], 670 | }, 671 | 672 | enterStiffness: { 673 | ...transitionPropertyControls.stiffness, 674 | hidden: (props) => 675 | props["transition"] !== "autoanimate" || 676 | props["enterTransition"] === "enterInstant" || 677 | props["enterTransitionType"] !== "spring" || 678 | props["enterTransitionConfigType"] === "default", 679 | defaultValue: defaultProps["enterStiffness"], 680 | }, 681 | 682 | enterDuration: { 683 | ...transitionPropertyControls.duration, 684 | hidden: (props) => 685 | props["transition"] !== "autoanimate" || 686 | props["enterTransition"] === "enterInstant" || 687 | props["enterTransitionType"] !== "tween" || 688 | props["enterTransitionConfigType"] === "default", 689 | defaultValue: defaultProps["enterDuration"], 690 | }, 691 | 692 | enterEase: { 693 | ...transitionPropertyControls.ease, 694 | hidden: (props) => 695 | props["transition"] !== "autoanimate" || 696 | props["enterTransition"] === "enterInstant" || 697 | props["enterTransitionType"] !== "tween" || 698 | props["enterTransitionConfigType"] === "default", 699 | defaultValue: defaultProps["enterEase"], 700 | }, 701 | 702 | enterCustomEase: { 703 | ...transitionPropertyControls.customEase, 704 | hidden: (props) => 705 | props["transition"] !== "autoanimate" || 706 | props["enterTransition"] === "enterInstant" || 707 | props["enterTransitionType"] !== "tween" || 708 | props["enterTransitionConfigType"] === "default" || 709 | props["enterEase"] !== "custom", 710 | defaultValue: defaultProps["enterCustomEase"], 711 | }, 712 | 713 | // -- end: enter transition options 714 | 715 | exitTransition: { 716 | title: "Exit Transition", 717 | type: ControlType.Enum, 718 | options: ["exitdissolve", "shrinkdissolve", "exitInstant"], 719 | optionTitles: ["Dissolve", "Shrink", "Instant"], 720 | defaultValue: defaultProps.exitTransition, 721 | hidden: (props) => props.transition !== "autoanimate", 722 | }, 723 | 724 | // -- start: exit transition options 725 | 726 | exitTransitionConfigType: { 727 | ...transitionPropertyControls.transitionConfigType, 728 | defaultValue: defaultProps["transitionConfigType"], 729 | hidden: (props) => 730 | props["transition"] !== "autoanimate" || 731 | props["exitTransition"] === "exitInstant", 732 | }, 733 | 734 | exitTransitionType: { 735 | ...transitionPropertyControls.transitionType, 736 | defaultValue: defaultProps["transitionType"], 737 | hidden: (props) => 738 | props["transition"] !== "autoanimate" || 739 | props["exitTransition"] === "exitInstant" || 740 | props["exitTransitionConfigType"] === "default", 741 | }, 742 | 743 | exitDamping: { 744 | ...transitionPropertyControls.damping, 745 | hidden: (props) => 746 | props["transition"] !== "autoanimate" || 747 | props["exitTransition"] === "exitInstant" || 748 | props["exitTransitionType"] !== "spring" || 749 | props["exitTransitionConfigType"] === "default", 750 | defaultValue: defaultProps["exitDamping"], 751 | }, 752 | 753 | exitMass: { 754 | ...transitionPropertyControls.mass, 755 | hidden: (props) => 756 | props["transition"] !== "autoanimate" || 757 | props["exitTransition"] === "exitInstant" || 758 | props["exitTransitionType"] !== "spring" || 759 | props["exitTransitionConfigType"] === "default", 760 | defaultValue: defaultProps["exitMass"], 761 | }, 762 | 763 | exitStiffness: { 764 | ...transitionPropertyControls.stiffness, 765 | hidden: (props) => 766 | props["transition"] !== "autoanimate" || 767 | props["exitTransition"] === "exitInstant" || 768 | props["exitTransitionType"] !== "spring" || 769 | props["exitTransitionConfigType"] === "default", 770 | defaultValue: defaultProps["exitStiffness"], 771 | }, 772 | 773 | exitDuration: { 774 | ...transitionPropertyControls.duration, 775 | hidden: (props) => 776 | props["transition"] !== "autoanimate" || 777 | props["exitTransition"] === "exitInstant" || 778 | props["exitTransitionType"] !== "tween" || 779 | props["exitTransitionConfigType"] === "default", 780 | defaultValue: defaultProps["exitDuration"], 781 | }, 782 | 783 | exitEase: { 784 | ...transitionPropertyControls.ease, 785 | hidden: (props) => 786 | props["transition"] !== "autoanimate" || 787 | props["exitTransition"] === "exitInstant" || 788 | props["exitTransitionType"] !== "tween" || 789 | props["exitTransitionConfigType"] === "default", 790 | defaultValue: defaultProps["exitEase"], 791 | }, 792 | 793 | exitCustomEase: { 794 | ...transitionPropertyControls.customEase, 795 | hidden: (props) => 796 | props["transition"] !== "autoanimate" || 797 | props["exitTransition"] === "exitInstant" || 798 | props["exitTransitionType"] !== "tween" || 799 | props["exitTransitionConfigType"] === "default" || 800 | props["exitEase"] !== "custom", 801 | defaultValue: defaultProps["exitCustomEase"], 802 | }, 803 | 804 | // -- end: exit transition options 805 | 806 | staggerChildren: { 807 | title: "Stagger", 808 | type: ControlType.Number, 809 | displayStepper: true, 810 | step: 0.01, 811 | unit: "s", 812 | defaultValue: defaultProps.staggerChildren, 813 | hidden: (props) => props.transition !== "autoanimate", 814 | }, 815 | 816 | delayChildren: { 817 | title: "Delay", 818 | type: ControlType.Number, 819 | displayStepper: true, 820 | step: 0.1, 821 | unit: "s", 822 | defaultValue: defaultProps.delayChildren, 823 | hidden: (props) => props.transition !== "autoanimate", 824 | }, 825 | } as { [key: string]: ControlDescription }) 826 | 827 | // ---------------------- Thumbnail ----------------------- 828 | 829 | function SwitchThumbnail() { 830 | return ( 831 | 837 | 838 | 839 | 844 | 849 | 854 | 862 | 870 | 871 | 872 | 873 | ) 874 | } 875 | -------------------------------------------------------------------------------- /code/SwitchOverrideExamples.tsx: -------------------------------------------------------------------------------- 1 | import { Override, useAnimation } from "framer" 2 | import { useSwitch } from "./" 3 | 4 | export function UnlockSlider(): Override { 5 | const controls = useAnimation() 6 | const dragThreshold = 200 7 | 8 | return { 9 | animate: controls, 10 | drag: "x", 11 | dragElastic: false, 12 | dragMomentum: false, 13 | dragConstraints: { left: 0, right: 200 }, 14 | onDragEnd: (e, { point }) => { 15 | // animate the slider to the start if it hasn't reached the 16 | // drag threshold 17 | if (point.x < dragThreshold) { 18 | controls.start({ x: 0 }) 19 | } 20 | }, 21 | 22 | // Only allow the SwitchToState action to trigger if the slider 23 | // has reached the drag threshold (200px to the right) 24 | shouldTrigger: (e, { point }) => point.x >= dragThreshold, 25 | } 26 | } 27 | 28 | export function ScaleDown(): Override { 29 | return { 30 | whileTap: { scale: 0.8 }, 31 | } 32 | } 33 | 34 | export function ExternalSwitchControl(): Override { 35 | const controls = useSwitch() 36 | 37 | return { 38 | onTap: () => { 39 | controls.setSwitchState("sharedFancyTabNav", "Middle Focused") 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /code/SwitchToStateAction.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useEffect, useState, useRef, createElement, memo } from "react" 3 | import { Frame, addPropertyControls, ControlType, RenderTarget } from "framer" 4 | import hotkeys, { KeyHandler } from "hotkeys-js" 5 | import { placeholderState } from "./placeholderState" 6 | import { sanitizePropName } from "./utils/propNameHelpers" 7 | import { omit } from "./utils/omit" 8 | import { colors as thumbnailColors } from "./thumbnailStyles" 9 | import { extractEventHandlersFromProps } from "./utils/extractEventHandlersFromProps" 10 | import { randomID } from "./utils/randomID" 11 | import { 12 | eventTriggerProps, 13 | keyEventTriggerProps, 14 | automaticEventTriggerProps, 15 | eventTriggerPropertyControls, 16 | } from "./controls" 17 | import { actions } from "./store/globalStore" 18 | 19 | // ------------- SwitchToStateAction Component ------------ 20 | 21 | function _SwitchToStateAction(props) { 22 | const { children, target, targetType, ...rest } = props 23 | const ref = useRef(null) 24 | const sanitizedTarget = sanitizePropName(target) 25 | const [targetId, setTargetId] = useState( 26 | targetType === "named" ? sanitizedTarget : randomID() 27 | ) 28 | 29 | if (RenderTarget.current() === RenderTarget.thumbnail) { 30 | return 31 | } 32 | 33 | const { 34 | getSwitchStateIndex, 35 | setSwitchStateIndex, 36 | registerSwitchStates, 37 | getAllSwitchStates, 38 | } = actions 39 | 40 | // Extract event handlers from props 41 | let [ 42 | eventHandlers, 43 | keyEvents, 44 | automaticEvents, 45 | ] = extractEventHandlersFromProps( 46 | props, 47 | { 48 | getSwitchStateIndex, 49 | setSwitchStateIndex, 50 | registerSwitchStates, 51 | getAllSwitchStates, 52 | }, 53 | targetId 54 | ) 55 | 56 | const automaticEventProps = Object.keys(props) 57 | .filter((prop) => automaticEventTriggerProps.indexOf(prop) !== -1) 58 | .map((prop) => props[prop]) 59 | 60 | // find id of closest switch if targetType is `closest` 61 | useEffect(() => { 62 | if ( 63 | RenderTarget.current() !== RenderTarget.preview || 64 | !ref.current || 65 | targetType !== "closest" 66 | ) { 67 | return 68 | } 69 | 70 | const closestSwitch = ref.current.closest( 71 | "[data-switch-id]" 72 | ) as HTMLElement | null 73 | 74 | if (closestSwitch) { 75 | const id = sanitizePropName(closestSwitch.dataset.switchId) 76 | if (targetId !== id) { 77 | setTargetId(id) 78 | } 79 | } 80 | }, [targetType, ref.current]) 81 | 82 | // execute automatic (delay) event triggers 83 | useEffect(() => { 84 | if (RenderTarget.current() !== RenderTarget.preview) { 85 | return 86 | } 87 | 88 | const timeouts = automaticEvents.map(({ handler }) => handler()) 89 | 90 | return () => { 91 | timeouts.forEach(clearTimeout) 92 | } 93 | }, [...automaticEventProps, targetId, props.id]) 94 | 95 | // attach key event handlers 96 | const keyEventProps = Object.keys(props) 97 | .filter((prop) => keyEventTriggerProps.indexOf(prop) !== -1) 98 | .map((prop) => props[prop]) 99 | 100 | useEffect(() => { 101 | if (RenderTarget.current() !== RenderTarget.preview) { 102 | return 103 | } 104 | 105 | keyEvents.forEach(({ hotkey, options, handler }) => 106 | hotkeys(hotkey, options, handler as KeyHandler) 107 | ) 108 | 109 | return () => { 110 | keyEvents.forEach(({ hotkey, handler }) => 111 | hotkeys.unbind(hotkey, handler as KeyHandler) 112 | ) 113 | } 114 | }, [...keyEventProps, targetId, props.id]) 115 | 116 | const child = children && React.Children.toArray(children)[0] 117 | let placeholder 118 | 119 | if (!child) { 120 | placeholder = createElement(placeholderState, { 121 | striped: true, 122 | }) 123 | } 124 | 125 | return ( 126 | 133 | {!child && RenderTarget.current() === RenderTarget.canvas 134 | ? placeholder 135 | : null} 136 | {children} 137 | 138 | ) 139 | } 140 | 141 | _SwitchToStateAction.displayName = "SwitchToStateAction" 142 | 143 | const __SwitchToStateAction = memo(_SwitchToStateAction) 144 | 145 | export const SwitchToStateAction = (props) => ( 146 | <__SwitchToStateAction {...props} /> 147 | ) 148 | 149 | const defaultProps = { 150 | width: 50, 151 | height: 50, 152 | targetType: "named", 153 | target: "sharedSwitch", 154 | isInteractive: true, 155 | } 156 | 157 | SwitchToStateAction.defaultProps = defaultProps 158 | 159 | // ------------------- Property Controls ------------------ 160 | 161 | addPropertyControls(SwitchToStateAction, { 162 | children: { 163 | type: ControlType.ComponentInstance, 164 | title: "Appearance", 165 | }, 166 | targetType: { 167 | title: "Switch", 168 | type: ControlType.Enum, 169 | options: ["closest", "named"], 170 | optionTitles: ["Closest", "Named"], 171 | defaultValue: defaultProps.targetType, 172 | }, 173 | target: { 174 | type: ControlType.String, 175 | title: " ", 176 | defaultValue: defaultProps.target, 177 | placeholder: "Name of Switch", 178 | hidden: (props) => props.targetType !== "named", 179 | }, 180 | 181 | ...eventTriggerPropertyControls, 182 | }) 183 | 184 | // ---------------------- Thumbnail ----------------------- 185 | 186 | function SwitchToStateActionThumbnail() { 187 | return ( 188 | 194 | 200 | 201 | ) 202 | } 203 | -------------------------------------------------------------------------------- /code/actions.ts: -------------------------------------------------------------------------------- 1 | export function handleTrigger( 2 | { getSwitchStateIndex, getAllSwitchStates, setSwitchStateIndex }, 3 | target, 4 | action, 5 | targetState 6 | ) { 7 | if (target === "") return 8 | 9 | const current = getSwitchStateIndex(target) 10 | const states = getAllSwitchStates(target) 11 | 12 | if (typeof current === "undefined") { 13 | console.warn( 14 | ` Tried to modify the state of the Switch "${target}" that either doesn't exist, or doesn't have any connected states.` 15 | ) 16 | return 17 | } 18 | 19 | if (action === "specific") { 20 | setSwitchStateIndex(target, targetState) 21 | } 22 | 23 | if (action === "specific-name") { 24 | const index = states.indexOf(targetState) 25 | if (index !== -1) { 26 | setSwitchStateIndex(target, index) 27 | } else { 28 | console.warn( 29 | ` Requested state name "${targetState}" wasn't found in the list of available states for this instance: ${states.join( 30 | ", " 31 | )}.\nMake sure the name matches the name of the state in the Layers panel exactly.` 32 | ) 33 | } 34 | } 35 | 36 | if (action === "next") { 37 | setSwitchStateIndex( 38 | target, 39 | current + 1 >= states.length ? 0 : current + 1 40 | ) 41 | } 42 | 43 | if (action === "previous") { 44 | setSwitchStateIndex( 45 | target, 46 | current - 1 < 0 ? states.length - 1 : current - 1 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /code/canvas.tsx: -------------------------------------------------------------------------------- 1 | // WARNING: this file is auto generated, any changes will be lost 2 | import { createDesignComponent, CanvasStore } from "framer" 3 | const canvas = CanvasStore.shared(); // CANVAS_DATA; 4 | -------------------------------------------------------------------------------- /code/controls.ts: -------------------------------------------------------------------------------- 1 | import { ControlType, PropertyControls, ControlDescription } from "framer" 2 | 3 | export const keyEventTriggerNames = ["onKeyDown", "onKeyUp"] 4 | export const automaticEventTriggerNames = ["afterDelay"] 5 | export const gestureEventNames = ["onDoubleTap", "onLongPress"] 6 | 7 | export const eventTriggerNames = [ 8 | ...automaticEventTriggerNames, 9 | "onTap", 10 | "onTapStart", 11 | "onTapCancel", 12 | "onHoverStart", 13 | "onHoverEnd", 14 | "onDragStart", 15 | "onDragEnd", 16 | ...gestureEventNames, 17 | ...keyEventTriggerNames, 18 | ] 19 | 20 | export const isCustomEvent = (name: string) => { 21 | return ( 22 | automaticEventTriggerNames.indexOf(name) !== -1 || 23 | gestureEventNames.indexOf(name) !== -1 24 | ) 25 | } 26 | 27 | export const eventTriggerTitles = { 28 | onTap: "On Tap", 29 | onTapStart: "Tap Start", 30 | onTapCancel: "Tap Cancel", 31 | afterDelay: "After Delay", 32 | onHoverStart: "Hover Start", 33 | onHoverEnd: "Hover End", 34 | onDragStart: "Drag Start", 35 | onDragEnd: "Drag End", 36 | onDoubleTap: "Double Tap", 37 | onLongPress: "Long Press", 38 | onKeyDown: "Key Down", 39 | onKeyUp: "Key Up", 40 | } 41 | 42 | // Auto-generated from: 43 | // 44 | // console.log( 45 | // JSON.stringify([ 46 | // ...eventTriggerNames, 47 | // ...eventTriggerNames.map(name => `${name}Action`), 48 | // ...eventTriggerNames.map(name => `${name}SpecificIndex`), 49 | // ...eventTriggerNames.map(name => `${name}SpecificName`), 50 | // ...keyEventTriggerNames.map(name => `${name}Key`), 51 | // ...automaticEventTriggerNames.map(name => `${name}Delay`), 52 | // "onLongPressDuration", 53 | // ]) 54 | // ) 55 | 56 | export const eventTriggerProps = [ 57 | "afterDelay", 58 | "onTap", 59 | "onTapStart", 60 | "onTapCancel", 61 | "onHoverStart", 62 | "onHoverEnd", 63 | "onDragStart", 64 | "onDragEnd", 65 | "onDoubleTap", 66 | "onLongPress", 67 | "onKeyDown", 68 | "onKeyUp", 69 | "afterDelayAction", 70 | "onTapAction", 71 | "onTapStartAction", 72 | "onTapCancelAction", 73 | "onHoverStartAction", 74 | "onHoverEndAction", 75 | "onDragStartAction", 76 | "onDragEndAction", 77 | "onDoubleTapAction", 78 | "onLongPressAction", 79 | "onKeyDownAction", 80 | "onKeyUpAction", 81 | "afterDelaySpecificIndex", 82 | "onTapSpecificIndex", 83 | "onTapStartSpecificIndex", 84 | "onTapCancelSpecificIndex", 85 | "onHoverStartSpecificIndex", 86 | "onHoverEndSpecificIndex", 87 | "onDragStartSpecificIndex", 88 | "onDragEndSpecificIndex", 89 | "onDoubleTapSpecificIndex", 90 | "onLongPressSpecificIndex", 91 | "onKeyDownSpecificIndex", 92 | "onKeyUpSpecificIndex", 93 | "afterDelaySpecificName", 94 | "onTapSpecificName", 95 | "onTapStartSpecificName", 96 | "onTapCancelSpecificName", 97 | "onHoverStartSpecificName", 98 | "onHoverEndSpecificName", 99 | "onDragStartSpecificName", 100 | "onDragEndSpecificName", 101 | "onDoubleTapSpecificName", 102 | "onLongPressSpecificName", 103 | "onKeyDownSpecificName", 104 | "onKeyUpSpecificName", 105 | "onKeyDownKey", 106 | "onKeyUpKey", 107 | "afterDelayDelay", 108 | "onLongPressDuration", 109 | ] 110 | 111 | // console.log( 112 | // JSON.stringify([ 113 | // ...keyEventTriggerNames.map(name => `${name}Action`), 114 | // ...keyEventTriggerNames.map(name => `${name}SpecificIndex`), 115 | // ...keyEventTriggerNames.map(name => `${name}SpecificName`), 116 | // ...keyEventTriggerNames.map(name => `${name}Key`), 117 | // ]) 118 | // ) 119 | 120 | export const keyEventTriggerProps = [ 121 | "onKeyDownAction", 122 | "onKeyUpAction", 123 | "onKeyDownSpecificIndex", 124 | "onKeyUpSpecificIndex", 125 | "onKeyDownSpecificName", 126 | "onKeyUpSpecificName", 127 | "onKeyDownKey", 128 | "onKeyUpKey", 129 | ] 130 | 131 | // console.log( 132 | // JSON.stringify([ 133 | // ...automaticEventTriggerNames.map(name => `${name}Action`), 134 | // ...automaticEventTriggerNames.map(name => `${name}SpecificIndex`), 135 | // ...automaticEventTriggerNames.map(name => `${name}SpecificName`), 136 | // ...automaticEventTriggerNames.map(name => `${name}Delay`), 137 | // ]) 138 | // ) 139 | 140 | export const automaticEventTriggerProps = [ 141 | "afterDelayAction", 142 | "afterDelaySpecificIndex", 143 | "afterDelaySpecificName", 144 | "afterDelayDelay", 145 | ] 146 | 147 | export const eventTriggerPropertyControls: PropertyControls = {} 148 | 149 | for (let trigger of eventTriggerNames) { 150 | eventTriggerPropertyControls[`${trigger}Action`] = { 151 | title: eventTriggerTitles[trigger] || trigger, 152 | type: ControlType.Enum, 153 | options: ["unset", "specific", "specific-name", "previous", "next"], 154 | optionTitles: [ 155 | "Not Set", 156 | "Specific State Index", 157 | "Specific State Name", 158 | "Previous State", 159 | "Next State", 160 | ], 161 | defaultValue: "unset", 162 | hidden: props => props.isInteractive === false, 163 | } 164 | 165 | eventTriggerPropertyControls[`${trigger}SpecificIndex`] = { 166 | title: "↳ State", 167 | type: ControlType.Number, 168 | displayStepper: true, 169 | defaultValue: 0, 170 | hidden: props => 171 | props.isInteractive === false || 172 | props[`${trigger}Action`] !== "specific", 173 | } 174 | 175 | eventTriggerPropertyControls[`${trigger}SpecificName`] = { 176 | title: "↳ State", 177 | type: ControlType.String, 178 | defaultValue: "", 179 | hidden: props => 180 | props.isInteractive === false || 181 | props[`${trigger}Action`] !== "specific-name", 182 | } 183 | 184 | if (keyEventTriggerNames.indexOf(trigger) !== -1) { 185 | eventTriggerPropertyControls[`${trigger}Key`] = { 186 | title: "↳ Key", 187 | type: ControlType.String, 188 | defaultValue: "", 189 | hidden: props => 190 | props.isInteractive === false || 191 | props[`${trigger}Action`] === "unset", 192 | } 193 | } 194 | 195 | if (automaticEventTriggerNames.indexOf(trigger) !== -1) { 196 | eventTriggerPropertyControls[`${trigger}Delay`] = { 197 | title: "↳ Delay", 198 | type: ControlType.Number, 199 | displayStepper: true, 200 | step: 0.1, 201 | defaultValue: 0, 202 | hidden: props => 203 | props.isInteractive === false || 204 | props[`${trigger}Action`] === "unset", 205 | } 206 | } 207 | 208 | if (trigger === "onLongPress") { 209 | eventTriggerPropertyControls["onLongPressDuration"] = { 210 | title: "↳ Duration", 211 | type: ControlType.Number, 212 | displayStepper: true, 213 | step: 0.1, 214 | defaultValue: 0.5, 215 | unit: "s", 216 | hidden: props => 217 | props.isInteractive === false || 218 | props[`${trigger}Action`] === "unset", 219 | } 220 | } 221 | } 222 | 223 | export const transitionPropertyControls: { 224 | [key: string]: Partial 225 | } = { 226 | transitionConfigType: { 227 | title: " ", 228 | type: ControlType.SegmentedEnum, 229 | options: ["default", "custom"], 230 | optionTitles: ["Default", "Custom"], 231 | }, 232 | 233 | transitionType: { 234 | title: "Type", 235 | type: ControlType.Enum, 236 | options: ["spring", "tween"], 237 | optionTitles: ["Spring", "Tween"], 238 | }, 239 | 240 | damping: { 241 | title: "Damping", 242 | type: ControlType.Number, 243 | min: 0, 244 | max: 50, 245 | }, 246 | 247 | mass: { 248 | title: "Mass", 249 | type: ControlType.Number, 250 | step: 0.1, 251 | min: 0, 252 | max: 5, 253 | }, 254 | 255 | stiffness: { 256 | title: "Stiffness", 257 | type: ControlType.Number, 258 | min: 0, 259 | max: 1000, 260 | }, 261 | 262 | duration: { 263 | title: "Duration", 264 | type: ControlType.Number, 265 | step: 0.1, 266 | min: 0, 267 | unit: "s", 268 | displayStepper: true, 269 | }, 270 | 271 | ease: { 272 | title: "Easing", 273 | type: ControlType.Enum, 274 | options: [ 275 | "custom", 276 | "linear", 277 | "easeIn", 278 | "easeOut", 279 | "easeInOut", 280 | "easeInSine", 281 | "easeOutSine", 282 | "easeInOutSine", 283 | "easeInQuad", 284 | "easeOutQuad", 285 | "easeInOutQuad", 286 | "easeInCubic", 287 | "easeOutCubic", 288 | "easeInOutCubic", 289 | "easeInQuart", 290 | "easeOutQuart", 291 | "easeInOutQuart", 292 | "easeInQuint", 293 | "easeOutQuint", 294 | "easeInOutQuint", 295 | "easeInExpo", 296 | "easeOutExpo", 297 | "easeInOutExpo", 298 | "circIn", 299 | "circOut", 300 | "circInOut", 301 | "backIn", 302 | "backOut", 303 | "backInOut", 304 | "anticipate", 305 | ], 306 | optionTitles: [ 307 | "Custom", 308 | "linear", 309 | "easeIn", 310 | "easeOut", 311 | "easeInOut", 312 | "easeInSine", 313 | "easeOutSine", 314 | "easeInOutSine", 315 | "easeInQuad", 316 | "easeOutQuad", 317 | "easeInOutQuad", 318 | "easeInCubic", 319 | "easeOutCubic", 320 | "easeInOutCubic", 321 | "easeInQuart", 322 | "easeOutQuart", 323 | "easeInOutQuart", 324 | "easeInQuint", 325 | "easeOutQuint", 326 | "easeInOutQuint", 327 | "easeInExpo", 328 | "easeOutExpo", 329 | "easeInOutExpo", 330 | "circIn", 331 | "circOut", 332 | "circInOut", 333 | "backIn", 334 | "backOut", 335 | "backInOut", 336 | "anticipate", 337 | ], 338 | }, 339 | 340 | customEase: { 341 | title: " ", 342 | type: ControlType.String, 343 | }, 344 | } 345 | -------------------------------------------------------------------------------- /code/hooks/useDoubleTap.ts: -------------------------------------------------------------------------------- 1 | // code from https://github.com/framer/snippets/blob/master/gestures/Double%20tap.md 2 | import { useRef } from "react" 3 | 4 | export function useDoubleTap( 5 | callback: (e: MouseEvent | TouchEvent) => void, 6 | timeout: number = 300 // ms 7 | ) { 8 | // Maintain the previous timestamp in a ref so it persists between renders 9 | const prevTapTimestamp = useRef(0) 10 | 11 | // Returns a function that will only fire the provided `callback` if it's 12 | // fired twice within the defined `timeout`. 13 | return (e: MouseEvent | TouchEvent) => { 14 | // performance.now() is a browser-specific function that returns the 15 | // current timestamp in milliseconds 16 | const tapTimestamp = performance.now() 17 | 18 | // We can get the time since the previous click by subtracting it from 19 | // the current timestamp. If that duration is than `timeout`, fire our callback 20 | if (tapTimestamp - prevTapTimestamp.current <= timeout) { 21 | callback(e) 22 | 23 | // Reset the previous timestamp to `0` to prevent users triggering 24 | // further double taps by tapping in rapid succession 25 | prevTapTimestamp.current = 0 26 | } else { 27 | // Otherwise update the previous timestamp to the latest timestamp. 28 | prevTapTimestamp.current = tapTimestamp 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /code/hooks/useLongPress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react" 2 | 3 | export function useLongPress( 4 | callback: (e: MouseEvent | TouchEvent) => void, 5 | duration: number = 500 6 | ) { 7 | // This will be a reference to our `setTimeout` counter, so we can clear it 8 | // if the user moves or releases their pointer. 9 | const timeout = useRef(null) 10 | 11 | // Create an event handler for mouse down and touch start events. We wrap the 12 | // handler in the `useCallback` hook and pass `callback` and `duration` as 13 | // dependencies so it only creates a new callback if either of these changes. 14 | const onPressStart = useCallback( 15 | (event: MouseEvent | TouchEvent) => { 16 | // Start a timeout that will fire the supplied callback after the 17 | // provided `duration` 18 | timeout.current = setTimeout(() => callback(event), duration) 19 | }, 20 | [callback, duration] 21 | ) 22 | 23 | // This function, when called, will cancel the timeout and thus end the 24 | // gesture. We provide an empty dependency array as we never want this 25 | // function to change for the lifecycle of the component. 26 | const cancelTimeout = useCallback(() => clearTimeout(timeout.current), []) 27 | 28 | return { 29 | // Initiate the gesture on mouse down or touch start 30 | onMouseDown: onPressStart, 31 | onTouchStart: onPressStart, 32 | 33 | // Cancel the gesture if the pointer is moved. This is quite an aggressive 34 | // approach so you might want to make an alternative function here that 35 | // detects how far the pointer has moved from its origin using `e.pageX` 36 | // for `MouseEvent`s or `e.touches[0].pageX` for `TouchEvent`s. 37 | onMouseMove: cancelTimeout, 38 | onTouchMove: cancelTimeout, 39 | 40 | // Cancel the timeout when the pointer session is ended. 41 | onMouseUp: cancelTimeout, 42 | onTouchEnd: cancelTimeout, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /code/index.ts: -------------------------------------------------------------------------------- 1 | export { useSwitch } from "./store/globalStore" 2 | export { Switch } from "./Switch" 3 | export { SwitchToStateAction } from "./SwitchToStateAction" 4 | -------------------------------------------------------------------------------- /code/placeholderState.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { CSSProperties } from "react" 3 | import { Stack, Color, Frame } from "framer" 4 | 5 | interface Props { 6 | title?: string 7 | label?: string 8 | error?: boolean 9 | striped?: boolean 10 | } 11 | 12 | const textStyles: CSSProperties = { 13 | maxWidth: "100%", 14 | overflow: "hidden", 15 | textOverflow: "ellipsis", 16 | textAlign: "center", 17 | wordWrap: "normal", 18 | } 19 | 20 | const colors = { 21 | error: "#FF3333", 22 | placeholder: "#0099FF", 23 | placeholderLight: "rgba(0, 153, 255, 0.25)", 24 | } 25 | 26 | const stripedStyles = { 27 | backgroundImage: `linear-gradient(135deg, ${colors.placeholderLight} 5.56%, transparent 5.56%, transparent 50%, ${colors.placeholderLight} 50%, ${colors.placeholderLight} 55.56%, transparent 55.56%, transparent 100%)`, 28 | backgroundSize: `12.73px 12.73px`, 29 | } 30 | 31 | export function placeholderState({ 32 | title, 33 | label, 34 | error, 35 | striped = false, 36 | }: Props) { 37 | const color = Color(error ? colors.error : colors.placeholder) 38 | 39 | return ( 40 | 55 | {title && ( 56 |
68 | {title} 69 |
70 | )} 71 | {label && ( 72 |

80 | {label} 81 |

82 | )} 83 |
84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /code/store/globalStore.ts: -------------------------------------------------------------------------------- 1 | import { setGlobal, getGlobal, useGlobal } from "reactn" 2 | 3 | setGlobal({ 4 | __switch: { __registry: {} }, 5 | }) 6 | 7 | type SwitchControls = () => { 8 | getSwitches: () => string[] 9 | getSwitchStateIndex: (identifier: string) => number 10 | getAllSwitchStates: (identifier: string) => string[] 11 | setSwitchStateIndex: (identifier: string, state: number) => void 12 | registerSwitchStates: (identifier: string, states: string[]) => void 13 | } 14 | 15 | type PrevNextSwitchOptions = { 16 | wrapAround?: boolean 17 | } 18 | 19 | export const useSwitch: SwitchControls = () => { 20 | const [store, setStore] = useGlobal("__switch") 21 | const prevNextSwitchOptions = { 22 | wrapAround: true, 23 | } 24 | 25 | const getSwitchStateIndex = (identifier: string) => { 26 | return store[identifier] 27 | } 28 | 29 | const setSwitchStateIndex = (identifier: string, state: number) => { 30 | store[identifier] = state 31 | setStore(store) 32 | } 33 | 34 | const setSwitchState = (identifier: string, state: string | number) => { 35 | const states = getAllSwitchStates(identifier) 36 | const index = typeof state === "number" ? state : states.indexOf(state) 37 | 38 | if (index === -1) { 39 | console.warn( 40 | ` Requested state name "${state}" wasn't found in the list of available states for this instance: ${states.join( 41 | ", " 42 | )}.\nMake sure the name matches the name of the state in the Layers panel exactly.` 43 | ) 44 | return 45 | } 46 | 47 | if (typeof states[index] === "undefined") { 48 | console.warn( 49 | ` Requested state index "${index}" isn't valid. Number of states for this instance: ${states.length}.` 50 | ) 51 | return 52 | } 53 | 54 | setSwitchStateIndex(identifier, index) 55 | } 56 | 57 | const setNextSwitchStateIndex = ( 58 | identifier: string, 59 | options: PrevNextSwitchOptions = {} 60 | ) => { 61 | const { wrapAround } = { ...prevNextSwitchOptions, ...options } 62 | const current = getSwitchStateIndex(identifier) 63 | const states = getAllSwitchStates(identifier) 64 | 65 | setSwitchStateIndex( 66 | identifier, 67 | current + 1 >= states.length 68 | ? wrapAround 69 | ? 0 70 | : states.length - 1 71 | : current + 1 72 | ) 73 | } 74 | 75 | const setPreviousSwitchStateIndex = ( 76 | identifier: string, 77 | options: PrevNextSwitchOptions = {} 78 | ) => { 79 | const { wrapAround } = { ...prevNextSwitchOptions, ...options } 80 | const current = getSwitchStateIndex(identifier) 81 | const states = getAllSwitchStates(identifier) 82 | 83 | setSwitchStateIndex( 84 | identifier, 85 | current - 1 < 0 ? (wrapAround ? states.length - 1 : 0) : current - 1 86 | ) 87 | } 88 | 89 | const registerSwitchStates = (identifier: string, states: string[]) => { 90 | store.__registry = { 91 | ...store.__registry, 92 | [identifier]: states, 93 | } 94 | setStore(store) 95 | } 96 | 97 | const getAllSwitchStates = (identifier: string) => { 98 | return store.__registry && store.__registry[identifier] 99 | ? store.__registry[identifier] 100 | : [] 101 | } 102 | 103 | const getSwitches = () => { 104 | return Object.keys(store.__registry || {}) 105 | } 106 | 107 | return { 108 | getSwitches, 109 | getSwitchStateIndex, 110 | getAllSwitchStates, 111 | setSwitchState, 112 | setSwitchStateIndex, 113 | setNextSwitchStateIndex, 114 | setNextSwitchState: setNextSwitchStateIndex, 115 | setPreviousSwitchStateIndex, 116 | setPreviousSwitchState: setPreviousSwitchStateIndex, 117 | registerSwitchStates, 118 | } 119 | } 120 | 121 | export const actions = { 122 | getSwitchStateIndex: identifier => { 123 | const store = getGlobal().__switch 124 | return store[identifier] 125 | }, 126 | setSwitchStateIndex: (identifier, state) => { 127 | const store = getGlobal().__switch 128 | store[identifier] = state 129 | setGlobal({ ...getGlobal(), __switch: store }) 130 | }, 131 | registerSwitchStates: (identifier: string, states: string[]) => { 132 | const store = getGlobal().__switch 133 | store.__registry = { 134 | ...store.__registry, 135 | [identifier]: states, 136 | } 137 | setGlobal({ ...getGlobal(), __switch: store }) 138 | }, 139 | getAllSwitchStates: (identifier: string) => { 140 | const store = getGlobal().__switch 141 | return store.__registry && store.__registry[identifier] 142 | ? store.__registry[identifier] 143 | : [] 144 | }, 145 | } 146 | -------------------------------------------------------------------------------- /code/store/keyStore.ts: -------------------------------------------------------------------------------- 1 | const caches = {} 2 | 3 | export const getCache = id => { 4 | if (!caches[id]) { 5 | caches[id] = {} 6 | } 7 | 8 | const cache = caches[id] 9 | 10 | return { 11 | getSourceKey: (targetKey, sourceKey) => 12 | getSourceKey(cache, targetKey, sourceKey), 13 | } 14 | } 15 | 16 | const getSourceKey = (cache, targetKey, sourceKey) => { 17 | const key = 18 | targetKey in cache 19 | ? resolveKey(targetKey, cache) 20 | : resolveKey(sourceKey, cache) 21 | cache[targetKey] = key 22 | return key 23 | } 24 | 25 | const resolveKey = (targetKey, cache) => { 26 | const checkedKeys = {} 27 | let key = targetKey 28 | while (cache[key] && !(cache[key] in checkedKeys)) { 29 | checkedKeys[key] = true 30 | key = cache[key] 31 | } 32 | return key 33 | } 34 | -------------------------------------------------------------------------------- /code/thumbnailStyles.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | primary: "#ED7BB6", 3 | background: "rgba(237, 123, 182, 0.25)", 4 | } 5 | -------------------------------------------------------------------------------- /code/transitions.ts: -------------------------------------------------------------------------------- 1 | import { prefixPropName } from "./utils/propNameHelpers" 2 | import { getOpacity } from "./utils/styleParsing" 3 | 4 | const EASINGS = { 5 | linear: "linear", 6 | easeIn: "easeIn", 7 | easeOut: "easeOut", 8 | easeInOut: "easeInOut", 9 | easeInSine: [0.47, 0, 0.745, 0.715], 10 | easeOutSine: [0.39, 0.575, 0.565, 1], 11 | easeInOutSine: [0.445, 0.05, 0.55, 0.95], 12 | easeInQuad: [0.55, 0.085, 0.68, 0.53], 13 | easeOutQuad: [0.25, 0.46, 0.45, 0.94], 14 | easeInOutQuad: [0.455, 0.03, 0.515, 0.955], 15 | easeInCubic: [0.55, 0.055, 0.675, 0.19], 16 | easeOutCubic: [0.215, 0.61, 0.355, 1], 17 | easeInOutCubic: [0.645, 0.045, 0.355, 1], 18 | easeInQuart: [0.895, 0.03, 0.685, 0.22], 19 | easeOutQuart: [0.165, 0.84, 0.44, 1], 20 | easeInOutQuart: [0.77, 0, 0.175, 1], 21 | easeInQuint: [0.755, 0.05, 0.855, 0.06], 22 | easeOutQuint: [0.23, 1, 0.32, 1], 23 | easeInOutQuint: [0.86, 0, 0.07, 1], 24 | easeInExpo: [0.95, 0.05, 0.795, 0.035], 25 | easeOutExpo: [0.19, 1, 0.22, 1], 26 | easeInOutExpo: [1, 0, 0, 1], 27 | circIn: "circIn", 28 | circOut: "circOut", 29 | circInOut: "circInOut", 30 | backIn: "backIn", 31 | backOut: "backOut", 32 | backInOut: "backInOut", 33 | anticipate: "anticipate", 34 | } 35 | 36 | export const transitionOptionsFromProps = (props, prefix = null) => { 37 | const getProp = (n) => props[prefixPropName(n, prefix)] 38 | const type = getProp("transitionType") 39 | 40 | if (type === "tween") { 41 | return { 42 | type: "tween", 43 | duration: getProp("duration"), 44 | ease: 45 | getProp("ease") === "custom" 46 | ? getProp("customEase").split(/,\s+/).map(parseFloat) 47 | : EASINGS[getProp("ease")], 48 | } 49 | } 50 | 51 | if (type === "spring") { 52 | return { 53 | type: "spring", 54 | damping: getProp("damping"), 55 | mass: getProp("mass"), 56 | stiffness: getProp("stiffness"), 57 | velocity: 0, 58 | } 59 | } 60 | 61 | return DEFAULT_SPRING 62 | } 63 | 64 | export const DEFAULT_SPRING = { 65 | type: "spring", 66 | damping: 45, 67 | mass: 1, 68 | stiffness: 500, 69 | } 70 | export const DEFAULT_TWEEN = { type: "tween", ease: "easeInOut", duration: 0.3 } 71 | 72 | export const TRANSITIONS = { 73 | instant: (childProps, containerProps) => ({ 74 | initial: { opacity: 0 }, 75 | animate: { opacity: 1 }, 76 | exit: { opacity: 0 }, 77 | transition: { type: "tween", ease: "linear", duration: 0 }, 78 | }), 79 | dissolve: (childProps, { transitionConfigType, ...containerProps }) => ({ 80 | initial: { opacity: 0 }, 81 | animate: { opacity: 1 }, 82 | exit: { opacity: [1, 1, 0] }, 83 | transition: 84 | transitionConfigType === "default" 85 | ? DEFAULT_TWEEN 86 | : transitionOptionsFromProps(containerProps), 87 | }), 88 | zoom: ( 89 | childProps, 90 | { transitionConfigType, ...containerProps }, 91 | direction 92 | ) => ({ 93 | variants: { 94 | initial: 95 | direction === 1 96 | ? { opacity: 0, scale: 1.15, zIndex: 0 } 97 | : { opacity: 0, scale: 0.85, zIndex: 1 }, 98 | enter: { opacity: [1, 1], scale: 1 }, 99 | exit: (direction) => 100 | direction === 1 101 | ? { scale: 0.85, opacity: 0, zIndex: 0 } 102 | : { opacity: 0, scale: 1.15, zIndex: 1 }, 103 | }, 104 | initial: "initial", 105 | animate: "enter", 106 | exit: "exit", 107 | transition: 108 | transitionConfigType === "default" 109 | ? DEFAULT_SPRING 110 | : transitionOptionsFromProps(containerProps), 111 | }), 112 | zoomout: (childProps, { transitionConfigType, ...containerProps }) => ({ 113 | initial: { opacity: 0, scale: 1.15 }, 114 | animate: { opacity: 1, scale: 1 }, 115 | exit: { opacity: 0, scale: 0.85 }, 116 | transition: 117 | transitionConfigType === "default" 118 | ? DEFAULT_SPRING 119 | : transitionOptionsFromProps(containerProps), 120 | }), 121 | zoomin: (childProps, { transitionConfigType, ...containerProps }) => ({ 122 | initial: { opacity: 0, scale: 0.85 }, 123 | animate: { opacity: 1, scale: 1 }, 124 | exit: { opacity: 0, scale: 1.15 }, 125 | transition: 126 | transitionConfigType === "default" 127 | ? DEFAULT_SPRING 128 | : transitionOptionsFromProps(containerProps), 129 | }), 130 | swapup: (childProps, { transitionConfigType, ...containerProps }) => ({ 131 | initial: { y: containerProps.height }, 132 | animate: { y: 0 }, 133 | exit: { y: containerProps.height }, 134 | transition: 135 | transitionConfigType === "default" 136 | ? DEFAULT_SPRING 137 | : transitionOptionsFromProps(containerProps), 138 | }), 139 | swapdown: (childProps, { transitionConfigType, ...containerProps }) => ({ 140 | initial: { y: -containerProps.height }, 141 | animate: { y: 0 }, 142 | exit: { y: -containerProps.height }, 143 | transition: 144 | transitionConfigType === "default" 145 | ? DEFAULT_SPRING 146 | : transitionOptionsFromProps(containerProps), 147 | }), 148 | swapleft: (childProps, { transitionConfigType, ...containerProps }) => ({ 149 | initial: { x: -containerProps.width }, 150 | animate: { x: 0 }, 151 | exit: { x: -containerProps.width }, 152 | transition: 153 | transitionConfigType === "default" 154 | ? DEFAULT_SPRING 155 | : transitionOptionsFromProps(containerProps), 156 | }), 157 | swapright: (childProps, { transitionConfigType, ...containerProps }) => ({ 158 | initial: { x: containerProps.width }, 159 | animate: { x: 0 }, 160 | exit: { x: containerProps.width }, 161 | transition: 162 | transitionConfigType === "default" 163 | ? DEFAULT_SPRING 164 | : transitionOptionsFromProps(containerProps), 165 | }), 166 | slidehorizontal: ( 167 | childProps, 168 | { transitionConfigType, ...containerProps }, 169 | direction 170 | ) => ({ 171 | variants: { 172 | initial: 173 | direction === 1 174 | ? { x: containerProps.width, zIndex: 1 } 175 | : { x: 0, zIndex: 0 }, 176 | enter: { x: 0, opacity: 1 }, 177 | exit: (direction) => { 178 | return direction === -1 179 | ? { x: containerProps.width, zIndex: 1 } 180 | : { opacity: [1, 1, 0], zIndex: 0 } 181 | }, 182 | }, 183 | initial: "initial", 184 | animate: "enter", 185 | exit: "exit", 186 | transition: 187 | transitionConfigType === "default" 188 | ? DEFAULT_SPRING 189 | : transitionOptionsFromProps(containerProps), 190 | }), 191 | slidevertical: ( 192 | childProps, 193 | { transitionConfigType, ...containerProps }, 194 | direction 195 | ) => ({ 196 | variants: { 197 | initial: 198 | direction === 1 199 | ? { y: containerProps.height, zIndex: 1 } 200 | : { y: 0, zIndex: 0 }, 201 | enter: { y: 0, opacity: 1 }, 202 | exit: (direction) => { 203 | return direction === -1 204 | ? { y: containerProps.height, zIndex: 1 } 205 | : { opacity: [1, 1, 0], zIndex: 0 } 206 | }, 207 | }, 208 | initial: "initial", 209 | animate: "enter", 210 | exit: "exit", 211 | transition: 212 | transitionConfigType === "default" 213 | ? DEFAULT_SPRING 214 | : transitionOptionsFromProps(containerProps), 215 | }), 216 | slideup: (childProps, { transitionConfigType, ...containerProps }) => ({ 217 | initial: { y: containerProps.height }, 218 | animate: { y: 0 }, 219 | exit: { opacity: [1, 1, 0] }, 220 | transition: 221 | transitionConfigType === "default" 222 | ? DEFAULT_SPRING 223 | : transitionOptionsFromProps(containerProps), 224 | }), 225 | slidedown: (childProps, { transitionConfigType, ...containerProps }) => ({ 226 | initial: { y: -containerProps.height }, 227 | animate: { y: 0 }, 228 | exit: { opacity: [1, 1, 0] }, 229 | transition: 230 | transitionConfigType === "default" 231 | ? DEFAULT_SPRING 232 | : transitionOptionsFromProps(containerProps), 233 | }), 234 | slideleft: (childProps, { transitionConfigType, ...containerProps }) => ({ 235 | initial: { x: containerProps.width }, 236 | animate: { x: 0 }, 237 | exit: { opacity: [1, 1, 0] }, 238 | transition: 239 | transitionConfigType === "default" 240 | ? DEFAULT_SPRING 241 | : transitionOptionsFromProps(containerProps), 242 | }), 243 | slideright: (childProps, { transitionConfigType, ...containerProps }) => ({ 244 | initial: { x: -containerProps.width }, 245 | animate: { x: 0 }, 246 | exit: { opacity: [1, 1, 0] }, 247 | transition: 248 | transitionConfigType === "default" 249 | ? DEFAULT_SPRING 250 | : transitionOptionsFromProps(containerProps), 251 | }), 252 | pushhorizontal: ( 253 | childProps, 254 | { transitionConfigType, ...containerProps }, 255 | direction 256 | ) => ({ 257 | variants: { 258 | initial: 259 | direction === 1 260 | ? { x: containerProps.width } 261 | : { x: -containerProps.width }, 262 | enter: { x: 0 }, 263 | exit: (direction) => 264 | direction === -1 265 | ? { x: containerProps.width, opacity: [1, 1, 1, 0] } 266 | : { x: -containerProps.width, opacity: [1, 1, 1, 0] }, 267 | }, 268 | initial: "initial", 269 | animate: "enter", 270 | exit: "exit", 271 | transition: 272 | transitionConfigType === "default" 273 | ? DEFAULT_SPRING 274 | : transitionOptionsFromProps(containerProps), 275 | }), 276 | pushvertical: ( 277 | childProps, 278 | { transitionConfigType, ...containerProps }, 279 | direction 280 | ) => ({ 281 | variants: { 282 | initial: 283 | direction === 1 284 | ? { y: containerProps.height } 285 | : { y: -containerProps.height }, 286 | enter: { y: 0 }, 287 | exit: (direction) => 288 | direction === -1 289 | ? { y: containerProps.height, opacity: [1, 1, 1, 0] } 290 | : { y: -containerProps.height, opacity: [1, 1, 1, 0] }, 291 | }, 292 | initial: "initial", 293 | animate: "enter", 294 | exit: "exit", 295 | transition: 296 | transitionConfigType === "default" 297 | ? DEFAULT_SPRING 298 | : transitionOptionsFromProps(containerProps), 299 | }), 300 | pushup: (childProps, { transitionConfigType, ...containerProps }) => ({ 301 | initial: { y: containerProps.height }, 302 | animate: { y: 0 }, 303 | exit: { y: -containerProps.height, opacity: [1, 1, 1, 0] }, 304 | transition: 305 | transitionConfigType === "default" 306 | ? DEFAULT_SPRING 307 | : transitionOptionsFromProps(containerProps), 308 | }), 309 | pushdown: (childProps, { transitionConfigType, ...containerProps }) => ({ 310 | initial: { y: -containerProps.height }, 311 | animate: { y: 0 }, 312 | exit: { y: containerProps.height, opacity: [1, 1, 1, 0] }, 313 | transition: 314 | transitionConfigType === "default" 315 | ? DEFAULT_SPRING 316 | : transitionOptionsFromProps(containerProps), 317 | }), 318 | pushleft: (childProps, { transitionConfigType, ...containerProps }) => ({ 319 | initial: { x: containerProps.width }, 320 | animate: { x: 0 }, 321 | exit: { x: -containerProps.height, opacity: [1, 1, 1, 0] }, 322 | transition: 323 | transitionConfigType === "default" 324 | ? DEFAULT_SPRING 325 | : transitionOptionsFromProps(containerProps), 326 | }), 327 | pushright: (childProps, { transitionConfigType, ...containerProps }) => ({ 328 | initial: { x: -containerProps.width }, 329 | animate: { x: 0 }, 330 | exit: { x: containerProps.height, opacity: [1, 1, 1, 0] }, 331 | transition: 332 | transitionConfigType === "default" 333 | ? DEFAULT_SPRING 334 | : transitionOptionsFromProps(containerProps), 335 | }), 336 | morph: ( 337 | childProps, 338 | { 339 | transitionConfigType, 340 | staggerChildren, 341 | delayChildren, 342 | ...containerProps 343 | } 344 | ) => ({ 345 | transition: { 346 | ...(transitionConfigType === "default" 347 | ? DEFAULT_SPRING 348 | : transitionOptionsFromProps(containerProps)), 349 | staggerChildren, 350 | delayChildren, 351 | }, 352 | }), 353 | crossdissolve: ( 354 | childProps, 355 | { 356 | transitionConfigType, 357 | staggerChildren, 358 | delayChildren, 359 | ...containerProps 360 | }, 361 | { direction } 362 | ) => { 363 | const options = 364 | transitionConfigType === "default" 365 | ? DEFAULT_SPRING 366 | : transitionOptionsFromProps(containerProps) 367 | 368 | // When using a tween transition, we intentionally give opacity a different 369 | // curve, which aims to maximize the time that both the appearing and disappearing 370 | // elements stay at a higher opacity value. This works around the issue when in the 371 | // middle of the cross-dissolve, both elements have an opacity of 50% for a combined 372 | // max alpha value of 0.75. The observable effect is that of the element dimming/blinking 373 | // out of existence and then back in, rather than smoothly cross-fading between states. 374 | // A true cross-dissolve would have us paint the blended value of the front/back layer, 375 | // preserving the alpha of the target, but hopefully this is a good approximation. 376 | const opacity = { 377 | type: "tween", 378 | // using a blend of easeIn/easeOut means that in the middle 379 | // of the transition, both elements will be at >50% opacity 380 | ease: 381 | direction === "cross-dissolve-enter" 382 | ? EASINGS.easeOutCubic 383 | : EASINGS.easeInCubic, 384 | duration: options["duration"], 385 | } 386 | 387 | return { 388 | transition: { 389 | opacity, 390 | default: options, 391 | staggerChildren, 392 | delayChildren, 393 | }, 394 | } 395 | }, 396 | enterdissolve: ( 397 | childProps, 398 | { enterTransitionConfigType, ...containerProps }, 399 | { 400 | transitionKey: tkey, 401 | useAbsolutePositioning, 402 | sourceRect: rect, 403 | ...transitionOptions 404 | } 405 | ) => { 406 | if (!useAbsolutePositioning) { 407 | return TRANSITIONS.growdissolve( 408 | childProps, 409 | { enterTransitionConfigType, ...containerProps }, 410 | { 411 | transitionKey: tkey, 412 | useAbsolutePositioning, 413 | sourceRect: rect, 414 | ...transitionOptions, 415 | } 416 | ) 417 | } 418 | 419 | return { 420 | variants: { 421 | [`__switch_initial_${tkey}`]: { opacity: 0, display: "block" }, 422 | [`__switch_next_${tkey}`]: { 423 | opacity: [0, getOpacity(childProps.style || {})], 424 | display: "block", 425 | width: [rect.width, rect.width], 426 | height: [rect.height, rect.height], 427 | }, 428 | }, 429 | initial: `__switch_initial_${tkey}`, 430 | animate: `__switch_next_${tkey}`, 431 | transition: 432 | enterTransitionConfigType === "default" 433 | ? DEFAULT_TWEEN 434 | : transitionOptionsFromProps(containerProps, "enter"), 435 | } 436 | }, 437 | exitdissolve: ( 438 | childProps, 439 | { exitTransitionConfigType, ...containerProps }, 440 | { transitionKey: tkey, useAbsolutePositioning, ...transitionOptions } 441 | ) => { 442 | if (!useAbsolutePositioning) { 443 | return TRANSITIONS.shrinkdissolve( 444 | childProps, 445 | { exitTransitionConfigType, ...containerProps }, 446 | { 447 | transitionKey: tkey, 448 | useAbsolutePositioning, 449 | ...transitionOptions, 450 | } 451 | ) 452 | } 453 | 454 | return { 455 | variants: { 456 | [`__switch_initial_${tkey}`]: { opacity: 1 }, 457 | [`__switch_next_${tkey}`]: { 458 | opacity: [getOpacity(childProps.style || {}), 0], 459 | transitionEnd: { display: "none" }, 460 | }, 461 | }, 462 | initial: `__switch_initial_${tkey}`, 463 | animate: `__switch_next_${tkey}`, 464 | transition: 465 | exitTransitionConfigType === "default" 466 | ? DEFAULT_TWEEN 467 | : transitionOptionsFromProps(containerProps, "exit"), 468 | } 469 | }, 470 | growdissolve: ( 471 | childProps, 472 | { enterTransitionConfigType, ...containerProps }, 473 | { transitionKey: tkey, sourceRect: rect } 474 | ) => ({ 475 | variants: { 476 | [`__switch_initial_${tkey}`]: { 477 | opacity: 0, 478 | width: 0, 479 | height: 0, 480 | display: "block", 481 | }, 482 | [`__switch_next_${tkey}`]: { 483 | opacity: getOpacity(childProps.style || {}), 484 | width: [0, rect.width], 485 | height: [0, rect.height], 486 | display: "block", 487 | }, 488 | }, 489 | initial: `__switch_initial_${tkey}`, 490 | animate: `__switch_next_${tkey}`, 491 | transition: 492 | enterTransitionConfigType === "default" 493 | ? DEFAULT_TWEEN 494 | : transitionOptionsFromProps(containerProps, "enter"), 495 | }), 496 | shrinkdissolve: ( 497 | childProps, 498 | { exitTransitionConfigType, ...containerProps }, 499 | { transitionKey: tkey, sourceRect: rect } 500 | ) => ({ 501 | variants: { 502 | [`__switch_next_${tkey}`]: { opacity: 0, width: 0, height: 0 }, 503 | }, 504 | animate: `__switch_next_${tkey}`, 505 | transition: 506 | exitTransitionConfigType === "default" 507 | ? DEFAULT_TWEEN 508 | : transitionOptionsFromProps(containerProps, "exit"), 509 | }), 510 | enterInstant: (childProps, containerProps, { transitionKey: tkey }) => ({ 511 | variants: { 512 | [`__switch_initial_${tkey}`]: { 513 | opacity: 0, 514 | display: "block", 515 | }, 516 | [`__switch_next_${tkey}`]: { 517 | opacity: getOpacity(childProps.style || {}), 518 | display: "block", 519 | }, 520 | }, 521 | initial: `__switch_initial_${tkey}`, 522 | animate: `__switch_next_${tkey}`, 523 | transition: { type: "tween", ease: "linear", duration: 0 }, 524 | }), 525 | exitInstant: (childProps, containerProps, { transitionKey: tkey }) => ({ 526 | variants: { 527 | [`__switch_initial_${tkey}`]: { 528 | opacity: getOpacity(childProps.style || {}), 529 | }, 530 | [`__switch_next_${tkey}`]: { 531 | opacity: 0, 532 | transitionEnd: { display: "none" }, 533 | }, 534 | }, 535 | initial: `__switch_initial_${tkey}`, 536 | animate: `__switch_next_${tkey}`, 537 | transition: { type: "tween", ease: "linear", duration: 0 }, 538 | }), 539 | } 540 | -------------------------------------------------------------------------------- /code/useWhyDidYouUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react" 2 | export function useWhyDidYouUpdate(name, props) { 3 | // Get a mutable ref object where we can store props ... 4 | // ... for comparison next time this hook runs. 5 | const previousProps = useRef() 6 | 7 | useEffect(() => { 8 | if (previousProps.current) { 9 | // Get all keys from previous and current props 10 | const allKeys = Object.keys({ ...previousProps.current, ...props }) 11 | // Use this object to keep track of changed props 12 | const changesObj = {} 13 | // Iterate through keys 14 | allKeys.forEach(key => { 15 | // If previous is different from current 16 | if (previousProps.current[key] !== props[key]) { 17 | // Add to changesObj 18 | changesObj[key] = { 19 | from: previousProps.current[key], 20 | to: props[key], 21 | } 22 | } 23 | }) 24 | 25 | // If changesObj not empty then output to console 26 | if (Object.keys(changesObj).length) { 27 | console.log("[why-did-you-update]", name, changesObj) 28 | } 29 | } 30 | 31 | // Finally update previousProps with current props for next hook call 32 | previousProps.current = props 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /code/utils/addAnimatableWrapperToNodeIfNeeded.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cloneElement } from "react" 3 | import { Frame } from "framer" 4 | import { getNodeType, getNodeName, hasOverrides } from "./nodeHelpers" 5 | 6 | const AnimatableWrapper = ({ children, name, ...props }) => ( 7 | 8 | {children} 9 | 10 | ) 11 | 12 | AnimatableWrapper.displayName = "AnimatableWrapper" 13 | 14 | export const addAnimatableWrapperToNodeIfNeeded = ( 15 | node, 16 | propOverrides = {}, 17 | children = [] 18 | ) => { 19 | const nodeType = getNodeType(node) 20 | const needsWrapper = 21 | ["Frame", "VectorWrapper", "AnimatableWrapper", "Stack"].indexOf( 22 | nodeType 23 | ) === -1 24 | 25 | return needsWrapper ? ( 26 | 27 | {cloneElement(node, propOverrides, ...children)} 28 | 29 | ) : ( 30 | node 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /code/utils/calculateRect.ts: -------------------------------------------------------------------------------- 1 | import { Animatable, Rect } from "framer" 2 | 3 | function isString(t: any): t is string { 4 | return typeof t === "string" 5 | } 6 | 7 | export function isFiniteNumber(value: any): value is number { 8 | return typeof value === "number" && isFinite(value) 9 | } 10 | 11 | function containsInvalidStringValues(props): boolean { 12 | const { left, right, top, bottom, center } = props 13 | // We never allow right or bottom to be strings 14 | if ([right, bottom].some(isString)) { 15 | return true 16 | } 17 | // Only allow a string for left, if it is part of the centering logic 18 | if (isString(left) && (!center || center === "y")) { 19 | // We are not centering or only centering in the opposite direction 20 | return true 21 | } 22 | // Only allow a string for top, if it is part of the centering logic 23 | if (isString(top) && (!center || center === "x")) { 24 | // We are not centering or only centering in the opposite direction 25 | return true 26 | } 27 | return false 28 | } 29 | 30 | export function constraintsEnabled(props) { 31 | const { _constraints } = props 32 | if (!_constraints) { 33 | return false 34 | } 35 | 36 | if (containsInvalidStringValues(props)) { 37 | return false 38 | } 39 | 40 | return _constraints.enabled 41 | } 42 | 43 | function sizeFromFiniteNumberProps(props) { 44 | const { size } = props 45 | let { width, height } = props 46 | if (isFiniteNumber(size)) { 47 | if (width === undefined) { 48 | width = size 49 | } 50 | if (height === undefined) { 51 | height = size 52 | } 53 | } 54 | if (isFiniteNumber(width) && isFiniteNumber(height)) { 55 | return { 56 | width: width, 57 | height: height, 58 | } 59 | } 60 | return null 61 | } 62 | 63 | function rectFromFiniteNumberProps(props) { 64 | const size = sizeFromFiniteNumberProps(props) 65 | if (size === null) { 66 | return null 67 | } 68 | const { left, top } = props 69 | if (isFiniteNumber(left) && isFiniteNumber(top)) { 70 | return { 71 | x: left, 72 | y: top, 73 | ...size, 74 | } 75 | } 76 | return null 77 | } 78 | 79 | export function pixelAligned(rect: Rect): Rect { 80 | const x = Math.round(rect.x) 81 | const y = Math.round(rect.y) 82 | const rectMaxX = Math.round(rect.x + rect.width) 83 | const rectMaxY = Math.round(rect.y + rect.height) 84 | const width = Math.max(rectMaxX - x, 0) 85 | const height = Math.max(rectMaxY - y, 0) 86 | return { x, y, width, height } 87 | } 88 | 89 | export enum ParentSizeState { 90 | Unknown, // There is no known ParentSize 91 | Disabled, // ParentSize should not be used for layout 92 | } 93 | 94 | export function deprecatedParentSize(parentSize) { 95 | if ( 96 | parentSize === ParentSizeState.Unknown || 97 | parentSize === ParentSizeState.Disabled 98 | ) { 99 | return null 100 | } 101 | return parentSize 102 | } 103 | 104 | export namespace ConstraintValues { 105 | // Returns a parent-relative rect given concrete ConstraintValues. 106 | export const toRect = ( 107 | values, 108 | parentSize, 109 | autoSize, 110 | pixelAlign, 111 | // This argument is actually never used, because fractional sizes are always calculated by it's parent to static sizes 112 | freeSpace = null 113 | ) => { 114 | let x = values.left || 0 115 | let y = values.top || 0 116 | let width: number | null = null 117 | let height: number | null = null 118 | 119 | const parentWidth = parentSize 120 | ? Animatable.getNumber(parentSize.width) 121 | : null 122 | const parentHeight = parentSize 123 | ? Animatable.getNumber(parentSize.height) 124 | : null 125 | 126 | const hOpposingPinsOffset = pinnedOffset(values.left, values.right) 127 | 128 | if (parentWidth && isFiniteNumber(hOpposingPinsOffset)) { 129 | width = parentWidth - hOpposingPinsOffset 130 | } else if (autoSize && values.widthType === DimensionType.Auto) { 131 | width = autoSize.width 132 | } else if (isFiniteNumber(values.width)) { 133 | switch (values.widthType) { 134 | case DimensionType.FixedNumber: 135 | width = values.width 136 | break 137 | case DimensionType.FractionOfFreeSpace: 138 | width = freeSpace 139 | ? (freeSpace.freeSpaceInParent.width / 140 | freeSpace.freeSpaceUnitDivisor.width) * 141 | values.width 142 | : null 143 | break 144 | case DimensionType.Percentage: 145 | if (parentWidth) { 146 | width = parentWidth * values.width 147 | } 148 | break 149 | } 150 | } 151 | 152 | const vOpposingPinsOffset = pinnedOffset(values.top, values.bottom) 153 | 154 | if (parentHeight && isFiniteNumber(vOpposingPinsOffset)) { 155 | height = parentHeight - vOpposingPinsOffset 156 | } else if (autoSize && values.heightType === DimensionType.Auto) { 157 | height = autoSize.height 158 | } else if (isFiniteNumber(values.height)) { 159 | switch (values.heightType) { 160 | case DimensionType.FixedNumber: 161 | height = values.height 162 | break 163 | case DimensionType.FractionOfFreeSpace: 164 | height = freeSpace 165 | ? (freeSpace.freeSpaceInParent.height / 166 | freeSpace.freeSpaceUnitDivisor.height) * 167 | values.height 168 | : null 169 | break 170 | case DimensionType.Percentage: 171 | if (parentHeight) { 172 | height = parentHeight * values.height 173 | } 174 | break 175 | } 176 | } 177 | 178 | const sizeWithDefaults = sizeAfterApplyingDefaultsAndAspectRatio( 179 | width, 180 | height, 181 | values 182 | ) 183 | width = sizeWithDefaults.width 184 | height = sizeWithDefaults.height 185 | 186 | if (values.left !== null) { 187 | x = values.left 188 | } else if (parentWidth && values.right !== null) { 189 | x = parentWidth - values.right - width 190 | } else if (parentWidth) { 191 | x = values.centerAnchorX * parentWidth - width / 2 192 | } 193 | 194 | if (values.top !== null) { 195 | y = values.top 196 | } else if (parentHeight && values.bottom !== null) { 197 | y = parentHeight - values.bottom - height 198 | } else if (parentHeight) { 199 | y = values.centerAnchorY * parentHeight - height / 2 200 | } 201 | 202 | const f = { x, y, width, height } 203 | if (pixelAlign) { 204 | return pixelAligned(f) 205 | } 206 | return f 207 | } 208 | } 209 | 210 | const defaultWidth = 200 211 | const defaultHeight = 200 212 | 213 | export enum DimensionType { 214 | FixedNumber, 215 | Percentage, 216 | /** @internal */ Auto, 217 | FractionOfFreeSpace, 218 | } 219 | 220 | function sizeAfterApplyingDefaultsAndAspectRatio( 221 | width: number | null, 222 | height: number | null, 223 | values 224 | ) { 225 | let w = isFiniteNumber(width) ? width : defaultWidth 226 | let h = isFiniteNumber(height) ? height : defaultHeight 227 | 228 | if (isFiniteNumber(values.aspectRatio)) { 229 | if (isFiniteNumber(values.left) && isFiniteNumber(values.right)) { 230 | h = w / values.aspectRatio 231 | } else if ( 232 | isFiniteNumber(values.top) && 233 | isFiniteNumber(values.bottom) 234 | ) { 235 | w = h * values.aspectRatio 236 | } else if (values.widthType !== DimensionType.FixedNumber) { 237 | h = w / values.aspectRatio 238 | } else { 239 | w = h * values.aspectRatio 240 | } 241 | } 242 | 243 | return { 244 | width: w, 245 | height: h, 246 | } 247 | } 248 | 249 | function pinnedOffset(start: number | null, end: number | null) { 250 | if (!isFiniteNumber(start) || !isFiniteNumber(end)) return null 251 | return start + end 252 | } 253 | 254 | export function calculateRect(props, parentSize, pixelAlign: boolean = true) { 255 | // if (!constraintsEnabled(props) || parentSize === ParentSizeState.Disabled) { 256 | // if (!constraintsEnabled(props)) { 257 | // return rectFromFiniteNumberProps(props) 258 | // } 259 | const constraintValues = getConstraintValues(props) 260 | 261 | return ConstraintValues.toRect( 262 | constraintValues, 263 | deprecatedParentSize(parentSize), 264 | null, 265 | pixelAlign 266 | ) 267 | } 268 | 269 | export namespace ConstraintMask { 270 | // Modifies the constraint mask to remove invalid (mutually exclusive) options and returns the original. 271 | // TODO: this removes major inconsistencies but probably needs to be merged with ConstraintSolver. 272 | export const quickfix = constraints => { 273 | if (constraints.fixedSize) { 274 | // auto sized text 275 | // TODO: use auto dimension type 276 | constraints.widthType = DimensionType.FixedNumber 277 | constraints.heightType = DimensionType.FixedNumber 278 | constraints.aspectRatio = null 279 | } 280 | 281 | if (isFiniteNumber(constraints.aspectRatio)) { 282 | if ( 283 | (constraints.left && constraints.right) || 284 | (constraints.top && constraints.bottom) 285 | ) { 286 | constraints.widthType = DimensionType.FixedNumber 287 | constraints.heightType = DimensionType.FixedNumber 288 | } 289 | if ( 290 | constraints.left && 291 | constraints.right && 292 | constraints.top && 293 | constraints.bottom 294 | ) { 295 | constraints.bottom = false 296 | } 297 | if ( 298 | constraints.widthType !== DimensionType.FixedNumber && 299 | constraints.heightType !== DimensionType.FixedNumber 300 | ) { 301 | constraints.heightType = DimensionType.FixedNumber 302 | } 303 | } 304 | 305 | if (constraints.left && constraints.right) { 306 | constraints.widthType = DimensionType.FixedNumber 307 | 308 | if (constraints.fixedSize) { 309 | constraints.right = false 310 | } 311 | } 312 | if (constraints.top && constraints.bottom) { 313 | constraints.heightType = DimensionType.FixedNumber 314 | 315 | if (constraints.fixedSize) { 316 | constraints.bottom = false 317 | } 318 | } 319 | 320 | return constraints 321 | } 322 | } 323 | 324 | export function valueToDimensionType( 325 | value: string | number | Animatable | undefined 326 | ) { 327 | if (typeof value === "string") { 328 | const trimmedValue = value.trim() 329 | if (trimmedValue === "auto") return DimensionType.Auto 330 | if (trimmedValue.endsWith("fr")) 331 | return DimensionType.FractionOfFreeSpace 332 | if (trimmedValue.endsWith("%")) return DimensionType.Percentage 333 | } 334 | return DimensionType.FixedNumber 335 | } 336 | 337 | function getConstraintValues(props) { 338 | const { left, right, top, bottom, center, _constraints = {}, size } = props 339 | let { width, height } = props 340 | if (width === undefined) { 341 | width = size 342 | } 343 | if (height === undefined) { 344 | height = size 345 | } 346 | const { aspectRatio, autoSize } = _constraints 347 | const constraintMask = ConstraintMask.quickfix({ 348 | left: isFiniteNumber(left), 349 | right: isFiniteNumber(right), 350 | top: isFiniteNumber(top), 351 | bottom: isFiniteNumber(bottom), 352 | widthType: valueToDimensionType(width), 353 | heightType: valueToDimensionType(height), 354 | aspectRatio: aspectRatio || null, 355 | fixedSize: autoSize === true, 356 | }) 357 | 358 | let widthValue: number | null = null 359 | let heightValue: number | null = null 360 | 361 | let widthType = DimensionType.FixedNumber 362 | let heightType = DimensionType.FixedNumber 363 | 364 | if ( 365 | constraintMask.widthType !== DimensionType.FixedNumber && 366 | typeof width === "string" 367 | ) { 368 | const parsedWidth = parseFloat(width) 369 | 370 | if (width.endsWith("fr")) { 371 | widthType = DimensionType.FractionOfFreeSpace 372 | widthValue = parsedWidth 373 | } else if (width === "auto") { 374 | widthType = DimensionType.Auto 375 | } else { 376 | // Percentage 377 | widthType = DimensionType.Percentage 378 | widthValue = parsedWidth / 100 379 | } 380 | } else if (width !== undefined && typeof width !== "string") { 381 | widthValue = width 382 | } 383 | 384 | if ( 385 | constraintMask.heightType !== DimensionType.FixedNumber && 386 | typeof height === "string" 387 | ) { 388 | const parsedHeight = parseFloat(height) 389 | 390 | if (height.endsWith("fr")) { 391 | heightType = DimensionType.FractionOfFreeSpace 392 | heightValue = parsedHeight 393 | } else if (height === "auto") { 394 | heightType = DimensionType.Auto 395 | } else { 396 | // Percentage 397 | heightType = DimensionType.Percentage 398 | heightValue = parseFloat(height) / 100 399 | } 400 | } else if (height !== undefined && typeof height !== "string") { 401 | heightValue = height 402 | } 403 | 404 | let centerAnchorX = 0.5 405 | let centerAnchorY = 0.5 406 | // XXX: is this 407 | if (center === true || center === "x") { 408 | constraintMask.left = false 409 | if (typeof left === "string") { 410 | centerAnchorX = parseFloat(left) / 100 411 | } 412 | } 413 | if (center === true || center === "y") { 414 | constraintMask.top = false 415 | if (typeof top === "string") { 416 | centerAnchorY = parseFloat(top) / 100 417 | } 418 | } 419 | 420 | return { 421 | // Because we check isFiniteNumber when creating the masks, 422 | // We know that left, right, top and bottom are numbers if the mask is true for the corresponding value 423 | // We need to cast this because typescript does not understand that 424 | left: constraintMask.left ? (left as number) : null, 425 | right: constraintMask.right ? (right as number) : null, 426 | top: constraintMask.top ? (top as number) : null, 427 | bottom: constraintMask.bottom ? (bottom as number) : null, 428 | widthType, 429 | heightType, 430 | width: widthValue, 431 | height: heightValue, 432 | aspectRatio: constraintMask.aspectRatio || null, 433 | centerAnchorX: centerAnchorX, 434 | centerAnchorY: centerAnchorY, 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /code/utils/equalizeArrayLength.ts: -------------------------------------------------------------------------------- 1 | export const equalizeArrayLength = (source, target, paddingValue) => { 2 | let sourceResult = [...source] 3 | let targetResult = [...target] 4 | 5 | if (source.length !== target.length) { 6 | const diff = source.length - target.length 7 | const padding = Array(Math.abs(diff)).fill(paddingValue) 8 | if (diff > 0) { 9 | targetResult = [...padding, ...targetResult] 10 | } else { 11 | sourceResult = [...padding, ...sourceResult] 12 | } 13 | } 14 | 15 | return [sourceResult, targetResult] 16 | } 17 | -------------------------------------------------------------------------------- /code/utils/extractEventHandlersFromProps.ts: -------------------------------------------------------------------------------- 1 | import { 2 | eventTriggerNames, 3 | keyEventTriggerNames, 4 | automaticEventTriggerNames, 5 | isCustomEvent, 6 | } from "../controls" 7 | import { handleTrigger } from "../actions" 8 | import { useDoubleTap } from "../hooks/useDoubleTap" 9 | import { useLongPress } from "../hooks/useLongPress" 10 | 11 | type EventHandlers = { 12 | [key: string]: Function 13 | } 14 | 15 | type KeyEventOptions = { keyup?: boolean; keydown?: boolean } 16 | type KeyEvent = { 17 | hotkey: string 18 | options: KeyEventOptions 19 | handler: Function 20 | } 21 | 22 | type AutomaticEvent = { 23 | delay: number 24 | handler: Function 25 | } 26 | 27 | export function extractEventHandlersFromProps( 28 | props, 29 | switchControls, 30 | sanitizedIdentifier 31 | ): [EventHandlers, KeyEvent[], AutomaticEvent[]] { 32 | const keyEvents = [] 33 | const automaticEvents = [] 34 | 35 | const eventHandlers = {} 36 | 37 | eventTriggerNames 38 | .reduce((handlers, event) => { 39 | const action = props[`${event}Action`] 40 | 41 | if (!isCustomEvent(event)) { 42 | const handlerFromProps = (...args) => { 43 | // execute any existing handlers 44 | if (props[event] && typeof props[event] === "function") { 45 | props[event](...args) 46 | } 47 | } 48 | 49 | mergeEvents(handlers, { [event]: handlerFromProps }) 50 | } 51 | 52 | const handler = (...args) => { 53 | // execute any existing handlers if this is a custom event (like a gesture or delay) 54 | if ( 55 | isCustomEvent(event) && 56 | props[event] && 57 | typeof props[event] === "function" 58 | ) { 59 | props[event](...args) 60 | } 61 | 62 | if (action !== "unset") { 63 | // check if a trigger condition has been passed in 64 | if ( 65 | "shouldTrigger" in props && 66 | typeof props.shouldTrigger === "function" && 67 | !props.shouldTrigger(...args) 68 | ) { 69 | // block trigger, because shouldTrigger returned a falsy value 70 | return 71 | } 72 | 73 | handleTrigger( 74 | switchControls, 75 | sanitizedIdentifier, 76 | action, 77 | action === "specific-name" 78 | ? props[`${event}SpecificName`] 79 | : props[`${event}SpecificIndex`] 80 | ) 81 | } 82 | } 83 | 84 | if (keyEventTriggerNames.indexOf(event) !== -1) { 85 | if (action !== "unset") { 86 | const hotkey = (props[`${event}Key`] || "").trim() 87 | if (hotkey !== "") { 88 | const options: KeyEventOptions = { 89 | keydown: true, 90 | keyup: false, 91 | } 92 | if (event === "onKeyUp") { 93 | options.keyup = true 94 | options.keydown = false 95 | } 96 | if (event === "onKeyDown") { 97 | options.keyup = false 98 | options.keydown = true 99 | } 100 | keyEvents.push({ hotkey, options, handler }) 101 | } 102 | } 103 | } else if (automaticEventTriggerNames.indexOf(event) !== -1) { 104 | if (action !== "unset") { 105 | const delay = props[`${event}Delay`] 106 | const delayedHandler = () => { 107 | return setTimeout(handler, delay * 1000) 108 | } 109 | automaticEvents.push({ delay, handler: delayedHandler }) 110 | } 111 | } else if (event === "onDoubleTap") { 112 | const onTap = useDoubleTap(handler) 113 | 114 | mergeEvents(handlers, { onTap }) 115 | } else if (event === "onLongPress") { 116 | const duration = props[`onLongPressDuration`] * 1000 117 | const gestures = useLongPress(handler, duration) 118 | 119 | mergeEvents(handlers, gestures) 120 | } else { 121 | mergeEvents(handlers, { [event]: handler }) 122 | } 123 | 124 | return handlers 125 | }, new Map()) 126 | .forEach((handlers, event) => { 127 | eventHandlers[event] = createEventHandlerSequence(...handlers) 128 | }) 129 | 130 | return [eventHandlers, keyEvents, automaticEvents] 131 | } 132 | 133 | function mergeEvents( 134 | map: Map, 135 | events: { [key: string]: Function } 136 | ) { 137 | for (let e in events) { 138 | if (events.hasOwnProperty(e)) { 139 | map.set(e, [...(map.get(e) || []), events[e]]) 140 | } 141 | } 142 | } 143 | 144 | function createEventHandlerSequence(...handlers) { 145 | return (...args) => { 146 | for (let handler of handlers) { 147 | if (typeof handler === "function") { 148 | handler(...args) 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /code/utils/nodeHelpers.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | Frame, 4 | FrameWithMotion, 5 | Text, 6 | Vector, 7 | VectorGroup, 8 | ComponentContainer, 9 | SVG, 10 | Stack, 11 | } from "framer" 12 | import { calculateRect } from "./calculateRect" 13 | import { rectAsStyleProps } from "./styleParsing" 14 | import { randomID } from "./randomID" 15 | 16 | const nodeTypeMap = { 17 | Frame: "Frame", 18 | Text: "Text", 19 | Vector: "Vector", 20 | VectorGroup: "VectorGroup", 21 | VectorWrapper: "VectorWrapper", 22 | StackLegacyContainer: "StackLegacyContainer", 23 | Stack: "Stack", 24 | ComponentContainer: "ComponentContainer", 25 | SVG: "SVG", 26 | Unknown: "Unknown", 27 | } 28 | 29 | // The Switch doesn't support animating the hierarchy of SVG nodes, 30 | // or Text components for now 31 | const nonAnimatableChildrenNodeTypes = [ 32 | "VectorWrapper", 33 | "Text", 34 | "ComponentContainer", 35 | "SVG", 36 | "Unknown", 37 | ] 38 | 39 | const animatableNodeTypes = ["Frame", "StackLegacyContainer", "Stack"] 40 | 41 | export const isNodeAnimatable = (node) => 42 | animatableNodeTypes.indexOf(getNodeType(node)) !== -1 43 | 44 | export const canAnimateNodeChildren = (node) => 45 | nonAnimatableChildrenNodeTypes.indexOf(getNodeType(node)) === -1 46 | 47 | export const isFrameLike = (node) => { 48 | return ( 49 | node.props && 50 | "_constraints" in node.props && 51 | "_border" in node.props && 52 | "style" in node.props && 53 | "visible" in node.props && 54 | "willChangeTransform" in node.props 55 | ) 56 | } 57 | 58 | export const hasOverrides = (node) => { 59 | const name = getNodeTypeName(node) 60 | 61 | return ( 62 | name === "s" || 63 | (typeof name === "string" && 64 | name.match(/^WithOverrides?\((Frame|Stack)/)) 65 | ) 66 | } 67 | 68 | export const getNodeName = (node) => { 69 | if ( 70 | node.props && 71 | typeof node.props.name !== "undefined" && 72 | node.props.name !== null 73 | ) { 74 | return node.props.name 75 | } 76 | 77 | return getNodeType(node) 78 | } 79 | 80 | export const getNodeId = (node) => node.props.id 81 | export const getNodeTypeName = (node) => { 82 | if (node.type) { 83 | if (node.type === Frame) { 84 | return "Frame" 85 | } 86 | 87 | if (node.type === FrameWithMotion) { 88 | return "FrameWithMotion" 89 | } 90 | 91 | if (node.type === Vector) { 92 | return "Vector" 93 | } 94 | 95 | if (node.type === VectorGroup) { 96 | return "VectorGroup" 97 | } 98 | 99 | if (node.type === Text) { 100 | return "Text" 101 | } 102 | 103 | if (node.type === ComponentContainer) { 104 | return "ComponentContainer" 105 | } 106 | 107 | if (node.type === SVG) { 108 | return "SVG" 109 | } 110 | 111 | if (node.type === Stack) { 112 | return "Stack" 113 | } 114 | 115 | if ("displayName" in node.type) { 116 | return node.type.displayName 117 | } 118 | 119 | if ("name" in node.type) { 120 | return node.type.name 121 | } 122 | 123 | if ( 124 | "render" in node.type && 125 | typeof node.type.render === "function" && 126 | "name" in node.type.render 127 | ) { 128 | return node.type.render.name 129 | } 130 | } 131 | 132 | return undefined 133 | } 134 | 135 | const isVectorWrapper = (node) => { 136 | // if all children are of type Vector or VectorGroup, this is a vector wrapper 137 | const children = React.Children.toArray(node.props.children || []) 138 | 139 | return ( 140 | children.length > 0 && 141 | children.every( 142 | (child) => 143 | [nodeTypeMap.Vector, nodeTypeMap.VectorGroup].indexOf( 144 | getNodeType(child) 145 | ) !== -1 146 | ) 147 | ) 148 | } 149 | 150 | export const getNodeType = (node) => { 151 | const name = getNodeTypeName(node) 152 | 153 | // Known Frame case - Frames could be Vector Wrappers or regular frames 154 | if (name === "Frame" || name === "FrameWithMotion") { 155 | return getRefinedFrameType(node) 156 | } 157 | 158 | // Component containers could be legacy stacks 159 | if (name === "ComponentContainer" && isLegacyStack(node)) { 160 | return nodeTypeMap.StackLegacyContainer 161 | } 162 | 163 | // Unknown types and Frames with Overrides 164 | // name will be "" (empty string) when the component was created using an anonymous function 165 | if (typeof name === "undefined" || name === "" || hasOverrides(node)) { 166 | // Test for an overridden Legacy Stack (a component container underneath) 167 | if (isLegacyStack(node)) { 168 | return nodeTypeMap.StackLegacyContainer 169 | } 170 | 171 | // Test for a modern overridden Stack (will have a proper displayName) 172 | if ( 173 | typeof name === "string" && 174 | name.match(/^WithOverrides?\(Stack\)/) 175 | ) { 176 | return nodeTypeMap.Stack 177 | } 178 | 179 | // Test for Frame-like props and if true, apply the same Frame heuristics as 180 | // in the known Frame case above 181 | if (isFrameLike(node)) { 182 | return getRefinedFrameType(node) 183 | } 184 | } 185 | 186 | return nodeTypeMap[name] || nodeTypeMap.Unknown 187 | } 188 | 189 | // Refines the node type of a Frame to either a VectorWrapper, or a regular Frame. 190 | // The passed in node is assumed to be a Frame/FrameWithMotion or a Frame-like component. 191 | const getRefinedFrameType = (node) => { 192 | if (isVectorWrapper(node)) { 193 | return nodeTypeMap.VectorWrapper 194 | } 195 | 196 | return nodeTypeMap.Frame 197 | } 198 | 199 | export const isLegacyStack = (node) => 200 | "componentIdentifier" in node.props && 201 | node.props.componentIdentifier === "framer/Stack" 202 | 203 | export const isSameComponent = (source, target) => { 204 | return source.props.componentIdentifier === target.props.componentIdentifier 205 | } 206 | 207 | export const getNodeRect = (node, parentSize) => { 208 | const nodeType = getNodeType(node) 209 | let props = { ...node.props } 210 | 211 | if (nodeType === "StackLegacyContainer") { 212 | const stack = React.Children.toArray(node.props.children)[0] 213 | 214 | props.width = stack.props.width 215 | props.height = stack.props.height 216 | } 217 | 218 | const rect = calculateRect(props, parentSize) 219 | return rectAsStyleProps(rect) 220 | } 221 | 222 | export const nodeWithIdAndKey = (node) => { 223 | let id = getNodeId(node) 224 | id = typeof id === "undefined" || id === null ? randomID() : id 225 | const key = 226 | typeof node.key === "undefined" || node.key === null ? id : node.key 227 | 228 | return React.cloneElement(node, { key, id }) 229 | } 230 | 231 | export const getNodeChildren = (node) => { 232 | const nodeType = getNodeType(node) 233 | let children = node.props.children 234 | 235 | if (nodeType === "StackLegacyContainer") { 236 | const stack = React.Children.toArray(children)[0] 237 | children = stack.props.children 238 | } 239 | 240 | return React.Children.map(children, nodeWithIdAndKey) 241 | } 242 | 243 | export const isNodeVisible = (node) => { 244 | return node.props && node.props.visible 245 | } 246 | -------------------------------------------------------------------------------- /code/utils/omit.ts: -------------------------------------------------------------------------------- 1 | export function omit(source, blacklist) { 2 | return Object.keys(source) 3 | .filter(key => blacklist.indexOf(key) < 0) 4 | .reduce( 5 | (result, key) => Object.assign(result, { [key]: source[key] }), 6 | {} 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /code/utils/pick.ts: -------------------------------------------------------------------------------- 1 | export function pick(source, whitelist) { 2 | return Object.keys(source) 3 | .filter(key => whitelist.indexOf(key) >= 0) 4 | .reduce( 5 | (result, key) => Object.assign(result, { [key]: source[key] }), 6 | {} 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /code/utils/propNameHelpers.ts: -------------------------------------------------------------------------------- 1 | function capitalize(name: string): string { 2 | return name.charAt(0).toUpperCase() + name.slice(1) 3 | } 4 | 5 | export function prefixPropName(name: string, prefix: string = null): string { 6 | return prefix ? `${prefix}${capitalize(name)}` : name 7 | } 8 | 9 | export function sanitizePropName(name: string): string { 10 | return name.replace(/\s/, "") 11 | } 12 | -------------------------------------------------------------------------------- /code/utils/randomID.ts: -------------------------------------------------------------------------------- 1 | const first = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 2 | const letters = 3 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_" 4 | 5 | function f(): string { 6 | return first[Math.floor(Math.random() * first.length)] 7 | } 8 | 9 | function l(): string { 10 | return letters[Math.floor(Math.random() * letters.length)] 11 | } 12 | 13 | export function randomID(): string { 14 | return f() + l() + l() + l() + l() + l() + l() + l() + l() 15 | } 16 | -------------------------------------------------------------------------------- /code/utils/styleParsing.ts: -------------------------------------------------------------------------------- 1 | import { Color, LinearGradient, RadialGradient } from "framer" 2 | import { omit } from "./omit" 3 | import { equalizeArrayLength } from "./equalizeArrayLength" 4 | 5 | export const getOpacity = (style) => 6 | typeof style === "undefined" || 7 | typeof style.opacity === "undefined" || 8 | style.opacity === null 9 | ? 1 10 | : style.opacity 11 | 12 | export const getRotate = (style) => 13 | typeof style === "undefined" || typeof style.rotate === "undefined" 14 | ? 0 15 | : style.rotate 16 | 17 | const cssColorVarRegex = /\bvar\([^,]*, (.*)\)/ 18 | 19 | export const getColorType = (colorObject) => { 20 | if (typeof colorObject === "undefined" || colorObject === null) { 21 | return "none" 22 | } 23 | 24 | if (typeof colorObject === "string") { 25 | if (colorObject.startsWith("linear-gradient")) { 26 | return "linear-gradient" 27 | } 28 | 29 | if (colorObject.startsWith("radial-gradient")) { 30 | return "radial-gradient" 31 | } 32 | 33 | return "plain" 34 | } 35 | 36 | if ("__class" in colorObject && colorObject.__class === "LinearGradient") { 37 | return "linear-gradient" 38 | } 39 | 40 | if ("__class" in colorObject && colorObject.__class === "RadialGradient") { 41 | return "radial-gradient" 42 | } 43 | 44 | if ("src" in colorObject) { 45 | return "image" 46 | } 47 | 48 | return "plain" 49 | } 50 | 51 | function parseColorStops(colorStopsCSS: string) { 52 | return colorStopsCSS 53 | .split(/, (?=[a-z])/) 54 | .map((stopCSS) => { 55 | const stopMatch = stopCSS.match(/(.*\)) ([\d\.]+)%$/) 56 | if (stopMatch) { 57 | return { 58 | value: stopMatch[1], 59 | position: parseFloat(stopMatch[2]) / 100, 60 | } 61 | } 62 | return null 63 | }) 64 | .filter(Boolean) 65 | } 66 | 67 | function linearGradientFromCSS(css: string) { 68 | if (!css.startsWith("linear-gradient(")) return null 69 | 70 | const match = css.match(/linear-gradient\((\d+)deg, (.*)\)/) 71 | if (match) { 72 | const angle = parseFloat(match[1]) 73 | const stops = parseColorStops(match[2]) 74 | 75 | return { 76 | angle, 77 | stops, 78 | } 79 | } 80 | 81 | return null 82 | } 83 | 84 | function radialGradientFromCSS(css: string) { 85 | if (!css.startsWith("radial-gradient(")) return null 86 | 87 | const match = css.match( 88 | /radial-gradient\(([\d\.]+)% ([\d\.]+)% at ([\d\.]+)% ([\d\.]+)%, (.*)\)/ 89 | ) 90 | if (match) { 91 | const widthFactor = parseFloat(match[1]) / 100 92 | const heightFactor = parseFloat(match[2]) / 100 93 | const centerAnchorX = parseFloat(match[3]) / 100 94 | const centerAnchorY = parseFloat(match[4]) / 100 95 | const stops = parseColorStops(match[5]) 96 | 97 | return { 98 | widthFactor, 99 | heightFactor, 100 | centerAnchorX, 101 | centerAnchorY, 102 | stops, 103 | } 104 | } 105 | return null 106 | } 107 | 108 | export const getBackgroundColorPair = (sourceProps, targetProps) => { 109 | let sourceColorSource = sourceProps.background 110 | if (!sourceColorSource && sourceProps.style) { 111 | sourceColorSource = 112 | sourceProps.style.backgroundColor || sourceProps.style.background 113 | } 114 | let targetColorSource = targetProps.background 115 | if (!targetColorSource && targetProps.style) { 116 | targetColorSource = 117 | targetProps.style.backgroundColor || targetProps.style.background 118 | } 119 | 120 | const sourceColorType = getColorType(sourceColorSource) 121 | const targetColorType = getColorType(targetColorSource) 122 | 123 | if ( 124 | typeof sourceColorSource === "string" && 125 | (sourceColorType === "linear-gradient" || 126 | sourceColorType === "radial-gradient") 127 | ) { 128 | sourceColorSource = 129 | sourceColorType === "linear-gradient" 130 | ? linearGradientFromCSS(sourceColorSource) 131 | : radialGradientFromCSS(sourceColorSource) 132 | } 133 | 134 | if ( 135 | typeof targetColorSource === "string" && 136 | (targetColorType === "linear-gradient" || 137 | targetColorType === "radial-gradient") 138 | ) { 139 | targetColorSource = 140 | targetColorType === "linear-gradient" 141 | ? linearGradientFromCSS(targetColorSource) 142 | : radialGradientFromCSS(targetColorSource) 143 | } 144 | 145 | let sourceLinear 146 | let targetLinear 147 | let sourceRadial 148 | let targetRadial 149 | 150 | if (targetColorType === "image" || sourceColorType === "image") { 151 | return [undefined, undefined] 152 | } 153 | 154 | if (targetColorType === "none" || targetColorType === "plain") { 155 | if (sourceColorType === "none" || sourceColorType === "plain") { 156 | const sourceColor = getPlainBackgroundColor(sourceProps) 157 | const targetColor = getPlainBackgroundColor(targetProps) 158 | 159 | sourceLinear = linearGradientFromColor( 160 | sourceColorType === "none" && targetColorType === "plain" 161 | ? transparent(targetColor) 162 | : sourceColor 163 | ) 164 | targetLinear = linearGradientFromColor( 165 | sourceColorType === "plain" && targetColorType === "none" 166 | ? transparent(sourceColor) 167 | : targetColor 168 | ) 169 | 170 | sourceRadial = radialGradientFromColor( 171 | sourceColorType === "none" && targetColorType === "plain" 172 | ? transparent(targetColor) 173 | : transparent(sourceColor) 174 | ) 175 | targetRadial = radialGradientFromColor( 176 | sourceColorType === "plain" && targetColorType === "none" 177 | ? transparent(sourceColor) 178 | : transparent(targetColor) 179 | ) 180 | } 181 | 182 | if (sourceColorType === "linear-gradient") { 183 | sourceLinear = linearGradientFromGradient(sourceColorSource) 184 | sourceRadial = radialGradientFromGradient( 185 | sourceColorSource, 186 | 0 // alpha 187 | ) 188 | const targetColor = getPlainBackgroundColor(targetProps) 189 | targetLinear = linearGradientFromColor( 190 | targetColor, 191 | sourceColorSource.angle 192 | ) 193 | targetRadial = radialGradientFromColor(transparent(targetColor)) 194 | } 195 | 196 | if (sourceColorType === "radial-gradient") { 197 | sourceRadial = radialGradientFromGradient(sourceColorSource) 198 | const { 199 | widthFactor, 200 | heightFactor, 201 | centerAnchorX, 202 | centerAnchorY, 203 | } = sourceColorSource 204 | 205 | const gradientShape = `${widthFactor * 100}% ${heightFactor * 100}%` 206 | const gradientPosition = `${centerAnchorX * 100}% ${ 207 | centerAnchorY * 100 208 | }%` 209 | const targetColor = getPlainBackgroundColor(targetProps) 210 | targetRadial = radialGradientFromColor( 211 | targetColor, 212 | gradientShape, 213 | gradientPosition 214 | ) 215 | 216 | sourceLinear = linearGradientFromColor(transparent(targetColor)) 217 | targetLinear = linearGradientFromColor(transparent(targetColor)) 218 | } 219 | } 220 | 221 | if (targetColorType === "linear-gradient") { 222 | if (sourceColorType === "none" || sourceColorType === "plain") { 223 | const sourceColor = getPlainBackgroundColor(sourceProps) 224 | sourceLinear = 225 | sourceColorType === "none" 226 | ? linearGradientFromGradient(targetColorSource, 0) 227 | : linearGradientFromColor( 228 | sourceColor, 229 | targetColorSource.angle 230 | ) 231 | sourceRadial = 232 | sourceColorType === "none" 233 | ? radialGradientFromGradient(targetColorSource, 0) 234 | : radialGradientFromColor(transparent(sourceColor)) 235 | 236 | targetLinear = linearGradientFromGradient(targetColorSource) 237 | targetRadial = radialGradientFromGradient(targetColorSource, 0) 238 | } 239 | 240 | if (sourceColorType === "linear-gradient") { 241 | sourceLinear = linearGradientFromGradient(sourceColorSource) 242 | sourceRadial = radialGradientFromGradient(sourceColorSource, 0) 243 | targetLinear = linearGradientFromGradient(targetColorSource) 244 | targetRadial = radialGradientFromGradient(targetColorSource, 0) 245 | } 246 | 247 | if (sourceColorType === "radial-gradient") { 248 | sourceLinear = linearGradientFromGradient(sourceColorSource, 0) 249 | sourceRadial = radialGradientFromGradient(sourceColorSource) 250 | targetLinear = linearGradientFromGradient(targetColorSource) 251 | targetRadial = radialGradientFromGradient(targetColorSource, 0) 252 | } 253 | } 254 | 255 | if (targetColorType === "radial-gradient") { 256 | if (sourceColorType === "none" || sourceColorType === "plain") { 257 | const sourceColor = getPlainBackgroundColor(sourceProps) 258 | 259 | const { 260 | widthFactor, 261 | heightFactor, 262 | centerAnchorX, 263 | centerAnchorY, 264 | } = targetColorSource 265 | 266 | const gradientShape = `${widthFactor * 100}% ${heightFactor * 100}%` 267 | const gradientPosition = `${centerAnchorX * 100}% ${ 268 | centerAnchorY * 100 269 | }%` 270 | 271 | sourceLinear = 272 | sourceColorType === "none" 273 | ? linearGradientFromGradient(targetColorSource, 0) 274 | : linearGradientFromColor(transparent(sourceColor)) 275 | targetLinear = 276 | sourceColorType === "none" 277 | ? linearGradientFromGradient(targetColorSource, 0) 278 | : linearGradientFromColor(transparent(sourceColor)) 279 | 280 | sourceRadial = radialGradientFromColor( 281 | sourceColor, 282 | gradientShape, 283 | gradientPosition 284 | ) 285 | targetRadial = radialGradientFromGradient(targetColorSource) 286 | } 287 | 288 | if (sourceColorType === "linear-gradient") { 289 | sourceLinear = linearGradientFromGradient(sourceColorSource) 290 | sourceRadial = radialGradientFromGradient(sourceColorSource, 0) 291 | targetRadial = radialGradientFromGradient(targetColorSource) 292 | targetLinear = linearGradientFromGradient(targetColorSource, 0) 293 | } 294 | 295 | if (sourceColorType === "radial-gradient") { 296 | sourceLinear = linearGradientFromGradient(sourceColorSource, 0) 297 | sourceRadial = radialGradientFromGradient(sourceColorSource) 298 | targetLinear = linearGradientFromGradient(targetColorSource, 0) 299 | targetRadial = radialGradientFromGradient(targetColorSource) 300 | } 301 | } 302 | 303 | const sourceBackgroundProps = { 304 | backgroundColor: "rgba(0, 0, 0, 0)", 305 | backgroundImage: `${sourceLinear}, ${sourceRadial}`, 306 | } 307 | 308 | const targetBackgroundProps = { 309 | backgroundColor: "rgba(0, 0, 0, 0)", 310 | backgroundImage: `${targetLinear}, ${targetRadial}`, 311 | } 312 | 313 | return [sourceBackgroundProps, targetBackgroundProps] 314 | } 315 | 316 | export const isBackgroundTransitionAnimatable = (source, target) => { 317 | const sourceBackground = getColorType(source.props.background) 318 | const targetBackground = getColorType(target.props.background) 319 | 320 | return !( 321 | (sourceBackground !== "image" && targetBackground === "image") || 322 | (sourceBackground === "image" && targetBackground !== "image") 323 | ) 324 | } 325 | 326 | export const transparent = (color) => 327 | Color.toString(Color.alpha(toColor(color), 0)) 328 | 329 | export const linearGradientFromColor = (color, angle = 0) => { 330 | return `linear-gradient(${angle}deg, ${color} 0%, ${color} 100%)` 331 | } 332 | 333 | export const radialGradientFromColor = ( 334 | color, 335 | shape = "50% 50%", 336 | position = "50% 50%" 337 | ) => { 338 | return `radial-gradient(${shape} at ${position}, ${color} 0%, ${color} 100%)` 339 | } 340 | 341 | const toColor = (color) => { 342 | if (typeof color === "string" && color.match(cssColorVarRegex)) { 343 | const matches = color.match(cssColorVarRegex) 344 | return Color(matches[1]) 345 | } 346 | 347 | return Color(color) 348 | } 349 | 350 | export const getPlainBackgroundColor = (props) => { 351 | let color = "transparent" 352 | 353 | if (typeof props.style !== "undefined") { 354 | color = 355 | props.style.backgroundColor || 356 | props.style.background || 357 | "transparent" 358 | } else { 359 | color = props.backgroundColor || props.background || "transparent" 360 | } 361 | 362 | return Color.toString(toColor(color)) 363 | } 364 | 365 | export const toCssGradientWithRgbStops = ( 366 | gradient, 367 | targetGradientType = null, 368 | targetAlpha = null 369 | ) => { 370 | const stops = gradient.stops.map((stop) => ({ 371 | ...stop, 372 | value: Color.toString( 373 | targetAlpha === null 374 | ? toColor(stop.value) 375 | : Color.alpha(toColor(stop.value), targetAlpha) 376 | ), 377 | })) 378 | 379 | let type = targetGradientType 380 | if (!type && gradient.__class === "LinearGradient") { 381 | type = "linear" 382 | } 383 | 384 | if (!type && gradient.__class === "RadialGradient") { 385 | type = "radial" 386 | } 387 | 388 | if (type === "linear") { 389 | return LinearGradient.toCSS({ 390 | angle: 0, 391 | ...gradient, 392 | stops, 393 | }) 394 | } 395 | 396 | if (type === "radial") { 397 | return RadialGradient.toCSS({ 398 | widthFactor: 0.5, 399 | heightFactor: 0.5, 400 | centerAnchorX: 0.5, 401 | centerAnchorY: 0.5, 402 | ...gradient, 403 | stops, 404 | }) 405 | } 406 | 407 | return gradient 408 | } 409 | 410 | export const linearGradientFromGradient = (gradient, alpha = null) => 411 | toCssGradientWithRgbStops(gradient, "linear", alpha) 412 | 413 | export const radialGradientFromGradient = (gradient, alpha = null) => 414 | toCssGradientWithRgbStops(gradient, "radial", alpha) 415 | 416 | const transparentShadow = `0px 0px 0px 0px rgba(0, 0, 0, 0)` 417 | const insetTransparentShadow = `inset ${transparentShadow}` 418 | 419 | const shadowRegex = new RegExp(/, (?=-?\d+px)|, (?=inset -?\d+px)/) 420 | const maxShadows = 10 421 | const colorRegex = /(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|(rgb|hsl)a?\((-?\d+%?[,\s]+){2,3}\s*[\d\.]+%?\)|\b(black|silver|gray|whitesmoke|maroon|red|purple|fuchsia|green|lime|olivedrab|yellow|navy|blue|teal|aquamarine|orange|aliceblue|antiquewhite|aqua|azure|beige|bisque|blanchedalmond|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|gainsboro|ghostwhite|goldenrod|gold|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavenderblush|lavender|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|limegreen|linen|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|oldlace|olive|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|thistle|tomato|turquoise|violet|wheat|white|yellowgreen|rebeccapurple)\b)/i 422 | 423 | export const convertColorsInStringToRgba = (string) => { 424 | const updated = string 425 | .replace(cssColorVarRegex, "$1") 426 | .replace(colorRegex, (match) => { 427 | return Color.toString(toColor(match)) 428 | }) 429 | 430 | return updated 431 | } 432 | 433 | export const getBoxShadowPair = (sourceProps, targetProps) => { 434 | let sourceShadows = getBoxShadow(sourceProps.style).split(shadowRegex) 435 | let sourceBoxShadows = sourceShadows 436 | .filter((s) => !s.match(/^inset/)) 437 | .slice(0, maxShadows) 438 | .map(convertColorsInStringToRgba) 439 | let sourceInnerShadows = sourceShadows 440 | .filter((s) => s.match(/^inset/)) 441 | .slice(0, maxShadows) 442 | .map(convertColorsInStringToRgba) 443 | let targetShadows = getBoxShadow(targetProps.style).split(shadowRegex) 444 | let targetBoxShadows = targetShadows 445 | .filter((s) => !s.match(/^inset/)) 446 | .slice(0, maxShadows) 447 | .map(convertColorsInStringToRgba) 448 | let targetInnerShadows = targetShadows 449 | .filter((s) => s.match(/^inset/)) 450 | .slice(0, maxShadows) 451 | .map(convertColorsInStringToRgba) 452 | const placeholderBoxShadows = Array(maxShadows).fill(transparentShadow) 453 | const placeholderInnerShadows = Array(maxShadows).fill( 454 | insetTransparentShadow 455 | ) 456 | 457 | if (sourceBoxShadows.length <= placeholderBoxShadows.length) { 458 | let _ 459 | ;[sourceBoxShadows, _] = equalizeArrayLength( 460 | sourceBoxShadows, 461 | placeholderBoxShadows, 462 | transparentShadow 463 | ) 464 | } 465 | 466 | if (targetBoxShadows.length < placeholderBoxShadows.length) { 467 | let _ 468 | ;[targetBoxShadows, _] = equalizeArrayLength( 469 | targetBoxShadows, 470 | placeholderBoxShadows, 471 | transparentShadow 472 | ) 473 | } 474 | 475 | if (sourceInnerShadows.length <= placeholderInnerShadows.length) { 476 | let _ 477 | ;[sourceInnerShadows, _] = equalizeArrayLength( 478 | sourceInnerShadows, 479 | placeholderInnerShadows, 480 | insetTransparentShadow 481 | ) 482 | } 483 | 484 | if (targetInnerShadows.length < placeholderBoxShadows.length) { 485 | let _ 486 | ;[targetInnerShadows, _] = equalizeArrayLength( 487 | targetInnerShadows, 488 | placeholderInnerShadows, 489 | insetTransparentShadow 490 | ) 491 | } 492 | 493 | sourceShadows = [...sourceBoxShadows, ...sourceInnerShadows] 494 | targetShadows = [...targetBoxShadows, ...targetInnerShadows] 495 | 496 | const sourceShadowsStyle = sourceShadows.join(", ") 497 | const targetShadowsStyle = targetShadows.join(", ") 498 | 499 | return [sourceShadowsStyle, targetShadowsStyle] 500 | } 501 | 502 | export const getBoxShadow = (style) => { 503 | if ( 504 | typeof style === "undefined" || 505 | typeof style.boxShadow === "undefined" || 506 | style.boxShadow === null 507 | ) { 508 | return transparentShadow 509 | } 510 | 511 | return style.boxShadow 512 | } 513 | 514 | export const getBorderRadius = (style) => { 515 | const result = { 516 | borderTopLeftRadius: "0px", 517 | borderTopRightRadius: "0px", 518 | borderBottomRightRadius: "0px", 519 | borderBottomLeftRadius: "0px", 520 | } 521 | 522 | if (typeof style === "undefined") return result 523 | 524 | // single value 525 | if ( 526 | typeof style.borderRadius === "string" && 527 | style.borderRadius.split(" ").length === 1 528 | ) { 529 | result.borderTopLeftRadius = style.borderRadius 530 | result.borderTopRightRadius = style.borderRadius 531 | result.borderBottomRightRadius = style.borderRadius 532 | result.borderBottomLeftRadius = style.borderRadius 533 | } 534 | 535 | // four values 536 | if ( 537 | typeof style.borderRadius === "string" && 538 | style.borderRadius.split(" ").length === 4 539 | ) { 540 | const values = style.borderRadius.split(" ") 541 | result.borderTopLeftRadius = values[0] 542 | result.borderTopRightRadius = values[1] 543 | result.borderBottomRightRadius = values[2] 544 | result.borderBottomLeftRadius = values[3] 545 | } 546 | 547 | // separate values 548 | for (let prop of [ 549 | "borderTopLeftRadius", 550 | "borderTopRightRadius", 551 | "borderBottomRightRadius", 552 | "borderBottomLeftRadius", 553 | ]) { 554 | if ( 555 | typeof style[prop] === "string" || 556 | (typeof style[prop] === "number" && !Number.isNaN(style[prop])) 557 | ) { 558 | result[prop] = 559 | typeof style[prop] === "number" 560 | ? `${style[prop]}px` 561 | : style[prop] 562 | } 563 | } 564 | 565 | return result 566 | } 567 | 568 | export const getBorderPair = (sourceProps, targetProps) => { 569 | const sourceBorder = getBorder(sourceProps._border) 570 | const targetBorder = getBorder(targetProps._border) 571 | 572 | return [sourceBorder, targetBorder] 573 | } 574 | 575 | export const getBorder = (border) => { 576 | if ( 577 | typeof border === "undefined" || 578 | border === null || 579 | Object.keys(border).length === 0 580 | ) { 581 | return { 582 | borderLeftWidth: "0px", 583 | borderTopWidth: "0px", 584 | borderRightWidth: "0px", 585 | borderBottomWidth: "0px", 586 | borderStyle: "solid", 587 | borderColor: "rgba(0, 0, 0, 0)", 588 | } 589 | } 590 | 591 | const rgbaColor = Color.toString(toColor(border.borderColor)) 592 | let borderLeftWidth 593 | let borderTopWidth 594 | let borderRightWidth 595 | let borderBottomWidth 596 | 597 | if (typeof border.borderWidth === "number") { 598 | borderLeftWidth = borderTopWidth = borderRightWidth = borderBottomWidth = `${border.borderWidth}px` 599 | } else { 600 | borderLeftWidth = `${border.borderWidth.left}px` 601 | borderTopWidth = `${border.borderWidth.top}px` 602 | borderRightWidth = `${border.borderWidth.right}px` 603 | borderBottomWidth = `${border.borderWidth.bottom}px` 604 | } 605 | 606 | return { 607 | borderLeftWidth, 608 | borderTopWidth, 609 | borderRightWidth, 610 | borderBottomWidth, 611 | borderStyle: border.borderStyle, 612 | borderColor: rgbaColor, 613 | } 614 | } 615 | 616 | export const rectAsStyleProps = (rect) => { 617 | return { 618 | width: rect.width, 619 | height: rect.height, 620 | top: rect.y, 621 | left: rect.x, 622 | } 623 | } 624 | 625 | const absolutePositioningProps = ["top", "left", "bottom", "right"] 626 | 627 | export const filterOutAbsolutePositioningProps = (props) => { 628 | const filteredProps = omit(props, absolutePositioningProps) 629 | 630 | if (filteredProps.variants) { 631 | filteredProps.variants = Object.keys(filteredProps.variants).reduce( 632 | (res, v) => { 633 | res[v] = omit( 634 | filteredProps.variants[v], 635 | absolutePositioningProps 636 | ) 637 | return res 638 | }, 639 | {} 640 | ) 641 | } 642 | 643 | return filteredProps 644 | } 645 | -------------------------------------------------------------------------------- /metadata/artwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tisho/Switch.framerfx/00190201a0ce089bfb829f64699e964abf6b7a1a/metadata/artwork.png -------------------------------------------------------------------------------- /metadata/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tisho/Switch.framerfx/00190201a0ce089bfb829f64699e964abf6b7a1a/metadata/icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/index.js", 3 | "license": "MIT", 4 | "devDependencies": { 5 | "@framer/framer.device-skin-apple-iphone-xs-spacegrey": "^1.0.0", 6 | "@types/react": "^16.8" 7 | }, 8 | "peerDependencies": { 9 | "framer": "^1.0", 10 | "react": "^16.8" 11 | }, 12 | "framer": { 13 | "id": "ed23e452-a6a0-4923-b8d0-b11df262bfbc", 14 | "displayName": "Switch" 15 | }, 16 | "author": "Tisho Georgiev", 17 | "dependencies": { 18 | "hotkeys-js": "^3.7.3", 19 | "reactn": "^2.2.4" 20 | }, 21 | "name": "@framer/tishogeorgiev.switch", 22 | "version": "1.31.0" 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["es2015", "dom"], 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "outDir": "build", 8 | "sourceMap": false, 9 | "declaration": false, 10 | "resolveJsonModule": true, 11 | "noImplicitAny": false 12 | }, 13 | "exclude": ["node_modules", "build"] 14 | } 15 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@framer/framer.device-skin-apple-iphone-xs-spacegrey@^1.0.0": 6 | version "1.0.0" 7 | resolved "https://registry.framer.com/@framer/framer.device-skin-apple-iphone-xs-spacegrey/-/@framer/framer.device-skin-apple-iphone-xs-spacegrey-1.0.0.tgz#d9f524f5a6a4afe8cb413c00f3f16fe4d52ee09e" 8 | integrity sha512-BWmGnCvnPmYel3ytJ1eiNxugHTj3e7eiih8L1b3RZtB4ZjgF6HiCMp+nsB0wAOJrMS2sA+fEYMDzxqybX9Dukg== 9 | 10 | "@types/prop-types@*": 11 | version "15.7.0" 12 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.0.tgz#4c48fed958d6dcf9487195a0ef6456d5f6e0163a" 13 | integrity sha512-eItQyV43bj4rR3JPV0Skpl1SncRCdziTEK9/v8VwXmV6d/qOUO8/EuWeHBbCZcsfSHfzI5UyMJLCSXtxxznyZg== 14 | 15 | "@types/react@^16.8": 16 | version "16.8.4" 17 | resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.4.tgz#134307f5266e866d5e7c25e47f31f9abd5b2ea34" 18 | integrity sha512-Mpz1NNMJvrjf0GcDqiK8+YeOydXfD8Mgag3UtqQ5lXYTsMnOiHcKmO48LiSWMb1rSHB9MV/jlgyNzeAVxWMZRQ== 19 | dependencies: 20 | "@types/prop-types" "*" 21 | csstype "^2.2.0" 22 | 23 | csstype@^2.2.0: 24 | version "2.6.1" 25 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.1.tgz#4cfbf637a577497036ebcd7e32647ef19a0b8076" 26 | integrity sha512-wv7IRqCGsL7WGKB8gPvrl+++HlFM9kxAM6jL1EXNPNTshEJYilMkbfS2SnuHha77uosp/YVK0wAp2jmlBzn1tg== 27 | 28 | hotkeys-js@^3.7.3: 29 | version "3.7.3" 30 | resolved "https://registry.yarnpkg.com/hotkeys-js/-/hotkeys-js-3.7.3.tgz#f0c718166e844b3e52065d1b60cffaa6065b5183" 31 | integrity sha512-CSaeVPAKEEYNexYR35znMJnCqoofk7oqG/AOOqWow1qDT0Yxy+g+Y8Hs/LhGlsZaSJ7973YN6/N41LAr3t30QQ== 32 | 33 | reactn@^2.2.4: 34 | version "2.2.4" 35 | resolved "https://registry.yarnpkg.com/reactn/-/reactn-2.2.4.tgz#f42168177f865feeeba057c8ad558d5456c730f9" 36 | integrity sha512-ROZGocaVrunwcyxmmi7tQWk0/NhersX55Zh0yInDXxD2sc7L4iriSu6oG0Cmq2QcwfEJTFXIEL+ASBpDgAYywQ== 37 | dependencies: 38 | use-force-update "^1.0.5" 39 | 40 | use-force-update@^1.0.5: 41 | version "1.0.7" 42 | resolved "https://registry.yarnpkg.com/use-force-update/-/use-force-update-1.0.7.tgz#f5e672633f5a398b25c33b2287150918bcb3526b" 43 | integrity sha512-k5dppYhO+I5X/cd7ildbrzeMZJkWwdAh5adaIk0qKN2euh7J0h2GBGBcB4QZ385eyHHnp7LIygvebdRx3XKdwA== 44 | --------------------------------------------------------------------------------