framer-code-link 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +115 -4
- package/package.json +3 -2
- package/skills/SKILL.md +133 -0
- package/skills/references/EXAMPLES.md +869 -0
- package/skills/references/PROPERTY_CONTROLS.md +715 -0
- package/skills/references/PROPERTY_CONTROL_GUIDE.md +332 -0
- package/skills/references/PROPERTY_CONTROL_TYPES.md +488 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
# Framer Component Examples
|
|
2
|
+
|
|
3
|
+
Reference implementations demonstrating best practices.
|
|
4
|
+
|
|
5
|
+
## Cookie Banner
|
|
6
|
+
|
|
7
|
+
Location-aware cookie consent with timezone detection.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// Cookie banner with opt-in for Europe, opt-out elsewhere, based on time zone
|
|
11
|
+
import {
|
|
12
|
+
useEffect,
|
|
13
|
+
useState,
|
|
14
|
+
startTransition,
|
|
15
|
+
type CSSProperties,
|
|
16
|
+
} from "react";
|
|
17
|
+
import { addPropertyControls, ControlType, RenderTarget } from "framer";
|
|
18
|
+
|
|
19
|
+
interface CookiebannerProps {
|
|
20
|
+
message: string;
|
|
21
|
+
acceptLabel: string;
|
|
22
|
+
declineLabel: string;
|
|
23
|
+
backgroundColor: string;
|
|
24
|
+
textColor: string;
|
|
25
|
+
buttonColor: string;
|
|
26
|
+
buttonTextColor: string;
|
|
27
|
+
font: any;
|
|
28
|
+
borderRadius: number;
|
|
29
|
+
buttonFont: any;
|
|
30
|
+
style?: CSSProperties;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Cookies
|
|
35
|
+
*
|
|
36
|
+
* @framerIntrinsicWidth 400
|
|
37
|
+
* @framerIntrinsicHeight 100
|
|
38
|
+
*
|
|
39
|
+
* @framerSupportedLayoutWidth any-prefer-fixed
|
|
40
|
+
* @framerSupportedLayoutHeight any-prefer-fixed
|
|
41
|
+
*/
|
|
42
|
+
export default function Cookiebanner(props: CookiebannerProps) {
|
|
43
|
+
const {
|
|
44
|
+
message,
|
|
45
|
+
acceptLabel,
|
|
46
|
+
declineLabel,
|
|
47
|
+
backgroundColor,
|
|
48
|
+
textColor,
|
|
49
|
+
buttonColor,
|
|
50
|
+
buttonTextColor,
|
|
51
|
+
font,
|
|
52
|
+
borderRadius,
|
|
53
|
+
} = props;
|
|
54
|
+
|
|
55
|
+
// Guess if user is in Europe based on timezone offset
|
|
56
|
+
const [show, setShow] = useState(true);
|
|
57
|
+
const [isEurope, setIsEurope] = useState(false);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (typeof window !== "undefined") {
|
|
60
|
+
const offset = new Date().getTimezoneOffset();
|
|
61
|
+
// Europe: UTC+0 to UTC+3 (offset -0 to -180)
|
|
62
|
+
startTransition(() => setIsEurope(offset <= 0 && offset >= -180));
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Hide on accept/decline
|
|
67
|
+
function handleAccept() {
|
|
68
|
+
startTransition(() => setShow(false));
|
|
69
|
+
}
|
|
70
|
+
function handleDecline() {
|
|
71
|
+
startTransition(() => setShow(false));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!show || RenderTarget.current() === RenderTarget.thumbnail) return null;
|
|
75
|
+
|
|
76
|
+
const buttonBaseStyles = {
|
|
77
|
+
borderRadius: 10,
|
|
78
|
+
flex: 1,
|
|
79
|
+
border: `1px solid ${buttonColor}`,
|
|
80
|
+
padding: "8px 18px",
|
|
81
|
+
cursor: "pointer",
|
|
82
|
+
...props.buttonFont,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isFixedWidth = props?.style && props.style.width === "100%";
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
style={{
|
|
90
|
+
...props.style,
|
|
91
|
+
overflow: "hidden",
|
|
92
|
+
position: "relative",
|
|
93
|
+
...(isFixedWidth ? { ...props?.style } : { minWidth: "max-content" }),
|
|
94
|
+
background: backgroundColor,
|
|
95
|
+
color: textColor,
|
|
96
|
+
borderRadius,
|
|
97
|
+
display: "flex",
|
|
98
|
+
flexDirection: "column",
|
|
99
|
+
justifyContent: "space-between",
|
|
100
|
+
padding: 20,
|
|
101
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
|
102
|
+
gap: 20,
|
|
103
|
+
|
|
104
|
+
...props.font,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<span style={{ flex: 1 }}>{message}</span>
|
|
108
|
+
<div style={{ width: "100%", display: "flex", gap: 10 }}>
|
|
109
|
+
<button
|
|
110
|
+
style={{
|
|
111
|
+
...buttonBaseStyles,
|
|
112
|
+
background: "transparent",
|
|
113
|
+
color: buttonColor,
|
|
114
|
+
}}
|
|
115
|
+
onClick={handleDecline}
|
|
116
|
+
>
|
|
117
|
+
{declineLabel}
|
|
118
|
+
</button>
|
|
119
|
+
<button
|
|
120
|
+
style={{
|
|
121
|
+
...buttonBaseStyles,
|
|
122
|
+
background: buttonColor,
|
|
123
|
+
color: buttonTextColor,
|
|
124
|
+
}}
|
|
125
|
+
onClick={handleAccept}
|
|
126
|
+
>
|
|
127
|
+
{acceptLabel}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
addPropertyControls(Cookiebanner, {
|
|
135
|
+
message: {
|
|
136
|
+
type: ControlType.String,
|
|
137
|
+
title: "Message",
|
|
138
|
+
defaultValue: "We use cookies to improve your website experience.",
|
|
139
|
+
displayTextArea: true,
|
|
140
|
+
},
|
|
141
|
+
acceptLabel: {
|
|
142
|
+
type: ControlType.String,
|
|
143
|
+
title: "Accept Label",
|
|
144
|
+
defaultValue: "Accept",
|
|
145
|
+
},
|
|
146
|
+
declineLabel: {
|
|
147
|
+
type: ControlType.String,
|
|
148
|
+
title: "Decline Label",
|
|
149
|
+
defaultValue: "Decline",
|
|
150
|
+
},
|
|
151
|
+
backgroundColor: {
|
|
152
|
+
type: ControlType.Color,
|
|
153
|
+
title: "Background",
|
|
154
|
+
defaultValue: "#fff",
|
|
155
|
+
},
|
|
156
|
+
textColor: {
|
|
157
|
+
type: ControlType.Color,
|
|
158
|
+
title: "Text Color",
|
|
159
|
+
defaultValue: "#222",
|
|
160
|
+
},
|
|
161
|
+
buttonColor: {
|
|
162
|
+
type: ControlType.Color,
|
|
163
|
+
title: "Button Color",
|
|
164
|
+
defaultValue: "#111",
|
|
165
|
+
},
|
|
166
|
+
buttonTextColor: {
|
|
167
|
+
type: ControlType.Color,
|
|
168
|
+
title: "Button Text",
|
|
169
|
+
defaultValue: "#fff",
|
|
170
|
+
},
|
|
171
|
+
font: {
|
|
172
|
+
type: ControlType.Font,
|
|
173
|
+
title: "Font",
|
|
174
|
+
controls: "extended",
|
|
175
|
+
defaultFontType: "sans-serif",
|
|
176
|
+
defaultValue: {
|
|
177
|
+
variant: "Medium",
|
|
178
|
+
fontSize: "14px",
|
|
179
|
+
letterSpacing: "-0.01em",
|
|
180
|
+
lineHeight: "1em",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
buttonFont: {
|
|
184
|
+
type: ControlType.Font,
|
|
185
|
+
title: "Font",
|
|
186
|
+
controls: "extended",
|
|
187
|
+
defaultFontType: "sans-serif",
|
|
188
|
+
defaultValue: {
|
|
189
|
+
variant: "Medium",
|
|
190
|
+
fontSize: "14px",
|
|
191
|
+
letterSpacing: "-0.01em",
|
|
192
|
+
lineHeight: "1em",
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
borderRadius: {
|
|
196
|
+
type: ControlType.Number,
|
|
197
|
+
title: "Radius",
|
|
198
|
+
defaultValue: 8,
|
|
199
|
+
min: 0,
|
|
200
|
+
max: 32,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Tweemoji
|
|
206
|
+
|
|
207
|
+
Convert emoji to Twitter's Twemoji SVGs.
|
|
208
|
+
|
|
209
|
+
````tsx
|
|
210
|
+
import { useMemo, useEffect, useState, type CSSProperties } from "react";
|
|
211
|
+
import { addPropertyControls, ControlType, withCSS } from "framer";
|
|
212
|
+
import twemojiParser from "https://jspm.dev/twemoji-parser@14.0.0";
|
|
213
|
+
|
|
214
|
+
const fireSrc =
|
|
215
|
+
"https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/1f525.svg";
|
|
216
|
+
|
|
217
|
+
interface TwemojiProps {
|
|
218
|
+
/** Emoji to convert such as 🍐, 🐙 or 🐸 */
|
|
219
|
+
search?: string;
|
|
220
|
+
isSelection?: boolean;
|
|
221
|
+
[prop: string]: any;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const baseURL = "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/";
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* TWEMOJI
|
|
228
|
+
*
|
|
229
|
+
* Convert any emoji into a Twemoji from Twitter. Choose a preset or type in your emoji and the Twemoji will automatically appear on the canvas.
|
|
230
|
+
*
|
|
231
|
+
* ```jsx
|
|
232
|
+
* <Twemoji search="🍐" />
|
|
233
|
+
* ```
|
|
234
|
+
*
|
|
235
|
+
* @framerIntrinsicWidth 100
|
|
236
|
+
* @framerIntrinsicHeight 100
|
|
237
|
+
*
|
|
238
|
+
* @framerSupportedLayoutWidth fixed
|
|
239
|
+
* @framerSupportedLayoutHeight fixed
|
|
240
|
+
*/
|
|
241
|
+
export default function Twemoji(props: TwemojiProps) {
|
|
242
|
+
const { search, isSelection, selection, style, alt = "" } = props;
|
|
243
|
+
|
|
244
|
+
const emoji = useMemo(() => {
|
|
245
|
+
if (isSelection) return selection;
|
|
246
|
+
if (!search) return "⭐️";
|
|
247
|
+
return search;
|
|
248
|
+
}, [search, isSelection, selection]);
|
|
249
|
+
|
|
250
|
+
const src = useMemo(() => {
|
|
251
|
+
const parsedTwemoji = twemojiParser.parse(emoji, {
|
|
252
|
+
buildUrl: (icon) => `${baseURL}${icon}.svg`,
|
|
253
|
+
});
|
|
254
|
+
return parsedTwemoji[0].url;
|
|
255
|
+
}, [emoji]);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<div style={containerStyle}>
|
|
259
|
+
<img src={src} style={containerStyle} alt={alt} />
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
addPropertyControls<TwemojiProps>(Twemoji, {
|
|
265
|
+
isSelection: {
|
|
266
|
+
type: ControlType.Boolean,
|
|
267
|
+
title: "Select",
|
|
268
|
+
enabledTitle: "Preset",
|
|
269
|
+
disabledTitle: "Search",
|
|
270
|
+
},
|
|
271
|
+
selection: {
|
|
272
|
+
type: ControlType.Enum,
|
|
273
|
+
title: " ",
|
|
274
|
+
options: ["🔥", "💖", "😆", "👍", "👎"],
|
|
275
|
+
defaultValue: "🔥",
|
|
276
|
+
displaySegmentedControl: true,
|
|
277
|
+
hidden: ({ isSelection }) => !isSelection,
|
|
278
|
+
},
|
|
279
|
+
search: {
|
|
280
|
+
type: ControlType.String,
|
|
281
|
+
title: " ",
|
|
282
|
+
placeholder: "Paste Emoji…",
|
|
283
|
+
defaultValue: "⭐️",
|
|
284
|
+
hidden: ({ isSelection }) => isSelection,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const containerStyle: CSSProperties = {
|
|
289
|
+
height: "100%",
|
|
290
|
+
width: "100%",
|
|
291
|
+
objectFit: "contain",
|
|
292
|
+
textAlign: "center",
|
|
293
|
+
overflow: "hidden",
|
|
294
|
+
backgroundColor: "transparent",
|
|
295
|
+
};
|
|
296
|
+
````
|
|
297
|
+
|
|
298
|
+
## Image Compare
|
|
299
|
+
|
|
300
|
+
Before/after image comparison slider.
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
import {
|
|
304
|
+
addPropertyControls,
|
|
305
|
+
ControlType,
|
|
306
|
+
RenderTarget,
|
|
307
|
+
useIsStaticRenderer,
|
|
308
|
+
} from "framer";
|
|
309
|
+
import {
|
|
310
|
+
useCallback,
|
|
311
|
+
useEffect,
|
|
312
|
+
useRef,
|
|
313
|
+
useState,
|
|
314
|
+
startTransition,
|
|
315
|
+
type CSSProperties,
|
|
316
|
+
} from "react";
|
|
317
|
+
|
|
318
|
+
interface Image {
|
|
319
|
+
src: string;
|
|
320
|
+
alt: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
interface ImageCompareProps {
|
|
324
|
+
beforeImage: Image;
|
|
325
|
+
afterImage: Image;
|
|
326
|
+
orientation: "horizontal" | "vertical";
|
|
327
|
+
initialPosition: number;
|
|
328
|
+
dividerColor: string;
|
|
329
|
+
dividerWidth: number;
|
|
330
|
+
dividerShadow: boolean;
|
|
331
|
+
showHandle: boolean;
|
|
332
|
+
handleColor: string;
|
|
333
|
+
handleSize: number;
|
|
334
|
+
style?: CSSProperties;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Image Comparison Slider
|
|
339
|
+
*
|
|
340
|
+
* A component that allows users to compare two images by dragging a divider.
|
|
341
|
+
*
|
|
342
|
+
* @framerIntrinsicWidth 500
|
|
343
|
+
* @framerIntrinsicHeight 300
|
|
344
|
+
*
|
|
345
|
+
* @framerSupportedLayoutWidth fixed
|
|
346
|
+
* @framerSupportedLayoutHeight fixed
|
|
347
|
+
*/
|
|
348
|
+
export default function ImageCompare(props: ImageCompareProps) {
|
|
349
|
+
const {
|
|
350
|
+
beforeImage = {
|
|
351
|
+
src: "https://framerusercontent.com/images/GfGkADagM4KEibNcIiRUWlfrR0.jpg",
|
|
352
|
+
alt: "Before image",
|
|
353
|
+
},
|
|
354
|
+
afterImage = {
|
|
355
|
+
src: "https://framerusercontent.com/images/aNsAT3jCvt4zglbWCUoFe33Q.jpg",
|
|
356
|
+
alt: "After image",
|
|
357
|
+
},
|
|
358
|
+
orientation = "horizontal",
|
|
359
|
+
initialPosition = 50,
|
|
360
|
+
dividerColor = "#FFFFFF",
|
|
361
|
+
dividerWidth = 2,
|
|
362
|
+
dividerShadow = true,
|
|
363
|
+
showHandle = false,
|
|
364
|
+
handleColor = "#FFFFFF",
|
|
365
|
+
handleSize = 40,
|
|
366
|
+
} = props;
|
|
367
|
+
|
|
368
|
+
const isHorizontal = orientation === "horizontal";
|
|
369
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
370
|
+
const [position, setPosition] = useState(initialPosition);
|
|
371
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
372
|
+
const isStatic = useIsStaticRenderer();
|
|
373
|
+
|
|
374
|
+
const updatePositionFromEvent = useCallback(
|
|
375
|
+
(e) => {
|
|
376
|
+
if (!containerRef.current) return;
|
|
377
|
+
|
|
378
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
379
|
+
|
|
380
|
+
if (isHorizontal) {
|
|
381
|
+
const x = e.clientX - rect.left;
|
|
382
|
+
const newPosition = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
|
383
|
+
startTransition(() => setPosition(newPosition));
|
|
384
|
+
} else {
|
|
385
|
+
const y = e.clientY - rect.top;
|
|
386
|
+
const newPosition = Math.max(0, Math.min(100, (y / rect.height) * 100));
|
|
387
|
+
startTransition(() => setPosition(newPosition));
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
[isHorizontal],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const handleClick = useCallback(
|
|
394
|
+
(e) => {
|
|
395
|
+
// Only handle as a click if we're not dragging
|
|
396
|
+
if (!isDragging) {
|
|
397
|
+
updatePositionFromEvent(e);
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
[isDragging, updatePositionFromEvent],
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const handleDoubleClick = () => {
|
|
404
|
+
startTransition(() => setPosition(initialPosition));
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const handleMouseDown = (e) => {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
startTransition(() => setIsDragging(true));
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const handleMouseMove = useCallback(
|
|
413
|
+
(e) => {
|
|
414
|
+
if (!isDragging || !containerRef.current) return;
|
|
415
|
+
updatePositionFromEvent(e);
|
|
416
|
+
},
|
|
417
|
+
[isDragging, updatePositionFromEvent],
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const handleMouseUp = useCallback(() => {
|
|
421
|
+
startTransition(() => setIsDragging(false));
|
|
422
|
+
}, []);
|
|
423
|
+
|
|
424
|
+
// Add global event listeners for drag
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
if (isStatic) return;
|
|
427
|
+
|
|
428
|
+
const handleGlobalMouseMove = (e) => handleMouseMove(e);
|
|
429
|
+
const handleGlobalMouseUp = () => handleMouseUp();
|
|
430
|
+
|
|
431
|
+
if (isDragging) {
|
|
432
|
+
window.addEventListener("mousemove", handleGlobalMouseMove);
|
|
433
|
+
window.addEventListener("mouseup", handleGlobalMouseUp);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return () => {
|
|
437
|
+
window.removeEventListener("mousemove", handleGlobalMouseMove);
|
|
438
|
+
window.removeEventListener("mouseup", handleGlobalMouseUp);
|
|
439
|
+
};
|
|
440
|
+
}, [isDragging, handleMouseMove, handleMouseUp, isStatic]);
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<div
|
|
444
|
+
ref={containerRef}
|
|
445
|
+
style={{
|
|
446
|
+
position: "relative",
|
|
447
|
+
width: "100%",
|
|
448
|
+
height: "100%",
|
|
449
|
+
overflow: "hidden",
|
|
450
|
+
cursor: isDragging
|
|
451
|
+
? isHorizontal
|
|
452
|
+
? "ew-resize"
|
|
453
|
+
: "ns-resize"
|
|
454
|
+
: "pointer",
|
|
455
|
+
userSelect: "none",
|
|
456
|
+
}}
|
|
457
|
+
onClick={isStatic ? undefined : handleClick}
|
|
458
|
+
onMouseMove={isStatic ? undefined : handleMouseMove}
|
|
459
|
+
onMouseDown={isStatic ? undefined : handleMouseDown}
|
|
460
|
+
onMouseUp={isStatic ? undefined : handleMouseUp}
|
|
461
|
+
onDoubleClick={handleDoubleClick}
|
|
462
|
+
onKeyDown={(e) => {
|
|
463
|
+
if (e.key === " " || e.key === "Enter") {
|
|
464
|
+
handleClick(e);
|
|
465
|
+
}
|
|
466
|
+
}}
|
|
467
|
+
tabIndex={0}
|
|
468
|
+
role="slider"
|
|
469
|
+
aria-valuenow={position}
|
|
470
|
+
aria-valuemin={0}
|
|
471
|
+
aria-valuemax={100}
|
|
472
|
+
aria-orientation={orientation}
|
|
473
|
+
>
|
|
474
|
+
{/* After Image (Full) */}
|
|
475
|
+
<div
|
|
476
|
+
style={{
|
|
477
|
+
position: "absolute",
|
|
478
|
+
top: 0,
|
|
479
|
+
left: 0,
|
|
480
|
+
width: "100%",
|
|
481
|
+
height: "100%",
|
|
482
|
+
backgroundImage: `url(${afterImage.src})`,
|
|
483
|
+
backgroundSize: "cover",
|
|
484
|
+
backgroundPosition: "center",
|
|
485
|
+
}}
|
|
486
|
+
aria-label={afterImage.alt}
|
|
487
|
+
role="img"
|
|
488
|
+
/>
|
|
489
|
+
|
|
490
|
+
{/* Before Image (Clipped) */}
|
|
491
|
+
<div
|
|
492
|
+
style={{
|
|
493
|
+
position: "absolute",
|
|
494
|
+
top: 0,
|
|
495
|
+
left: 0,
|
|
496
|
+
width: "100%",
|
|
497
|
+
height: "100%",
|
|
498
|
+
backgroundImage: `url(${beforeImage.src})`,
|
|
499
|
+
backgroundSize: "cover",
|
|
500
|
+
backgroundPosition: "center",
|
|
501
|
+
clipPath: isHorizontal
|
|
502
|
+
? `inset(0 ${100 - position}% 0 0)`
|
|
503
|
+
: `inset(0 0 ${100 - position}% 0)`,
|
|
504
|
+
}}
|
|
505
|
+
aria-label={beforeImage.alt}
|
|
506
|
+
role="img"
|
|
507
|
+
/>
|
|
508
|
+
|
|
509
|
+
{/* Divider */}
|
|
510
|
+
<div
|
|
511
|
+
style={{
|
|
512
|
+
position: "absolute",
|
|
513
|
+
top: isHorizontal ? 0 : `${position}%`,
|
|
514
|
+
left: isHorizontal ? `${position}%` : 0,
|
|
515
|
+
width: isHorizontal ? `${dividerWidth}px` : "100%",
|
|
516
|
+
height: isHorizontal ? "100%" : `${dividerWidth}px`,
|
|
517
|
+
backgroundColor: dividerColor,
|
|
518
|
+
boxShadow: dividerShadow ? "0 0 5px rgba(0, 0, 0, 0.7)" : "none",
|
|
519
|
+
transform: isHorizontal
|
|
520
|
+
? `translateX(-${dividerWidth / 2}px)`
|
|
521
|
+
: `translateY(-${dividerWidth / 2}px)`,
|
|
522
|
+
cursor: isHorizontal ? "ew-resize" : "ns-resize",
|
|
523
|
+
zIndex: 2,
|
|
524
|
+
}}
|
|
525
|
+
onMouseDown={isStatic ? undefined : handleMouseDown}
|
|
526
|
+
/>
|
|
527
|
+
|
|
528
|
+
{/* Handle */}
|
|
529
|
+
{showHandle && (
|
|
530
|
+
<div
|
|
531
|
+
style={{
|
|
532
|
+
position: "absolute",
|
|
533
|
+
top: isHorizontal
|
|
534
|
+
? `calc(50% - ${handleSize / 2}px)`
|
|
535
|
+
: `${position}%`,
|
|
536
|
+
left: isHorizontal
|
|
537
|
+
? `${position}%`
|
|
538
|
+
: `calc(50% - ${handleSize / 2}px)`,
|
|
539
|
+
width: `${handleSize}px`,
|
|
540
|
+
height: `${handleSize}px`,
|
|
541
|
+
borderRadius: "50%",
|
|
542
|
+
backgroundColor: handleColor,
|
|
543
|
+
border: `2px solid ${handleColor}`,
|
|
544
|
+
boxShadow: "0 0 5px rgba(0, 0, 0, 0.5)",
|
|
545
|
+
transform: isHorizontal
|
|
546
|
+
? `translateX(-${handleSize / 2}px)`
|
|
547
|
+
: `translateY(-${handleSize / 2}px)`,
|
|
548
|
+
cursor: isHorizontal ? "ew-resize" : "ns-resize",
|
|
549
|
+
zIndex: 3,
|
|
550
|
+
display: "flex",
|
|
551
|
+
justifyContent: "center",
|
|
552
|
+
alignItems: "center",
|
|
553
|
+
}}
|
|
554
|
+
onMouseDown={isStatic ? undefined : handleMouseDown}
|
|
555
|
+
>
|
|
556
|
+
<div
|
|
557
|
+
style={{
|
|
558
|
+
display: "flex",
|
|
559
|
+
justifyContent: "center",
|
|
560
|
+
alignItems: "center",
|
|
561
|
+
width: "100%",
|
|
562
|
+
height: "100%",
|
|
563
|
+
transform: isHorizontal ? "rotate(90deg)" : "rotate(0deg)",
|
|
564
|
+
}}
|
|
565
|
+
>
|
|
566
|
+
<svg
|
|
567
|
+
viewBox="0 0 24 24"
|
|
568
|
+
width={handleSize * 0.5}
|
|
569
|
+
height={handleSize * 0.5}
|
|
570
|
+
strokeWidth="2"
|
|
571
|
+
stroke="#000"
|
|
572
|
+
fill="none"
|
|
573
|
+
aria-label="Drag handle"
|
|
574
|
+
>
|
|
575
|
+
<title>Drag handle</title>
|
|
576
|
+
<path d="M13 5l6 6m-6 6l6-6m-6 0l-6 6m6-6l-6-6" />
|
|
577
|
+
</svg>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
</div>
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
addPropertyControls(ImageCompare, {
|
|
586
|
+
beforeImage: {
|
|
587
|
+
type: ControlType.ResponsiveImage,
|
|
588
|
+
title: "Before Image",
|
|
589
|
+
},
|
|
590
|
+
afterImage: {
|
|
591
|
+
type: ControlType.ResponsiveImage,
|
|
592
|
+
title: "After Image",
|
|
593
|
+
},
|
|
594
|
+
orientation: {
|
|
595
|
+
type: ControlType.Enum,
|
|
596
|
+
title: "Orientation",
|
|
597
|
+
options: ["horizontal", "vertical"],
|
|
598
|
+
optionTitles: ["Horizontal", "Vertical"],
|
|
599
|
+
defaultValue: "horizontal",
|
|
600
|
+
displaySegmentedControl: true,
|
|
601
|
+
},
|
|
602
|
+
initialPosition: {
|
|
603
|
+
type: ControlType.Number,
|
|
604
|
+
title: "Initial Position",
|
|
605
|
+
defaultValue: 50,
|
|
606
|
+
min: 0,
|
|
607
|
+
max: 100,
|
|
608
|
+
step: 1,
|
|
609
|
+
unit: "%",
|
|
610
|
+
},
|
|
611
|
+
dividerColor: {
|
|
612
|
+
type: ControlType.Color,
|
|
613
|
+
title: "Divider Color",
|
|
614
|
+
defaultValue: "#FFFFFF",
|
|
615
|
+
},
|
|
616
|
+
dividerWidth: {
|
|
617
|
+
type: ControlType.Number,
|
|
618
|
+
title: "Divider Width",
|
|
619
|
+
defaultValue: 2,
|
|
620
|
+
min: 1,
|
|
621
|
+
max: 20,
|
|
622
|
+
step: 1,
|
|
623
|
+
unit: "px",
|
|
624
|
+
},
|
|
625
|
+
dividerShadow: {
|
|
626
|
+
type: ControlType.Boolean,
|
|
627
|
+
title: "Divider Shadow",
|
|
628
|
+
defaultValue: true,
|
|
629
|
+
enabledTitle: "On",
|
|
630
|
+
disabledTitle: "Off",
|
|
631
|
+
},
|
|
632
|
+
showHandle: {
|
|
633
|
+
type: ControlType.Boolean,
|
|
634
|
+
title: "Show Handle",
|
|
635
|
+
defaultValue: false,
|
|
636
|
+
enabledTitle: "Show",
|
|
637
|
+
disabledTitle: "Hide",
|
|
638
|
+
},
|
|
639
|
+
handleColor: {
|
|
640
|
+
type: ControlType.Color,
|
|
641
|
+
title: "Handle Color",
|
|
642
|
+
defaultValue: "#FFFFFF",
|
|
643
|
+
hidden: ({ showHandle }) => !showHandle,
|
|
644
|
+
},
|
|
645
|
+
handleSize: {
|
|
646
|
+
type: ControlType.Number,
|
|
647
|
+
title: "Handle Size",
|
|
648
|
+
defaultValue: 40,
|
|
649
|
+
min: 20,
|
|
650
|
+
max: 80,
|
|
651
|
+
step: 1,
|
|
652
|
+
unit: "px",
|
|
653
|
+
hidden: ({ showHandle }) => !showHandle,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
## Notes (Sticky Note)
|
|
659
|
+
|
|
660
|
+
Colorful sticky note with font options.
|
|
661
|
+
|
|
662
|
+
```tsx
|
|
663
|
+
import { type MouseEventHandler, type CSSProperties, useMemo } from "react";
|
|
664
|
+
import { addPropertyControls, ControlType, RenderTarget, Color } from "framer";
|
|
665
|
+
|
|
666
|
+
const colors = {
|
|
667
|
+
blue: "#0099FF",
|
|
668
|
+
darkBlue: "#0066FF",
|
|
669
|
+
purple: "#8855FF",
|
|
670
|
+
red: "#FF5588",
|
|
671
|
+
green: "#22CC66",
|
|
672
|
+
yellow: "#FFBB00",
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
interface NotesProps {
|
|
676
|
+
note: string;
|
|
677
|
+
shadow: boolean;
|
|
678
|
+
color: string;
|
|
679
|
+
preview: boolean;
|
|
680
|
+
alignment: "left" | "center" | "right";
|
|
681
|
+
smallFont: boolean;
|
|
682
|
+
onClick?: MouseEventHandler<HTMLDivElement>;
|
|
683
|
+
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
|
|
684
|
+
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
|
685
|
+
onMouseDown?: MouseEventHandler<HTMLDivElement>;
|
|
686
|
+
onMouseUp?: MouseEventHandler<HTMLDivElement>;
|
|
687
|
+
useScriptFont: boolean;
|
|
688
|
+
font: CSSProperties;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* STICKY
|
|
693
|
+
*
|
|
694
|
+
* @framerIntrinsicWidth 150
|
|
695
|
+
* @framerIntrinsicHeight 150
|
|
696
|
+
*
|
|
697
|
+
* @framerSupportedLayoutWidth any-prefer-fixed
|
|
698
|
+
* @framerSupportedLayoutHeight any-prefer-fixed
|
|
699
|
+
*/
|
|
700
|
+
export default function Notes(props: NotesProps) {
|
|
701
|
+
const {
|
|
702
|
+
note = "",
|
|
703
|
+
shadow,
|
|
704
|
+
color,
|
|
705
|
+
preview,
|
|
706
|
+
alignment,
|
|
707
|
+
smallFont,
|
|
708
|
+
onClick,
|
|
709
|
+
onMouseEnter,
|
|
710
|
+
onMouseLeave,
|
|
711
|
+
onMouseDown,
|
|
712
|
+
onMouseUp,
|
|
713
|
+
useScriptFont,
|
|
714
|
+
font,
|
|
715
|
+
} = props;
|
|
716
|
+
|
|
717
|
+
const [baseColorString, backgroundColorString] = useMemo(() => {
|
|
718
|
+
const baseColor = Color(colors[color]);
|
|
719
|
+
const hslColor = Color.toHsl(baseColor);
|
|
720
|
+
hslColor.l = 0.95;
|
|
721
|
+
|
|
722
|
+
const baseColorString = Color(colors[color]).toValue();
|
|
723
|
+
const backgroundColorString = Color(hslColor).toValue();
|
|
724
|
+
|
|
725
|
+
return [baseColorString, backgroundColorString];
|
|
726
|
+
}, [color]);
|
|
727
|
+
|
|
728
|
+
const centerAligned = alignment === "center";
|
|
729
|
+
const hasContent = note.length > 0;
|
|
730
|
+
|
|
731
|
+
return (
|
|
732
|
+
<div
|
|
733
|
+
style={{
|
|
734
|
+
flex: 1,
|
|
735
|
+
width: "100%",
|
|
736
|
+
height: "100%",
|
|
737
|
+
display: "flex",
|
|
738
|
+
alignItems: centerAligned ? "center" : "flex-start",
|
|
739
|
+
backgroundColor: backgroundColorString,
|
|
740
|
+
overflow: "hidden",
|
|
741
|
+
paddingLeft: smallFont ? 15 : 18,
|
|
742
|
+
paddingTop: useScriptFont ? 12 : 14,
|
|
743
|
+
paddingBottom: useScriptFont ? 12 : 14,
|
|
744
|
+
paddingRight: smallFont ? 15 : 18,
|
|
745
|
+
borderRadius: 8,
|
|
746
|
+
visibility:
|
|
747
|
+
RenderTarget.current() === RenderTarget.preview && !preview
|
|
748
|
+
? "hidden"
|
|
749
|
+
: "visible",
|
|
750
|
+
...(useScriptFont ? { fontFamily: "Nanum Pen Script" } : font),
|
|
751
|
+
//@ts-ignore
|
|
752
|
+
fontDisplay: "fallback",
|
|
753
|
+
boxShadow: shadow ? "0 4px 10px rgba(0,0,0,0.08)" : "none",
|
|
754
|
+
}}
|
|
755
|
+
{...{ onClick, onMouseEnter, onMouseLeave, onMouseDown, onMouseUp }}
|
|
756
|
+
>
|
|
757
|
+
{useScriptFont && (
|
|
758
|
+
<link
|
|
759
|
+
href="https://fonts.googleapis.com/css?family=Nanum+Pen+Script&display=swap"
|
|
760
|
+
rel="stylesheet"
|
|
761
|
+
/>
|
|
762
|
+
)}
|
|
763
|
+
<p
|
|
764
|
+
style={{
|
|
765
|
+
width: "max-content",
|
|
766
|
+
wordBreak: "break-word",
|
|
767
|
+
overflowWrap: "break-word",
|
|
768
|
+
overflow: "hidden",
|
|
769
|
+
whiteSpace: "pre-wrap",
|
|
770
|
+
margin: 0,
|
|
771
|
+
fontSize: smallFont
|
|
772
|
+
? useScriptFont
|
|
773
|
+
? 18
|
|
774
|
+
: 12
|
|
775
|
+
: useScriptFont
|
|
776
|
+
? 32
|
|
777
|
+
: 24,
|
|
778
|
+
|
|
779
|
+
lineHeight: smallFont
|
|
780
|
+
? useScriptFont
|
|
781
|
+
? 1.15
|
|
782
|
+
: 1.4
|
|
783
|
+
: useScriptFont
|
|
784
|
+
? 1.08
|
|
785
|
+
: 1.3,
|
|
786
|
+
textAlign: alignment,
|
|
787
|
+
color: baseColorString,
|
|
788
|
+
display: "-webkit-box",
|
|
789
|
+
opacity: hasContent ? 1 : 0.5,
|
|
790
|
+
WebkitBoxOrient: "vertical",
|
|
791
|
+
}}
|
|
792
|
+
>
|
|
793
|
+
{hasContent ? note : "Write something..."}
|
|
794
|
+
</p>
|
|
795
|
+
</div>
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
addPropertyControls(Notes, {
|
|
800
|
+
note: {
|
|
801
|
+
type: ControlType.String,
|
|
802
|
+
displayTextArea: true,
|
|
803
|
+
placeholder: `Write something… \n\n\n`,
|
|
804
|
+
},
|
|
805
|
+
color: {
|
|
806
|
+
type: ControlType.Enum,
|
|
807
|
+
defaultValue: "blue",
|
|
808
|
+
options: Object.keys(colors),
|
|
809
|
+
optionTitles: Object.keys(colors).map((c) =>
|
|
810
|
+
c.replace(/^\w/, (c) => c.toUpperCase()),
|
|
811
|
+
),
|
|
812
|
+
},
|
|
813
|
+
|
|
814
|
+
alignment: {
|
|
815
|
+
title: "Text Align",
|
|
816
|
+
type: ControlType.Enum,
|
|
817
|
+
displaySegmentedControl: true,
|
|
818
|
+
optionTitles: ["Left", "Center", "Right"],
|
|
819
|
+
options: ["left", "center", "right"],
|
|
820
|
+
},
|
|
821
|
+
useScriptFont: {
|
|
822
|
+
type: ControlType.Boolean,
|
|
823
|
+
disabledTitle: "Custom",
|
|
824
|
+
enabledTitle: "Script",
|
|
825
|
+
title: "Font",
|
|
826
|
+
defaultTitle: true,
|
|
827
|
+
},
|
|
828
|
+
font: {
|
|
829
|
+
type: ControlType.Font,
|
|
830
|
+
defaultFontType: "sans-serif",
|
|
831
|
+
controls: "extended",
|
|
832
|
+
hidden: ({ useScriptFont }) => useScriptFont,
|
|
833
|
+
},
|
|
834
|
+
smallFont: {
|
|
835
|
+
type: ControlType.Boolean,
|
|
836
|
+
disabledTitle: "Big",
|
|
837
|
+
enabledTitle: "Small",
|
|
838
|
+
title: "Text Size",
|
|
839
|
+
defaultValue: true,
|
|
840
|
+
},
|
|
841
|
+
preview: {
|
|
842
|
+
type: ControlType.Boolean,
|
|
843
|
+
defaultValue: true,
|
|
844
|
+
title: "In Preview",
|
|
845
|
+
enabledTitle: "Show",
|
|
846
|
+
disabledTitle: "Hide",
|
|
847
|
+
},
|
|
848
|
+
shadow: {
|
|
849
|
+
type: ControlType.Boolean,
|
|
850
|
+
defaultValue: false,
|
|
851
|
+
title: "Shadow",
|
|
852
|
+
enabledTitle: "Show",
|
|
853
|
+
disabledTitle: "Hide",
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
Notes.displayName = "Sticky Note";
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
## Key Patterns Demonstrated
|
|
861
|
+
|
|
862
|
+
1. **SSR Safety**: `if (typeof window !== "undefined")` guards
|
|
863
|
+
2. **State Transitions**: `setState` wrapped in `startTransition()` for smooth interactions
|
|
864
|
+
3. **Static Renderer**: `useIsStaticRenderer()` to skip animations on canvas
|
|
865
|
+
4. **Image Defaults**: Set in destructuring, not in property controls
|
|
866
|
+
5. **Font Controls**: `controls: "extended"` with `defaultFontType: "sans-serif"` for full typography customization
|
|
867
|
+
6. **Conditional Controls**: `hidden: (props) => !props.showFeature`
|
|
868
|
+
7. **Accessibility**: `role`, `aria-*`, semantic HTML, keyboard support
|
|
869
|
+
8. **Color Utilities**: Using `Color` from framer for color manipulation
|