@teamofsilicons/silicon-ui 0.0.0-stage → 0.2.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/LICENSE +22 -0
- package/README.md +81 -2
- package/check.mjs +236 -0
- package/components/accordion/accordion.d.ts +13 -0
- package/components/accordion/accordion.js +35 -0
- package/components/accordion/accordion.module.css +15 -0
- package/components/action-button/action-button.d.ts +11 -0
- package/components/action-button/action-button.js +57 -0
- package/components/action-button/action-button.module.css +15 -0
- package/components/activity-heatmap/activity-heatmap.d.ts +37 -0
- package/components/activity-heatmap/activity-heatmap.js +303 -0
- package/components/activity-heatmap/activity-heatmap.module.css +83 -0
- package/components/alert/alert.d.ts +13 -0
- package/components/alert/alert.js +79 -0
- package/components/alert/alert.module.css +15 -0
- package/components/animated-counter/animated-counter.d.ts +13 -0
- package/components/animated-counter/animated-counter.js +73 -0
- package/components/animated-counter/animated-counter.module.css +12 -0
- package/components/announcement-bar/announcement-bar.d.ts +53 -0
- package/components/announcement-bar/announcement-bar.js +187 -0
- package/components/announcement-bar/announcement-bar.module.css +60 -0
- package/components/avatar/avatar.d.ts +8 -0
- package/components/avatar/avatar.js +18 -0
- package/components/avatar/avatar.module.css +60 -0
- package/components/avatar-group/avatar-group.d.ts +13 -0
- package/components/avatar-group/avatar-group.js +24 -0
- package/components/avatar-group/avatar-group.module.css +27 -0
- package/components/badge/badge.d.ts +10 -0
- package/components/badge/badge.js +66 -0
- package/components/badge/badge.module.css +52 -0
- package/components/bar-chart/bar-chart.d.ts +32 -0
- package/components/bar-chart/bar-chart.js +327 -0
- package/components/bar-chart/bar-chart.module.css +49 -0
- package/components/billing-toggle/billing-toggle.d.ts +35 -0
- package/components/billing-toggle/billing-toggle.js +83 -0
- package/components/billing-toggle/billing-toggle.module.css +139 -0
- package/components/bottom-sheet/bottom-sheet.d.ts +27 -0
- package/components/bottom-sheet/bottom-sheet.js +386 -0
- package/components/bottom-sheet/bottom-sheet.module.css +69 -0
- package/components/breadcrumb/breadcrumb.d.ts +13 -0
- package/components/breadcrumb/breadcrumb.js +19 -0
- package/components/breadcrumb/breadcrumb.module.css +12 -0
- package/components/brush-chart/brush-chart.d.ts +45 -0
- package/components/brush-chart/brush-chart.js +426 -0
- package/components/brush-chart/brush-chart.module.css +69 -0
- package/components/button/button.d.ts +10 -0
- package/components/button/button.js +97 -0
- package/components/button/button.module.css +39 -0
- package/components/button-group/button-group.d.ts +59 -0
- package/components/button-group/button-group.js +280 -0
- package/components/button-group/button-group.module.css +87 -0
- package/components/button-link/button-link.d.ts +24 -0
- package/components/button-link/button-link.js +11 -0
- package/components/button-link/button-link.module.css +7 -0
- package/components/calendar/calendar.d.ts +22 -0
- package/components/calendar/calendar.js +253 -0
- package/components/calendar/calendar.module.css +51 -0
- package/components/card/card.d.ts +19 -0
- package/components/card/card.js +108 -0
- package/components/card/card.module.css +51 -0
- package/components/card-stack/card-stack.d.ts +34 -0
- package/components/card-stack/card-stack.js +291 -0
- package/components/card-stack/card-stack.module.css +63 -0
- package/components/carousel/carousel.d.ts +28 -0
- package/components/carousel/carousel.js +332 -0
- package/components/carousel/carousel.module.css +42 -0
- package/components/chat-thread/chat-thread.d.ts +135 -0
- package/components/chat-thread/chat-thread.js +406 -0
- package/components/chat-thread/chat-thread.module.css +242 -0
- package/components/checkbox/checkbox.d.ts +7 -0
- package/components/checkbox/checkbox.js +23 -0
- package/components/checkbox/checkbox.module.css +15 -0
- package/components/chip-group/chip-group.d.ts +24 -0
- package/components/chip-group/chip-group.js +196 -0
- package/components/chip-group/chip-group.module.css +38 -0
- package/components/code-block/code-block.d.ts +11 -0
- package/components/code-block/code-block.js +133 -0
- package/components/code-block/code-block.module.css +43 -0
- package/components/color-picker/color-picker.d.ts +40 -0
- package/components/color-picker/color-picker.js +449 -0
- package/components/color-picker/color-picker.module.css +111 -0
- package/components/combobox/combobox.d.ts +19 -0
- package/components/combobox/combobox.js +167 -0
- package/components/combobox/combobox.module.css +28 -0
- package/components/command-palette/command-palette.d.ts +20 -0
- package/components/command-palette/command-palette.js +151 -0
- package/components/command-palette/command-palette.module.css +94 -0
- package/components/comment-thread/comment-thread.d.ts +120 -0
- package/components/comment-thread/comment-thread.js +343 -0
- package/components/comment-thread/comment-thread.module.css +195 -0
- package/components/confirm-morph/confirm-morph.d.ts +65 -0
- package/components/confirm-morph/confirm-morph.js +355 -0
- package/components/confirm-morph/confirm-morph.module.css +146 -0
- package/components/context-menu/context-menu.d.ts +18 -0
- package/components/context-menu/context-menu.js +148 -0
- package/components/context-menu/context-menu.module.css +17 -0
- package/components/copy-button/copy-button.d.ts +10 -0
- package/components/copy-button/copy-button.js +69 -0
- package/components/copy-button/copy-button.module.css +21 -0
- package/components/countdown/countdown.d.ts +31 -0
- package/components/countdown/countdown.js +177 -0
- package/components/countdown/countdown.module.css +49 -0
- package/components/date-picker/date-picker.d.ts +17 -0
- package/components/date-picker/date-picker.js +100 -0
- package/components/date-picker/date-picker.module.css +34 -0
- package/components/date-range-picker/date-range-picker.d.ts +44 -0
- package/components/date-range-picker/date-range-picker.js +534 -0
- package/components/date-range-picker/date-range-picker.module.css +214 -0
- package/components/dialog/dialog.d.ts +11 -0
- package/components/dialog/dialog.js +52 -0
- package/components/dialog/dialog.module.css +36 -0
- package/components/donut-chart/donut-chart.d.ts +46 -0
- package/components/donut-chart/donut-chart.js +382 -0
- package/components/donut-chart/donut-chart.module.css +48 -0
- package/components/drawer/drawer.d.ts +20 -0
- package/components/drawer/drawer.js +111 -0
- package/components/drawer/drawer.module.css +97 -0
- package/components/dropdown-menu/dropdown-menu.d.ts +16 -0
- package/components/dropdown-menu/dropdown-menu.js +39 -0
- package/components/dropdown-menu/dropdown-menu.module.css +38 -0
- package/components/elastic-slider/elastic-slider.d.ts +39 -0
- package/components/elastic-slider/elastic-slider.js +261 -0
- package/components/elastic-slider/elastic-slider.module.css +40 -0
- package/components/empty-state/empty-state.d.ts +12 -0
- package/components/empty-state/empty-state.js +69 -0
- package/components/empty-state/empty-state.module.css +13 -0
- package/components/expandable-card/expandable-card.d.ts +13 -0
- package/components/expandable-card/expandable-card.js +89 -0
- package/components/expandable-card/expandable-card.module.css +22 -0
- package/components/expanding-button-group/expanding-button-group.d.ts +41 -0
- package/components/expanding-button-group/expanding-button-group.js +258 -0
- package/components/expanding-button-group/expanding-button-group.module.css +44 -0
- package/components/expanding-search/expanding-search.d.ts +38 -0
- package/components/expanding-search/expanding-search.js +242 -0
- package/components/expanding-search/expanding-search.module.css +52 -0
- package/components/file-dropzone/file-dropzone.d.ts +44 -0
- package/components/file-dropzone/file-dropzone.js +379 -0
- package/components/file-dropzone/file-dropzone.module.css +115 -0
- package/components/file-upload/file-upload.d.ts +22 -0
- package/components/file-upload/file-upload.js +192 -0
- package/components/file-upload/file-upload.module.css +49 -0
- package/components/filter-toolbar/filter-toolbar.d.ts +48 -0
- package/components/filter-toolbar/filter-toolbar.js +446 -0
- package/components/filter-toolbar/filter-toolbar.module.css +70 -0
- package/components/floating-button-group/floating-button-group.d.ts +51 -0
- package/components/floating-button-group/floating-button-group.js +215 -0
- package/components/floating-button-group/floating-button-group.module.css +74 -0
- package/components/gauge/gauge.d.ts +18 -0
- package/components/gauge/gauge.js +101 -0
- package/components/gauge/gauge.module.css +22 -0
- package/components/hold-to-confirm/hold-to-confirm.d.ts +25 -0
- package/components/hold-to-confirm/hold-to-confirm.js +165 -0
- package/components/hold-to-confirm/hold-to-confirm.module.css +20 -0
- package/components/hover-card/hover-card.d.ts +38 -0
- package/components/hover-card/hover-card.js +145 -0
- package/components/hover-card/hover-card.module.css +20 -0
- package/components/image-compare/image-compare.d.ts +27 -0
- package/components/image-compare/image-compare.js +267 -0
- package/components/image-compare/image-compare.module.css +25 -0
- package/components/in-view-title/in-view-title.d.ts +17 -0
- package/components/in-view-title/in-view-title.js +70 -0
- package/components/in-view-title/in-view-title.module.css +13 -0
- package/components/inline-edit/inline-edit.d.ts +27 -0
- package/components/inline-edit/inline-edit.js +315 -0
- package/components/inline-edit/inline-edit.module.css +73 -0
- package/components/input/input.d.ts +7 -0
- package/components/input/input.js +60 -0
- package/components/input/input.module.css +22 -0
- package/components/json-viewer/json-viewer.d.ts +48 -0
- package/components/json-viewer/json-viewer.js +463 -0
- package/components/json-viewer/json-viewer.module.css +92 -0
- package/components/line-chart/line-chart.d.ts +58 -0
- package/components/line-chart/line-chart.js +383 -0
- package/components/line-chart/line-chart.module.css +60 -0
- package/components/mention-input/mention-input.d.ts +72 -0
- package/components/mention-input/mention-input.js +417 -0
- package/components/mention-input/mention-input.module.css +134 -0
- package/components/metric-card/metric-card.d.ts +8 -0
- package/components/metric-card/metric-card.js +44 -0
- package/components/metric-card/metric-card.module.css +18 -0
- package/components/money-input/money-input.d.ts +48 -0
- package/components/money-input/money-input.js +503 -0
- package/components/money-input/money-input.module.css +77 -0
- package/components/morph-select/morph-select.d.ts +50 -0
- package/components/morph-select/morph-select.js +395 -0
- package/components/morph-select/morph-select.module.css +88 -0
- package/components/multi-select/multi-select.d.ts +18 -0
- package/components/multi-select/multi-select.js +105 -0
- package/components/multi-select/multi-select.module.css +37 -0
- package/components/notification-center/notification-center.d.ts +24 -0
- package/components/notification-center/notification-center.js +146 -0
- package/components/notification-center/notification-center.module.css +79 -0
- package/components/number-field/number-field.d.ts +41 -0
- package/components/number-field/number-field.js +455 -0
- package/components/number-field/number-field.module.css +71 -0
- package/components/otp-input/otp-input.d.ts +13 -0
- package/components/otp-input/otp-input.js +129 -0
- package/components/otp-input/otp-input.module.css +28 -0
- package/components/pagination/pagination.d.ts +7 -0
- package/components/pagination/pagination.js +60 -0
- package/components/pagination/pagination.module.css +16 -0
- package/components/password-field/password-field.d.ts +14 -0
- package/components/password-field/password-field.js +51 -0
- package/components/password-field/password-field.module.css +27 -0
- package/components/password-strength/password-strength.d.ts +107 -0
- package/components/password-strength/password-strength.js +161 -0
- package/components/password-strength/password-strength.module.css +57 -0
- package/components/phone-input/phone-input.d.ts +73 -0
- package/components/phone-input/phone-input.js +612 -0
- package/components/phone-input/phone-input.module.css +98 -0
- package/components/popover/popover.d.ts +6 -0
- package/components/popover/popover.js +19 -0
- package/components/popover/popover.module.css +32 -0
- package/components/progress/progress.d.ts +8 -0
- package/components/progress/progress.js +42 -0
- package/components/progress/progress.module.css +1 -0
- package/components/radio-cards/radio-cards.d.ts +35 -0
- package/components/radio-cards/radio-cards.js +110 -0
- package/components/radio-cards/radio-cards.module.css +84 -0
- package/components/radio-group/radio-group.d.ts +13 -0
- package/components/radio-group/radio-group.js +55 -0
- package/components/radio-group/radio-group.module.css +30 -0
- package/components/resizable-panels/resizable-panels.d.ts +37 -0
- package/components/resizable-panels/resizable-panels.js +338 -0
- package/components/resizable-panels/resizable-panels.module.css +40 -0
- package/components/rich-text-editor/rich-text-editor.d.ts +55 -0
- package/components/rich-text-editor/rich-text-editor.js +1733 -0
- package/components/rich-text-editor/rich-text-editor.module.css +201 -0
- package/components/ridgeline/ridgeline.d.ts +37 -0
- package/components/ridgeline/ridgeline.js +346 -0
- package/components/ridgeline/ridgeline.module.css +58 -0
- package/components/scroll-area/scroll-area.d.ts +40 -0
- package/components/scroll-area/scroll-area.js +153 -0
- package/components/scroll-area/scroll-area.module.css +79 -0
- package/components/search-field/search-field.d.ts +7 -0
- package/components/search-field/search-field.js +20 -0
- package/components/search-field/search-field.module.css +17 -0
- package/components/segmented-control/segmented-control.d.ts +16 -0
- package/components/segmented-control/segmented-control.js +56 -0
- package/components/segmented-control/segmented-control.module.css +15 -0
- package/components/select/select.d.ts +15 -0
- package/components/select/select.js +36 -0
- package/components/select/select.module.css +35 -0
- package/components/shortcut-recorder/shortcut-recorder.d.ts +108 -0
- package/components/shortcut-recorder/shortcut-recorder.js +380 -0
- package/components/shortcut-recorder/shortcut-recorder.module.css +85 -0
- package/components/signature-pad/signature-pad.d.ts +72 -0
- package/components/signature-pad/signature-pad.js +450 -0
- package/components/signature-pad/signature-pad.module.css +84 -0
- package/components/skeleton/skeleton.d.ts +13 -0
- package/components/skeleton/skeleton.js +53 -0
- package/components/skeleton/skeleton.module.css +11 -0
- package/components/slider/slider.d.ts +45 -0
- package/components/slider/slider.js +347 -0
- package/components/slider/slider.module.css +45 -0
- package/components/slope-chart/slope-chart.d.ts +38 -0
- package/components/slope-chart/slope-chart.js +183 -0
- package/components/slope-chart/slope-chart.module.css +51 -0
- package/components/slot-text/slot-text.d.ts +26 -0
- package/components/slot-text/slot-text.js +119 -0
- package/components/slot-text/slot-text.module.css +19 -0
- package/components/sortable-data-table/sortable-data-table.d.ts +38 -0
- package/components/sortable-data-table/sortable-data-table.js +240 -0
- package/components/sortable-data-table/sortable-data-table.module.css +116 -0
- package/components/sparkline/sparkline.d.ts +19 -0
- package/components/sparkline/sparkline.js +239 -0
- package/components/sparkline/sparkline.module.css +26 -0
- package/components/split-button/split-button.d.ts +19 -0
- package/components/split-button/split-button.js +33 -0
- package/components/split-button/split-button.module.css +51 -0
- package/components/stepper/stepper.d.ts +36 -0
- package/components/stepper/stepper.js +108 -0
- package/components/stepper/stepper.module.css +74 -0
- package/components/streamgraph/streamgraph.d.ts +51 -0
- package/components/streamgraph/streamgraph.js +376 -0
- package/components/streamgraph/streamgraph.module.css +55 -0
- package/components/swipe-actions/swipe-actions.d.ts +35 -0
- package/components/swipe-actions/swipe-actions.js +330 -0
- package/components/swipe-actions/swipe-actions.module.css +64 -0
- package/components/switch/switch.d.ts +7 -0
- package/components/switch/switch.js +45 -0
- package/components/switch/switch.module.css +12 -0
- package/components/tabs/tabs.d.ts +8 -0
- package/components/tabs/tabs.js +138 -0
- package/components/tabs/tabs.module.css +27 -0
- package/components/tag-input/tag-input.d.ts +15 -0
- package/components/tag-input/tag-input.js +150 -0
- package/components/tag-input/tag-input.module.css +34 -0
- package/components/text-morph/text-morph.d.ts +13 -0
- package/components/text-morph/text-morph.js +69 -0
- package/components/text-morph/text-morph.module.css +8 -0
- package/components/text-reveal/text-reveal.d.ts +14 -0
- package/components/text-reveal/text-reveal.js +20 -0
- package/components/text-reveal/text-reveal.module.css +13 -0
- package/components/text-shimmer/text-shimmer.d.ts +18 -0
- package/components/text-shimmer/text-shimmer.js +90 -0
- package/components/text-shimmer/text-shimmer.module.css +9 -0
- package/components/textarea/textarea.d.ts +7 -0
- package/components/textarea/textarea.js +56 -0
- package/components/textarea/textarea.module.css +22 -0
- package/components/theme-switch/theme-switch.d.ts +11 -0
- package/components/theme-switch/theme-switch.js +37 -0
- package/components/theme-switch/theme-switch.module.css +31 -0
- package/components/time-picker/time-picker.d.ts +13 -0
- package/components/time-picker/time-picker.js +95 -0
- package/components/time-picker/time-picker.module.css +23 -0
- package/components/timeline/timeline.d.ts +47 -0
- package/components/timeline/timeline.js +171 -0
- package/components/timeline/timeline.module.css +51 -0
- package/components/toast/toast.d.ts +8 -0
- package/components/toast/toast.js +118 -0
- package/components/toast/toast.module.css +100 -0
- package/components/toast-stack/toast-stack.d.ts +57 -0
- package/components/toast-stack/toast-stack.js +399 -0
- package/components/toast-stack/toast-stack.module.css +61 -0
- package/components/tooltip/tooltip.d.ts +8 -0
- package/components/tooltip/tooltip.js +62 -0
- package/components/tooltip/tooltip.module.css +18 -0
- package/components/tree-view/tree-view.d.ts +17 -0
- package/components/tree-view/tree-view.js +147 -0
- package/components/tree-view/tree-view.module.css +22 -0
- package/components/treemap/treemap.d.ts +35 -0
- package/components/treemap/treemap.js +375 -0
- package/components/treemap/treemap.module.css +59 -0
- package/components/usage-meter/usage-meter.d.ts +24 -0
- package/components/usage-meter/usage-meter.js +296 -0
- package/components/usage-meter/usage-meter.module.css +61 -0
- package/components/user-menu/user-menu.d.ts +62 -0
- package/components/user-menu/user-menu.js +316 -0
- package/components/user-menu/user-menu.module.css +97 -0
- package/components/waffle-chart/waffle-chart.d.ts +39 -0
- package/components/waffle-chart/waffle-chart.js +244 -0
- package/components/waffle-chart/waffle-chart.module.css +52 -0
- package/components.json +1330 -0
- package/eslint.js +53 -0
- package/lib/escape.d.ts +19 -0
- package/lib/escape.js +50 -0
- package/lib/media.d.ts +353 -0
- package/lib/media.js +65 -0
- package/lib/menu-highlight.d.ts +28 -0
- package/lib/menu-highlight.js +43 -0
- package/lib/motion-tokens.d.ts +60 -0
- package/lib/motion-tokens.js +25 -0
- package/lib/return-focus.d.ts +9 -0
- package/lib/return-focus.js +53 -0
- package/lib/silicon-provider.d.ts +60 -0
- package/lib/silicon-provider.js +47 -0
- package/lib/use-copy-feedback.d.ts +8 -0
- package/lib/use-copy-feedback.js +36 -0
- package/lib/use-today.d.ts +2 -0
- package/lib/use-today.js +29 -0
- package/package.json +76 -4
- package/styles.css +218 -0
- package/styles.css.d.ts +1 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
|
|
5
|
+
import { Check, Download, Eraser, Play, Redo2, Square, Undo2, X } from "lucide-react";
|
|
6
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
7
|
+
import styles from "./signature-pad.module.css";
|
|
8
|
+
/** Pad coordinates. Strokes are stored in this space and scale with the pad. */
|
|
9
|
+
export const PAD_WIDTH = 600, PAD_HEIGHT = 260;
|
|
10
|
+
const INK = {
|
|
11
|
+
black: { label: "Black ink", export: "#17171a" },
|
|
12
|
+
blue: { label: "Blue ink", export: "#1d3fae" },
|
|
13
|
+
violet: { label: "Violet ink", export: "#5b35c8" },
|
|
14
|
+
};
|
|
15
|
+
const WIDTHS = {
|
|
16
|
+
fine: { label: "Fine", size: 3.4 },
|
|
17
|
+
medium: { label: "Medium", size: 5.6 },
|
|
18
|
+
bold: { label: "Bold", size: 8.4 },
|
|
19
|
+
};
|
|
20
|
+
const COLORS = Object.keys(INK);
|
|
21
|
+
const SIZES = Object.keys(WIDTHS);
|
|
22
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
|
|
23
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
|
|
24
|
+
const mul = (a, n) => [a[0] * n, a[1] * n];
|
|
25
|
+
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
26
|
+
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
27
|
+
const norm = (a) => { const l = Math.hypot(a[0], a[1]); return l ? [a[0] / l, a[1] / l] : [0, 0]; };
|
|
28
|
+
const perp = (a) => [a[1], -a[0]];
|
|
29
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1];
|
|
30
|
+
const rotAround = (p, c, r) => {
|
|
31
|
+
const s = Math.sin(r), co = Math.cos(r), px = p[0] - c[0], py = p[1] - c[1];
|
|
32
|
+
return [px * co - py * s + c[0], px * s + py * co + c[1]];
|
|
33
|
+
};
|
|
34
|
+
const easeOut = (t) => t * (2 - t);
|
|
35
|
+
/**
|
|
36
|
+
* Turns sampled points into the outline of a variable width stroke, in the spirit of perfect-freehand. Points are streamlined
|
|
37
|
+
* toward the pointer, width follows pen pressure or, for a mouse or finger, speed, the ends taper, sharp turns get round joins,
|
|
38
|
+
* and both ends get round caps. Returns the outline polygon; pass it to `outlineToPath` for SVG.
|
|
39
|
+
*/
|
|
40
|
+
export function getStrokeOutline(input, options) {
|
|
41
|
+
const { size, thinning = .62, streamline = .42, simulatePressure = true, taperStart = 0, taperEnd = 0, last = true } = options;
|
|
42
|
+
const [head] = input;
|
|
43
|
+
if (!head)
|
|
44
|
+
return [];
|
|
45
|
+
const usePen = !simulatePressure && input.some(point => point.p > 0);
|
|
46
|
+
// Streamline: each point eases toward the raw input, which removes jitter without a smoothing pass.
|
|
47
|
+
const pts = [];
|
|
48
|
+
let prev = [head.x, head.y], length = 0;
|
|
49
|
+
const start = { at: prev, pressure: usePen ? head.p : .5, length: 0, vector: [1, 0] };
|
|
50
|
+
pts.push(start);
|
|
51
|
+
for (let i = 1; i < input.length; i++) {
|
|
52
|
+
const sample = input[i];
|
|
53
|
+
const raw = [sample.x, sample.y];
|
|
54
|
+
const at = i === input.length - 1 && last ? raw : lerp(prev, raw, 1 - streamline);
|
|
55
|
+
const d = dist(at, prev);
|
|
56
|
+
if (d < .4)
|
|
57
|
+
continue;
|
|
58
|
+
length += d;
|
|
59
|
+
pts.push({ at, pressure: usePen ? sample.p : .5, length, vector: norm(sub(prev, at)) });
|
|
60
|
+
prev = at;
|
|
61
|
+
}
|
|
62
|
+
const second = pts[1];
|
|
63
|
+
if (second)
|
|
64
|
+
start.vector = second.vector;
|
|
65
|
+
const total = length;
|
|
66
|
+
// A dot: a round mark sized by the first pressure.
|
|
67
|
+
if (pts.length === 1) {
|
|
68
|
+
const r = Math.max(.6, size * (.5 - thinning * (.5 - start.pressure)) * .9);
|
|
69
|
+
return Array.from({ length: 18 }, (_, i) => add(start.at, mul([Math.cos(i / 18 * Math.PI * 2), Math.sin(i / 18 * Math.PI * 2)], r)));
|
|
70
|
+
}
|
|
71
|
+
const left = [], right = [];
|
|
72
|
+
let pressure = start.pressure, prevVector = start.vector;
|
|
73
|
+
const radii = [];
|
|
74
|
+
for (let i = 0; i < pts.length; i++) {
|
|
75
|
+
const point = pts[i];
|
|
76
|
+
if (!usePen) {
|
|
77
|
+
// Speed thins the line: a long step between samples reads as a fast stroke.
|
|
78
|
+
const step = i === 0 ? 0 : dist(point.at, pts[i - 1].at);
|
|
79
|
+
const speed = Math.min(1, step / size);
|
|
80
|
+
const target = Math.min(1, 1 - speed);
|
|
81
|
+
pressure = Math.min(1, pressure + (target - pressure) * speed * .3);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
pressure = point.pressure;
|
|
85
|
+
}
|
|
86
|
+
let radius = Math.max(.25, size * (.5 - thinning * (.5 - pressure)));
|
|
87
|
+
const ts = taperStart ? easeOut(Math.min(1, point.length / taperStart)) : 1;
|
|
88
|
+
const te = taperEnd && last ? easeOut(Math.min(1, (total - point.length) / taperEnd)) : 1;
|
|
89
|
+
radius = Math.max(.12 * size, radius * Math.min(ts, te));
|
|
90
|
+
radii.push(radius);
|
|
91
|
+
const next = pts[i + 1]?.vector ?? point.vector;
|
|
92
|
+
const offset = mul(perp(lerp(next, point.vector, .5)), radius);
|
|
93
|
+
// A sharp turn: sweep round the corner so the outline does not pinch.
|
|
94
|
+
if (i > 0 && i < pts.length - 1 && dot(point.vector, next) < -.2) {
|
|
95
|
+
const corner = mul(perp(prevVector), radius);
|
|
96
|
+
for (let t = 0; t <= 1; t += 1 / 12) {
|
|
97
|
+
left.push(rotAround(sub(point.at, corner), point.at, Math.PI * t));
|
|
98
|
+
right.push(rotAround(add(point.at, corner), point.at, -Math.PI * t));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
left.push(sub(point.at, offset));
|
|
103
|
+
right.push(add(point.at, offset));
|
|
104
|
+
}
|
|
105
|
+
prevVector = point.vector;
|
|
106
|
+
}
|
|
107
|
+
// Round caps: half circles swept around the first and last points, on the side facing away from the stroke.
|
|
108
|
+
const cap = (center, from, to, away) => {
|
|
109
|
+
const out = [];
|
|
110
|
+
const r = dist(center, from), start = Math.atan2(from[1] - center[1], from[0] - center[0]);
|
|
111
|
+
const mid = start + Math.PI / 2;
|
|
112
|
+
const sign = dot([Math.cos(mid), Math.sin(mid)], away) >= 0 ? 1 : -1;
|
|
113
|
+
for (let k = 1; k < 12; k++) {
|
|
114
|
+
const a = start + sign * Math.PI * k / 12;
|
|
115
|
+
out.push([center[0] + Math.cos(a) * r, center[1] + Math.sin(a) * r]);
|
|
116
|
+
}
|
|
117
|
+
out.push(to);
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
const end = pts[pts.length - 1];
|
|
121
|
+
const endCap = cap(end.at, left[left.length - 1], right[right.length - 1], mul(end.vector, -1));
|
|
122
|
+
const startCap = cap(start.at, right[0], left[0], start.vector);
|
|
123
|
+
return [...left, ...endCap, ...right.reverse(), ...startCap];
|
|
124
|
+
}
|
|
125
|
+
const round = (value) => Math.round(value * 100) / 100;
|
|
126
|
+
/** Draws an outline as one smooth closed path, with quadratic curves through the midpoints of its edges. */
|
|
127
|
+
export function outlineToPath(points) {
|
|
128
|
+
if (points.length < 3)
|
|
129
|
+
return "";
|
|
130
|
+
let d = `M${round(points[0][0])} ${round(points[0][1])} Q`;
|
|
131
|
+
for (let i = 0; i < points.length; i++) {
|
|
132
|
+
const [x0, y0] = points[i], [x1, y1] = points[(i + 1) % points.length];
|
|
133
|
+
d += `${round(x0)} ${round(y0)} ${round((x0 + x1) / 2)} ${round((y0 + y1) / 2)} `;
|
|
134
|
+
}
|
|
135
|
+
return `${d}Z`;
|
|
136
|
+
}
|
|
137
|
+
function strokeOptions(stroke, last) {
|
|
138
|
+
const size = WIDTHS[stroke.width].size;
|
|
139
|
+
const pen = stroke.points.some(point => point.p > 0);
|
|
140
|
+
return { size, simulatePressure: !pen, taperStart: size * 1.5, taperEnd: pen ? 0 : size * 5, last };
|
|
141
|
+
}
|
|
142
|
+
/** The SVG path of a stroke, or of its first `upTo` ms while it is replayed. */
|
|
143
|
+
export function strokePath(stroke, upTo = Infinity) {
|
|
144
|
+
const all = stroke.points;
|
|
145
|
+
const done = upTo >= all[all.length - 1].t;
|
|
146
|
+
const points = done ? all : all.filter(point => point.t <= upTo);
|
|
147
|
+
return outlineToPath(getStrokeOutline(points, strokeOptions(stroke, done)));
|
|
148
|
+
}
|
|
149
|
+
/** Tight bounds of the ink, padded, for export. */
|
|
150
|
+
function inkBounds(strokes) {
|
|
151
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
152
|
+
for (const stroke of strokes)
|
|
153
|
+
for (const point of stroke.points) {
|
|
154
|
+
minX = Math.min(minX, point.x);
|
|
155
|
+
minY = Math.min(minY, point.y);
|
|
156
|
+
maxX = Math.max(maxX, point.x);
|
|
157
|
+
maxY = Math.max(maxY, point.y);
|
|
158
|
+
}
|
|
159
|
+
const pad = 16;
|
|
160
|
+
return { x: Math.floor(minX - pad), y: Math.floor(minY - pad), width: Math.ceil(maxX - minX + pad * 2), height: Math.ceil(maxY - minY + pad * 2) };
|
|
161
|
+
}
|
|
162
|
+
/** A standalone SVG of the signature, cropped to the ink, with print ink colors. */
|
|
163
|
+
export function signatureToSvg(strokes) {
|
|
164
|
+
if (!strokes.length)
|
|
165
|
+
return "";
|
|
166
|
+
const box = inkBounds(strokes);
|
|
167
|
+
const paths = strokes.map(stroke => `<path d="${strokePath(stroke)}" fill="${INK[stroke.color].export}"/>`).join("");
|
|
168
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${box.x} ${box.y} ${box.width} ${box.height}" width="${box.width}" height="${box.height}">${paths}</svg>`;
|
|
169
|
+
}
|
|
170
|
+
/** A transparent PNG of the signature, cropped to the ink, at `scale` times pad resolution. */
|
|
171
|
+
export function signatureToPng(strokes, scale = 3) {
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
if (!strokes.length)
|
|
174
|
+
return reject(new Error("Nothing to export"));
|
|
175
|
+
const box = inkBounds(strokes);
|
|
176
|
+
const canvas = document.createElement("canvas");
|
|
177
|
+
canvas.width = box.width * scale;
|
|
178
|
+
canvas.height = box.height * scale;
|
|
179
|
+
const ctx = canvas.getContext("2d");
|
|
180
|
+
if (!ctx)
|
|
181
|
+
return reject(new Error("Canvas is unavailable"));
|
|
182
|
+
ctx.scale(scale, scale);
|
|
183
|
+
ctx.translate(-box.x, -box.y);
|
|
184
|
+
for (const stroke of strokes) {
|
|
185
|
+
ctx.fillStyle = INK[stroke.color].export;
|
|
186
|
+
ctx.fill(new Path2D(strokePath(stroke)));
|
|
187
|
+
}
|
|
188
|
+
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error("Export failed")), "image/png");
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function download(blob, name) {
|
|
192
|
+
const url = URL.createObjectURL(blob);
|
|
193
|
+
const link = document.createElement("a");
|
|
194
|
+
link.href = url;
|
|
195
|
+
link.download = name;
|
|
196
|
+
document.body.appendChild(link);
|
|
197
|
+
link.click();
|
|
198
|
+
link.remove();
|
|
199
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
200
|
+
}
|
|
201
|
+
const { spring, duration, ease, blur } = motionTokens;
|
|
202
|
+
const subscribe = () => () => { };
|
|
203
|
+
function useReducedFlag() {
|
|
204
|
+
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
|
|
205
|
+
return !!useReducedMotion() && hydrated;
|
|
206
|
+
}
|
|
207
|
+
export function SignaturePad({ signer, hint = "Sign here", defaultColor = "black", defaultWidth = "medium", onChange, fileName = "signature", label = "Signature pad", className }) {
|
|
208
|
+
const uid = useId().replace(/[^a-zA-Z0-9-]/g, "");
|
|
209
|
+
const reduced = useReducedFlag();
|
|
210
|
+
const [history, setHistory] = useState({ past: [], present: [], future: [], fresh: null });
|
|
211
|
+
const [color, setColor] = useState(defaultColor);
|
|
212
|
+
const [width, setWidth] = useState(defaultWidth);
|
|
213
|
+
const [drawing, setDrawing] = useState(false);
|
|
214
|
+
const [wiping, setWiping] = useState(false);
|
|
215
|
+
const [replay, setReplay] = useState(null);
|
|
216
|
+
const [saved, setSaved] = useState(null);
|
|
217
|
+
const [status, setStatus] = useState("");
|
|
218
|
+
const padRef = useRef(null);
|
|
219
|
+
const inkRef = useRef(null);
|
|
220
|
+
const liveRef = useRef(null);
|
|
221
|
+
const live = useRef(null);
|
|
222
|
+
const replayFrame = useRef(0);
|
|
223
|
+
const savedTimer = useRef(0);
|
|
224
|
+
const wipeAnimation = useRef(null);
|
|
225
|
+
const counter = useRef(0);
|
|
226
|
+
const onChangeRef = useRef(onChange);
|
|
227
|
+
useEffect(() => { onChangeRef.current = onChange; });
|
|
228
|
+
const strokes = history.present;
|
|
229
|
+
const empty = strokes.length === 0;
|
|
230
|
+
const replaying = replay !== null;
|
|
231
|
+
useEffect(() => { onChangeRef.current?.(strokes); }, [strokes]);
|
|
232
|
+
useEffect(() => () => { cancelAnimationFrame(replayFrame.current); window.clearTimeout(savedTimer.current); }, []);
|
|
233
|
+
const commit = useCallback((next, fresh = null) => {
|
|
234
|
+
setHistory(current => ({ past: [...current.past, current.present].slice(-60), present: next, future: [], fresh }));
|
|
235
|
+
}, []);
|
|
236
|
+
const stopReplay = useCallback(() => { cancelAnimationFrame(replayFrame.current); setReplay(null); }, []);
|
|
237
|
+
/** A wipe in flight completes at once, so whatever comes next starts from a clear pad. */
|
|
238
|
+
const settleWipe = useCallback(() => { wipeAnimation.current?.complete(); }, []);
|
|
239
|
+
function toPad(event) {
|
|
240
|
+
const box = padRef.current.getBoundingClientRect();
|
|
241
|
+
return { x: (event.clientX - box.left) / box.width * PAD_WIDTH, y: (event.clientY - box.top) / box.height * PAD_HEIGHT };
|
|
242
|
+
}
|
|
243
|
+
function onPointerDown(event) {
|
|
244
|
+
if (live.current || (event.pointerType === "mouse" && event.button !== 0))
|
|
245
|
+
return;
|
|
246
|
+
event.preventDefault();
|
|
247
|
+
settleWipe();
|
|
248
|
+
if (replaying)
|
|
249
|
+
stopReplay();
|
|
250
|
+
padRef.current?.focus({ preventScroll: true });
|
|
251
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
252
|
+
const { x, y } = toPad(event);
|
|
253
|
+
const now = event.nativeEvent.timeStamp;
|
|
254
|
+
counter.current += 1;
|
|
255
|
+
const pen = event.pointerType === "pen" && event.pressure > 0;
|
|
256
|
+
live.current = { id: event.pointerId, stamp: event.nativeEvent.timeStamp, stroke: { id: `${uid}-${counter.current}`, color, width, at: now, points: [{ x, y, p: pen ? event.pressure : -1, t: 0 }] } };
|
|
257
|
+
setDrawing(true);
|
|
258
|
+
paintLive();
|
|
259
|
+
}
|
|
260
|
+
function onPointerMove(event) {
|
|
261
|
+
const current = live.current;
|
|
262
|
+
if (!current || current.id !== event.pointerId)
|
|
263
|
+
return;
|
|
264
|
+
const samples = typeof event.nativeEvent.getCoalescedEvents === "function" ? event.nativeEvent.getCoalescedEvents() : [];
|
|
265
|
+
const list = samples.length ? samples : [event.nativeEvent];
|
|
266
|
+
for (const sample of list) {
|
|
267
|
+
const { x, y } = toPad(sample);
|
|
268
|
+
const pen = sample.pointerType === "pen" && sample.pressure > 0;
|
|
269
|
+
current.stroke.points.push({ x, y, p: pen ? sample.pressure : -1, t: Math.max(current.stroke.points[current.stroke.points.length - 1].t, sample.timeStamp - current.stamp) });
|
|
270
|
+
}
|
|
271
|
+
paintLive();
|
|
272
|
+
}
|
|
273
|
+
function onPointerUp(event) {
|
|
274
|
+
const current = live.current;
|
|
275
|
+
if (!current || current.id !== event.pointerId)
|
|
276
|
+
return;
|
|
277
|
+
live.current = null;
|
|
278
|
+
commit([...strokes, current.stroke], current.stroke.id);
|
|
279
|
+
liveRef.current?.setAttribute("d", "");
|
|
280
|
+
setDrawing(false);
|
|
281
|
+
setStatus(`Stroke added. ${strokes.length + 1} in the signature.`);
|
|
282
|
+
}
|
|
283
|
+
function paintLive() {
|
|
284
|
+
const current = live.current;
|
|
285
|
+
if (!current || !liveRef.current)
|
|
286
|
+
return;
|
|
287
|
+
liveRef.current.setAttribute("d", outlineToPath(getStrokeOutline(current.stroke.points, strokeOptions(current.stroke, false))));
|
|
288
|
+
}
|
|
289
|
+
function undo() {
|
|
290
|
+
if (!history.past.length || live.current)
|
|
291
|
+
return;
|
|
292
|
+
settleWipe();
|
|
293
|
+
stopReplay();
|
|
294
|
+
setHistory(current => current.past.length ? { past: current.past.slice(0, -1), present: current.past[current.past.length - 1], future: [current.present, ...current.future], fresh: null } : current);
|
|
295
|
+
setStatus("Undone");
|
|
296
|
+
}
|
|
297
|
+
function redo() {
|
|
298
|
+
if (!history.future.length || live.current)
|
|
299
|
+
return;
|
|
300
|
+
settleWipe();
|
|
301
|
+
stopReplay();
|
|
302
|
+
setHistory(current => current.future.length ? { past: [...current.past, current.present], present: current.future[0], future: current.future.slice(1), fresh: null } : current);
|
|
303
|
+
setStatus("Redone");
|
|
304
|
+
}
|
|
305
|
+
function clear() {
|
|
306
|
+
if (empty || wiping || live.current)
|
|
307
|
+
return;
|
|
308
|
+
stopReplay();
|
|
309
|
+
const node = inkRef.current;
|
|
310
|
+
const finish = () => {
|
|
311
|
+
wipeAnimation.current = null;
|
|
312
|
+
commit([]);
|
|
313
|
+
setWiping(false);
|
|
314
|
+
setStatus("Signature cleared. Undo brings it back.");
|
|
315
|
+
// Lift the clip only once the wiped strokes have left, so they never flash back for a frame.
|
|
316
|
+
if (node)
|
|
317
|
+
requestAnimationFrame(() => requestAnimationFrame(() => { node.style.clipPath = ""; }));
|
|
318
|
+
};
|
|
319
|
+
if (!node || reduced) {
|
|
320
|
+
finish();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
setWiping(true);
|
|
324
|
+
// The ink is cut away behind a squeegee edge that sweeps across the pad; the edge itself is drawn by the sweep element.
|
|
325
|
+
const controls = animate(node, { clipPath: ["inset(0% 0% 0% 0%)", "inset(0% 0% 0% 100%)"] }, { duration: .56, ease: [...ease.inOut] });
|
|
326
|
+
wipeAnimation.current = { stop: () => controls.stop(), complete: () => { controls.complete(); } };
|
|
327
|
+
controls.then(finish);
|
|
328
|
+
}
|
|
329
|
+
function toggleReplay() {
|
|
330
|
+
if (replaying) {
|
|
331
|
+
stopReplay();
|
|
332
|
+
setStatus("Replay stopped");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (empty || live.current)
|
|
336
|
+
return;
|
|
337
|
+
settleWipe();
|
|
338
|
+
// Build a timeline: each stroke keeps its own rhythm, and pauses between strokes are capped so the replay never stalls.
|
|
339
|
+
const offsets = [];
|
|
340
|
+
let cursor = 0;
|
|
341
|
+
strokes.forEach((stroke, index) => {
|
|
342
|
+
if (index > 0) {
|
|
343
|
+
const previous = strokes[index - 1];
|
|
344
|
+
const gap = stroke.at - (previous.at + previous.points[previous.points.length - 1].t);
|
|
345
|
+
cursor += Math.min(260, Math.max(90, gap));
|
|
346
|
+
}
|
|
347
|
+
offsets.push(cursor);
|
|
348
|
+
cursor += stroke.points[stroke.points.length - 1].t;
|
|
349
|
+
});
|
|
350
|
+
const speed = Math.max(1, cursor / 3200);
|
|
351
|
+
const total = cursor + 120;
|
|
352
|
+
const begin = performance.now();
|
|
353
|
+
const tick = (now) => {
|
|
354
|
+
const at = (now - begin) * speed;
|
|
355
|
+
if (at >= total) {
|
|
356
|
+
setReplay(null);
|
|
357
|
+
setStatus("Replay finished");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
setReplay({ at, offsets });
|
|
361
|
+
replayFrame.current = requestAnimationFrame(tick);
|
|
362
|
+
};
|
|
363
|
+
setReplay({ at: 0, offsets });
|
|
364
|
+
setStatus("Replaying signature");
|
|
365
|
+
replayFrame.current = requestAnimationFrame(tick);
|
|
366
|
+
}
|
|
367
|
+
async function save(kind) {
|
|
368
|
+
if (empty)
|
|
369
|
+
return;
|
|
370
|
+
window.clearTimeout(savedTimer.current);
|
|
371
|
+
try {
|
|
372
|
+
const blob = kind === "svg" ? new Blob([signatureToSvg(strokes)], { type: "image/svg+xml" }) : await signatureToPng(strokes);
|
|
373
|
+
download(blob, `${fileName}.${kind}`);
|
|
374
|
+
setSaved(kind);
|
|
375
|
+
setStatus(`Saved ${fileName}.${kind}`);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
setSaved("failed");
|
|
379
|
+
setStatus("Export failed. Try again.");
|
|
380
|
+
}
|
|
381
|
+
savedTimer.current = window.setTimeout(() => setSaved(null), 1800);
|
|
382
|
+
}
|
|
383
|
+
function onKeyDown(event) {
|
|
384
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
385
|
+
const key = event.key.toLowerCase();
|
|
386
|
+
if (mod && key === "z") {
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
if (event.shiftKey)
|
|
389
|
+
redo();
|
|
390
|
+
else
|
|
391
|
+
undo();
|
|
392
|
+
}
|
|
393
|
+
else if (mod && key === "y") {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
redo();
|
|
396
|
+
}
|
|
397
|
+
else if (!mod && (event.key === "Delete" || event.key === "Backspace") && event.currentTarget === event.target) {
|
|
398
|
+
event.preventDefault();
|
|
399
|
+
clear();
|
|
400
|
+
}
|
|
401
|
+
else if (event.key === "Escape" && replaying) {
|
|
402
|
+
stopReplay();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// During replay each stroke shows only the part drawn so far, and the nib rides its newest point.
|
|
406
|
+
let nib = null, nibColor = color;
|
|
407
|
+
const visible = [];
|
|
408
|
+
for (let index = 0; index < strokes.length; index++) {
|
|
409
|
+
const stroke = strokes[index];
|
|
410
|
+
if (replay === null) {
|
|
411
|
+
visible.push({ stroke, d: null });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const local = replay.at - (replay.offsets[index] ?? 0);
|
|
415
|
+
const lastT = stroke.points[stroke.points.length - 1].t;
|
|
416
|
+
if (local < 0) {
|
|
417
|
+
visible.push({ stroke, d: "" });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (local >= lastT) {
|
|
421
|
+
visible.push({ stroke, d: null });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
nib = stroke.points.findLast(point => point.t <= local) ?? stroke.points[0];
|
|
425
|
+
nibColor = stroke.color;
|
|
426
|
+
visible.push({ stroke, d: strokePath(stroke, local) });
|
|
427
|
+
}
|
|
428
|
+
const showHint = empty && !drawing && !wiping;
|
|
429
|
+
const hintHidden = reduced ? { opacity: 0 } : { opacity: 0, y: 6, scale: .97, filter: `blur(${blur.soft}px)` };
|
|
430
|
+
const t = reduced ? { duration: duration.instant } : spring.snappy;
|
|
431
|
+
return _jsxs("div", { className: [styles.root, className].filter(Boolean).join(" "), "data-ink": color, children: [_jsxs("div", { ref: padRef, className: styles.pad, tabIndex: 0, role: "img", "aria-roledescription": "signature pad", "aria-label": `${label}. ${empty ? "Empty." : `${strokes.length} ${strokes.length === 1 ? "stroke" : "strokes"}.`} Draw with a pointer, pen, or finger.`, "aria-describedby": `${uid}-keys`, "data-drawing": drawing ? "" : undefined, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onKeyDown: onKeyDown, children: [_jsxs("div", { className: styles.guide, "aria-hidden": "true", children: [_jsx(X, { className: styles.mark, size: 14, strokeWidth: 1.75 }), _jsx("span", { className: styles.baseline }), signer && _jsx("span", { className: styles.signer, children: signer }), _jsx(AnimatePresence, { initial: false, children: showHint && _jsx(motion.span, { className: styles.hint, initial: hintHidden, animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }, exit: { ...hintHidden, transition: { duration: duration.fast, ease: [...ease.standard] } }, transition: reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }, children: hint }, "hint") })] }), _jsx("div", { ref: inkRef, className: styles.ink, children: _jsxs("svg", { viewBox: `0 0 ${PAD_WIDTH} ${PAD_HEIGHT}`, preserveAspectRatio: "none", "aria-hidden": "true", children: [_jsx(AnimatePresence, { initial: false, children: visible.map(({ stroke, d }) => _jsx(motion.path, { className: styles.stroke, "data-color": stroke.color, d: d ?? strokePath(stroke), initial: history.fresh === stroke.id ? false : { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: wiping ? 0 : duration.standard, ease: [...ease.standard] } }, transition: { duration: duration.standard, ease: [...ease.standard] } }, stroke.id)) }), _jsx("path", { ref: liveRef, className: styles.stroke, "data-color": color }), nib && _jsx("circle", { className: styles.nib, "data-color": nibColor, cx: nib.x, cy: nib.y, r: WIDTHS[width].size * .9 })] }) }), wiping && _jsx(motion.span, { className: styles.sweep, "aria-hidden": "true", initial: { x: "0%", opacity: 0 }, animate: { x: "100%", opacity: [0, 1, 1, 0] }, transition: { duration: .56, ease: [...ease.inOut], opacity: { duration: .56, times: [0, .12, .85, 1] } } })] }), _jsxs("div", { className: styles.toolbar, children: [_jsxs("div", { className: styles.group, children: [_jsx(Choice, { label: "Ink color", options: COLORS, value: color, onChange: next => { setColor(next); setStatus(`${INK[next].label} selected`); }, layoutId: `${uid}-color`, reduced: reduced, render: option => _jsx("span", { className: styles.swatch, "data-color": option }), name: option => INK[option].label }), _jsx("span", { className: styles.divider, "aria-hidden": "true" }), _jsx(Choice, { label: "Stroke width", options: SIZES, value: width, onChange: next => { setWidth(next); setStatus(`${WIDTHS[next].label} nib selected`); }, layoutId: `${uid}-width`, reduced: reduced, render: option => _jsx("span", { className: styles.weight, style: { width: WIDTHS[option].size + 2, height: WIDTHS[option].size + 2 } }), name: option => WIDTHS[option].label })] }), _jsxs("div", { className: styles.group, children: [_jsx(IconButton, { label: "Undo", onClick: undo, disabled: !history.past.length || wiping, children: _jsx(Undo2, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx(IconButton, { label: "Redo", onClick: redo, disabled: !history.future.length || wiping, children: _jsx(Redo2, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx(IconButton, { label: replaying ? "Stop replay" : "Replay signature", onClick: toggleReplay, disabled: empty || wiping, pressed: replaying, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.swap, initial: { opacity: 0, scale: .6 }, animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: .6 }, transition: t, children: replaying ? _jsx(Square, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }) : _jsx(Play, { size: 17, strokeWidth: 1.75, "aria-hidden": "true" }) }, replaying ? "stop" : "play") }) }), _jsx(IconButton, { label: "Clear signature", onClick: clear, disabled: empty || wiping, children: _jsx(Eraser, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] }), _jsxs("div", { className: styles.exports, children: [_jsx("span", { className: styles.exportLabel, children: "Save as" }), ["png", "svg"].map(kind => _jsx("button", { type: "button", className: styles.export, onClick: () => save(kind), disabled: empty, "aria-label": `Download ${kind.toUpperCase()}`, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(motion.span, { className: styles.exportContent, initial: { opacity: 0, y: reduced ? 0 : 4, filter: reduced ? "blur(0px)" : `blur(${blur.subtle}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, y: reduced ? 0 : -4, transition: { duration: duration.instant } }, transition: reduced ? { duration: duration.instant } : { duration: duration.fast, ease: [...ease.enter] }, children: saved === kind ? _jsxs(_Fragment, { children: [_jsx(Check, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), "Saved"] }) : _jsxs(_Fragment, { children: [_jsx(Download, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), kind.toUpperCase()] }) }, saved === kind ? "done" : saved === "failed" ? "failed" : "idle") }) }, kind))] }), _jsx("p", { id: `${uid}-keys`, className: styles.srOnly, children: "Press Control or Command Z to undo, add Shift to redo, and Delete to clear." }), _jsx("p", { className: styles.srOnly, role: "status", "aria-live": "polite", children: status })] });
|
|
432
|
+
}
|
|
433
|
+
function IconButton({ label, onClick, disabled, pressed, children }) {
|
|
434
|
+
return _jsx("button", { type: "button", className: styles.iconButton, "aria-label": label, title: label, onClick: onClick, disabled: disabled, "aria-pressed": pressed, children: children });
|
|
435
|
+
}
|
|
436
|
+
/** A compact radio group with a highlight that glides to the chosen option. Arrow keys move the choice. */
|
|
437
|
+
function Choice({ label, options, value, onChange, render, name, layoutId, reduced }) {
|
|
438
|
+
const refs = useRef([]);
|
|
439
|
+
function onKeyDown(event) {
|
|
440
|
+
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
|
441
|
+
if (!step)
|
|
442
|
+
return;
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
const index = (options.indexOf(value) + step + options.length) % options.length;
|
|
445
|
+
onChange(options[index]);
|
|
446
|
+
refs.current[index]?.focus();
|
|
447
|
+
}
|
|
448
|
+
return _jsx("div", { className: styles.choice, role: "radiogroup", "aria-label": label, onKeyDown: onKeyDown, children: options.map((option, index) => _jsxs("button", { ref: node => { refs.current[index] = node; }, type: "button", role: "radio", "aria-checked": option === value, "aria-label": name(option), title: name(option), tabIndex: option === value ? 0 : -1, className: styles.option, onClick: () => onChange(option), children: [option === value && _jsx(motion.span, { layoutId: layoutId, className: styles.selected, transition: reduced ? { duration: 0 } : spring.morph }), render(option)] }, option)) });
|
|
449
|
+
}
|
|
450
|
+
export default SignaturePad;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
--ink-black: var(--foreground);
|
|
3
|
+
--ink-blue: oklch(46% .17 262);
|
|
4
|
+
--ink-violet: oklch(49% .2 292);
|
|
5
|
+
display: grid; gap: 10px; width: 100%; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body);
|
|
6
|
+
}
|
|
7
|
+
:global(:root[data-theme="dark"]) .root { --ink-blue: oklch(76% .12 258); --ink-violet: oklch(77% .13 295); }
|
|
8
|
+
|
|
9
|
+
/* The pad keeps the 600 by 260 ratio of its coordinate space, so strokes scale with it at every width. */
|
|
10
|
+
.pad {
|
|
11
|
+
position: relative; aspect-ratio: 600 / 260; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape);
|
|
12
|
+
background: var(--surface); cursor: crosshair; touch-action: none; user-select: none; -webkit-user-select: none; isolation: isolate;
|
|
13
|
+
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
14
|
+
}
|
|
15
|
+
.pad[data-drawing] { border-color: var(--border-strong); }
|
|
16
|
+
|
|
17
|
+
.guide { position: absolute; inset: 0; pointer-events: none; }
|
|
18
|
+
.baseline { position: absolute; left: 7%; right: 7%; top: 72%; border-top: 1.5px dashed var(--border-strong); }
|
|
19
|
+
.mark { position: absolute; left: 7%; top: 72%; color: var(--text-muted); translate: 0 calc(-100% - 6px); }
|
|
20
|
+
.signer { position: absolute; left: 7%; top: calc(72% + 8px); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
|
|
21
|
+
.hint {
|
|
22
|
+
position: absolute; left: 50%; top: 72%; translate: -50% calc(-100% - 8px); color: var(--text-muted);
|
|
23
|
+
font-size: var(--text-lg); line-height: var(--leading-body); white-space: nowrap;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ink { position: absolute; inset: 0; pointer-events: none; }
|
|
27
|
+
.ink svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
|
28
|
+
.stroke { fill: var(--ink-black); }
|
|
29
|
+
.stroke[data-color="blue"], .nib[data-color="blue"] { fill: var(--ink-blue); }
|
|
30
|
+
.stroke[data-color="violet"], .nib[data-color="violet"] { fill: var(--ink-violet); }
|
|
31
|
+
.nib { fill: var(--ink-black); opacity: .35; }
|
|
32
|
+
|
|
33
|
+
/* The squeegee edge that clears the ink as it crosses the pad. */
|
|
34
|
+
.sweep { position: absolute; inset: 0; pointer-events: none; }
|
|
35
|
+
.sweep::before {
|
|
36
|
+
content: ""; position: absolute; top: 8%; bottom: 8%; left: -1px; width: 2px; border-radius: var(--radius-full); corner-shape: round;
|
|
37
|
+
background: var(--foreground); opacity: .5;
|
|
38
|
+
}
|
|
39
|
+
.sweep::after {
|
|
40
|
+
content: ""; position: absolute; top: 0; bottom: 0; left: -28px; width: 28px;
|
|
41
|
+
background: linear-gradient(to right, transparent, color-mix(in oklab, var(--foreground) 5%, transparent));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
|
45
|
+
.group { display: flex; align-items: center; gap: 2px; }
|
|
46
|
+
.divider { width: 1px; height: 20px; margin: 0 6px; background: var(--border); }
|
|
47
|
+
|
|
48
|
+
.choice { display: flex; align-items: center; gap: 2px; }
|
|
49
|
+
.option {
|
|
50
|
+
position: relative; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-full); corner-shape: round;
|
|
51
|
+
background: transparent; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
52
|
+
}
|
|
53
|
+
.selected { position: absolute; inset: 0; border-radius: inherit; corner-shape: inherit; background: var(--surface-muted); box-shadow: inset 0 0 0 1px var(--border); }
|
|
54
|
+
.swatch { position: relative; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--ink-black); }
|
|
55
|
+
.swatch[data-color="blue"] { background: var(--ink-blue); }
|
|
56
|
+
.swatch[data-color="violet"] { background: var(--ink-violet); }
|
|
57
|
+
.weight { position: relative; border-radius: 50%; corner-shape: round; background: var(--foreground); }
|
|
58
|
+
|
|
59
|
+
.iconButton {
|
|
60
|
+
display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-full); corner-shape: round;
|
|
61
|
+
background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
62
|
+
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
|
|
63
|
+
}
|
|
64
|
+
.iconButton:active:not(:disabled) { transform: scale(.94); }
|
|
65
|
+
.iconButton:disabled { color: var(--text-muted); opacity: .45; cursor: default; }
|
|
66
|
+
.iconButton[aria-pressed="true"] { background: var(--surface-muted); color: var(--foreground); }
|
|
67
|
+
.swap { display: grid; place-items: center; }
|
|
68
|
+
|
|
69
|
+
.exports { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
70
|
+
.exportLabel { margin-right: 4px; color: var(--text-muted); font-size: var(--text-xs); }
|
|
71
|
+
.export {
|
|
72
|
+
display: grid; min-width: 76px; height: var(--control-height-sm); padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape);
|
|
73
|
+
background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent;
|
|
74
|
+
transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
|
|
75
|
+
}
|
|
76
|
+
.export:active:not(:disabled) { transform: scale(.97); }
|
|
77
|
+
.export:disabled { color: var(--text-muted); cursor: default; }
|
|
78
|
+
.exportContent { display: inline-flex; grid-area: 1 / 1; align-items: center; justify-content: center; gap: 6px; }
|
|
79
|
+
|
|
80
|
+
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
81
|
+
|
|
82
|
+
@media (hover: hover) and (pointer: fine) {
|
|
83
|
+
.option:hover, .iconButton:hover:not(:disabled), .export:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }
|
|
84
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface SkeletonProps {
|
|
3
|
+
label?: string;
|
|
4
|
+
lines?: number;
|
|
5
|
+
avatar?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
/** Content to reveal once loading finishes. With children, the placeholder crossfades into them. */
|
|
8
|
+
children?: ReactNode;
|
|
9
|
+
/** Keeps the placeholder visible while true. Only used together with children. */
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function Skeleton({ label, lines, avatar, className, children, loading }: SkeletonProps): import("react").JSX.Element;
|
|
13
|
+
export default Skeleton;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
4
|
+
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
|
|
5
|
+
import { motionTokens } from "../../lib/motion-tokens.js";
|
|
6
|
+
import styles from "./skeleton.module.css";
|
|
7
|
+
/** Follows its content height. After `morphKey` changes, the height springs from the old size to the new one and then returns to auto, so passive reflows (a resize, a font swap) follow instantly. It clips only while moving, so focus rings stay visible at rest. */
|
|
8
|
+
function HeightFrame({ className, busy, reduce, children }) {
|
|
9
|
+
const frame = useRef(null);
|
|
10
|
+
const content = useRef(null);
|
|
11
|
+
const height = useMotionValue("auto");
|
|
12
|
+
const changedAt = useRef(0);
|
|
13
|
+
useLayoutEffect(() => { changedAt.current = performance.now(); }, [busy]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const node = content.current;
|
|
16
|
+
if (!node || typeof ResizeObserver === "undefined")
|
|
17
|
+
return;
|
|
18
|
+
let last;
|
|
19
|
+
let controls;
|
|
20
|
+
const settle = () => { height.jump("auto"); if (frame.current)
|
|
21
|
+
Object.assign(frame.current.style, { overflow: "", height: "auto" }); };
|
|
22
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
23
|
+
if (!entry)
|
|
24
|
+
return;
|
|
25
|
+
const next = entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;
|
|
26
|
+
const current = height.get();
|
|
27
|
+
const from = typeof current === "number" ? current : last;
|
|
28
|
+
last = next;
|
|
29
|
+
controls?.stop();
|
|
30
|
+
if (reduce || from === undefined || from === next || performance.now() - changedAt.current > 120)
|
|
31
|
+
return settle();
|
|
32
|
+
// Pin the old height before this frame paints, then spring to the new one.
|
|
33
|
+
if (frame.current)
|
|
34
|
+
Object.assign(frame.current.style, { overflow: "hidden", height: `${from}px` });
|
|
35
|
+
controls = animate(height, [from, next], { ...motionTokens.spring.smooth, onComplete: settle });
|
|
36
|
+
});
|
|
37
|
+
observer.observe(node);
|
|
38
|
+
return () => { observer.disconnect(); controls?.stop(); };
|
|
39
|
+
}, [height, reduce]);
|
|
40
|
+
return _jsx(motion.div, { ref: frame, className: className, "aria-busy": busy, style: { height }, children: _jsx("div", { ref: content, className: styles.frame, children: children }) });
|
|
41
|
+
}
|
|
42
|
+
export function Skeleton({ label = "Loading content", lines = 3, avatar = false, className, children, loading = true }) {
|
|
43
|
+
const reduce = useReducedMotion();
|
|
44
|
+
const count = Math.min(Math.max(Math.floor(lines), 1), 6);
|
|
45
|
+
// Each block pulses a beat after the one above it, so the wait reads as one calm wave.
|
|
46
|
+
const placeholder = (extra) => _jsxs("div", { className: [styles.root, extra].filter(Boolean).join(" "), role: "status", "aria-label": label, "aria-busy": "true", children: [avatar && _jsx("span", { className: styles.avatar, "aria-hidden": "true" }), _jsx("span", { className: styles.lines, "aria-hidden": "true", children: Array.from({ length: count }, (_, index) => _jsx("span", { className: styles.line, style: { "--index": index + (avatar ? 1 : 0) } }, index)) })] });
|
|
47
|
+
if (children === undefined)
|
|
48
|
+
return placeholder(className);
|
|
49
|
+
return _jsx(HeightFrame, { className: className, busy: loading, reduce: reduce, children: _jsx(AnimatePresence, { mode: "popLayout", initial: false, children: loading
|
|
50
|
+
? _jsx(motion.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast, ease: [...motionTokens.ease.standard] }, exit: { opacity: 0, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast } }, children: placeholder() }, "placeholder")
|
|
51
|
+
: _jsx(motion.div, { initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.fast } }, transition: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }, children: children }, "content") }) });
|
|
52
|
+
}
|
|
53
|
+
export default Skeleton;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.root { display: flex; width: 100%; min-width: 0; align-items: flex-start; gap: var(--space-inset); }
|
|
2
|
+
/* A calm, finite pulse: each block dims a beat after the one above it, then rests after about 20 seconds. */
|
|
3
|
+
.avatar, .line { display: block; flex: 0 0 auto; border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: color-mix(in oklab, var(--border-strong) 38%, var(--surface)); animation: skeleton-pulse 1.8s var(--ease-in-out) calc(var(--index, 0) * 90ms) 11 both; }
|
|
4
|
+
.avatar { width: var(--control-height-md); height: var(--control-height-md); border-radius: var(--radius-full); corner-shape: round; }
|
|
5
|
+
.lines { display: grid; flex: 1; gap: var(--space-group); padding-top: var(--space-tight); }
|
|
6
|
+
.line { width: 100%; height: var(--space-group); border-radius: var(--radius-full); corner-shape: round; }
|
|
7
|
+
.line:first-child { width: 48%; height: var(--space-inset); }
|
|
8
|
+
.line:last-child { width: 72%; }
|
|
9
|
+
.frame { position: relative; display: flow-root; }
|
|
10
|
+
@keyframes skeleton-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .52; } }
|
|
11
|
+
@media (prefers-reduced-motion: reduce) { .avatar, .line { animation: none; } }
|